|
Professor Seleznov
|
Первая статья из цикла из трёх частей. Часть 1 — где LLM теряет межсервисный контекст и почему локальных спек недостаточно. Часть 2 — archspec: версионируемый архитектурный контракт для сервисов. Часть 3 — archspec: исследование фичи, обновление контрактов и реализация.
1. Вступление Я работаю в большой продуктовой компании с тысячей микросервисов. В такой системе даже небольшая фича часто проходит через несколько сервисов, событий и внутренних контрактов. Spec-driven development с LLM уже применяется в некоторых командах для планирования и ревью фич, поэтому мне было важно понять, где этот подход помогает, а где начинает ошибаться. Пока задача живёт внутри одного сервиса, всё обычно идёт быстро: спека короткая, описание и реализация помещаются в контекст модели. Но как только фича проходит через несколько сервисов, начинаются проблемы. По отдельности каждый кусок выглядит нормально: разбиение на слои, именование по код стайлу, прохождение тестов и ревью. Но в целом система не работает должным образом. Типичные ошибки: нет идемпотентности, LLM упускает сценарии и edge case-ы, появляются циклические вызовы сервисов. Чем больше делаешь правок, тем больше ошибок она допускает. Для эксперимента я собрал отдельный стенд: Go-проект - платформа для поиска фрилансеров. Внутри 12 микросервисов, связанных через gRPC и брокер сообщений; в этом проекте брокером выступает NATS. Одни сервисы хранят задачи и профили исполнителей, другие подбирают кандидатов, считают расстояния, проверяют портфолио и отправляют уведомления. Проект специально спроектирован с шестью категориями архитектурных ловушек: они проявляются не внутри одного сервиса, а на границах между сервисами. Фича для эксперимента была такой: если выбранный фрилансер отказался от оффера, платформа должна автоматически найти следующего подходящего кандидата, отправить ему новый оффер и уведомить заказчика о переназначении. Claude написал спеку, реализацию и юнит-тесты, но полный сценарий отказа и переназначения не сошёлся. Два независимых ревью нашли одну и ту же группу ошибок: по отдельности сервисы выглядели нормально, а вместе работали не так, как нужно. На это можно ответить, что нужен end-to-end тест на весь сценарий, но это не закрывает проблему целиком. End-to-end тесты есть не везде, их дорого поддерживать, и они не покрывают все развилки: особенно редкие edge case-ы, дубликаты событий, гонки и редкие комбинации условий. Главное же в другом: на этапе spec-driven разработки модель должна помочь собрать требования, ограничения и контекст, а именно там она часто ошибается. Разработчик тоже не всегда заранее знает, где спрятана проблема. Он может помнить про Outbox, дедупликацию уведомлений или особые требования конкретного сервиса к входным данным, но не сформулировать это как ограничение для новой фичи. LLM читает документы по сервисам, задаёт уточняющие вопросы и всё равно может пропустить связь между ними. В итоге спека получается подробной, но неполной: в ней есть локальные изменения по сервисам, зато нет системных инвариантов, которые живут между сервисами. Реализация может быть нормально разложена по слоям, тесты отдельных компонентов проходят, а ошибка обнаруживается уже на уровне сценария или ревью. 2. Демо-Проект Сетап такой: двенадцать Go-микросервисов, gRPC для синхронных вызовов и брокер сообщений для асинхронных событий. Каждый сервис собран по одной схеме Clean Architecture: domain, usecase, repository, gateway, handler, infra. У task-service и matching-service есть Transactional Outbox, чтобы изменение состояния и событие записывались вместе.
 Коротко по сервисам: api-gateway принимает клиентские запросы и проксирует их в gRPC, task-service хранит задачи и публикует task.created через Outbox, а matching-service оркестрирует подбор: вызывает skill-analyzer, затем worker-facade, затем review-service, ранжирует кандидатов и публикует match.found. Закрытые сервисы worker-profile, portfolio-service и verification-service изолированы NetworkPolicy, поэтому ходить к ним напрямую нельзя; единственная разрешённая точка входа — worker-facade. notification-service слушает match.found и отправляет уведомления, geo-service считает расстояния, а config-service в этом сценарии не участвует. В проекте заложены шесть архитектурных ловушек. Это обычные для микросервисов ограничения, но LLM легко пропускает их, когда читает сервисы по одному.
- Закрытые сервисы нельзя вызывать напрямую. worker-profile, portfolio-service и verification-service доступны только через worker-facade; это закреплено NetworkPolicy, то есть правилом, которое ограничивает сетевой доступ к сервисам. Если LLM вызовет их напрямую, код может выглядеть нормальным, но архитектурная граница будет нарушена.
- У skill-analyzer один метод анализа текста. В proto есть только AnalyzeText. Методов вроде ExtractSkills или DetectUrgency нет, хотя LLM легко может их придумать по названию задачи.
- Данные о городе уже есть в профиле исполнителя. worker-profile отдаёт city_name, region_name и timezone. geo-service нужен не для этих данных, а для расчёта расстояния по city_id.
- Имя автора отзыва уже хранится в review-service. В отзыве есть поле author_name, поэтому review-service не должен ходить за именем автора обратно через worker-facade. Иначе легко получить цепочку вызовов review → worker-facade → review.
- Массовые запросы должны идти batch-методами. Для этого уже есть GetWorkersBatch и GetDistancesBatch. Иначе подбор кандидатов легко превращается в N+1: вместо одного запроса сервис делает отдельный запрос на каждого кандидата.
- Состояние и событие должны записываться вместе. Для этого используется Outbox, например CreateWithEvent и UpdateWithEvent, а получатель делает дедупликацию событий: запоминает уже обработанные ключи и отбрасывает повторы. Если запись состояния и публикацию события разнести, можно получить рассинхрон.
3. Задача Задача для эксперимента называлась Smart Task Reassignment. По бизнес‑смыслу это автоматическое переназначение задачи: заказчик уже получил подходящего фрилансера, но фрилансер отказался от оффера. В этот момент платформа должна не бросать задачу в ручную обработку, а сама выбрать следующего кандидата и отправить ему новый оффер. Правила переназначения:
- отказ фрилансера запускает новый подбор;
- кандидаты ранжируются по рейтингу, а при равном рейтинге — по расстоянию до города задачи;
- заказчик получает уведомление о переназначении;
- после трёх неудачных переназначений задача переходит в failed.
В этих требованиях не видно главного: где проходит граница закрытых сервисов, какой ключ используется для дедупликации уведомлений, в каком формате передавать город, когда нужен batch-запрос и какой сервис должен атомарно записывать состояние вместе с событием. Именно эти детали потом решают, будет ли фича работать. 4. Как я запускал эксперимент У каждого сервиса есть свой CLAUDE.md: зона ответственности, открытые RPC, события и список сервисов, к которым можно обращаться. Над ними лежит проектный CLAUDE.md со списком всех сервисов и ссылками на архитектурные доки. Идея была простая: перед реализацией Claude Code должен прочитать эти файлы и понять общую схему проекта. Для планирования я использовал скилл superpowers:brainstorming на Sonnet 4.6. Я дал промпт с описанием фичи, Claude задал два уточняющих вопроса и предложил три варианта реализации. Я выбрал event-driven вариант, после чего Claude подготовил полную спеку.
Как на самом деле прошла сессия брейншторма
Промпт, который я дал Claude
Уточнение 1 — Claude задаёт вопросы по API и данным
Уточнение 2 — Claude уточняет поведение на edge cases
Три предложенных подхода — я выбрал event-driven
Claude выдал план примерно на 180 строк, а затем реализовал фичу по этому плану. Только затем я прогнал два независимых ревью: Claude Opus в отдельной сессии и Codex со сверкой по эталонному решению и проектному чек-листу. Оба ревью показали одно и то же: полный сценарий отказа и переназначения работает не так, как должен. 5. Межсервисные ошибки в реализации К плану Claude приложил sequence-диаграмму:
 На схеме учтены важные вещи: Outbox, идемпотентность и расчёт расстояний, чтобы при равном рейтинге выбрать ближайшего кандидата. Если читать только план, особенно без запуска сценария целиком, то придраться почти не к чему; проблемы начинаются ниже, уже в реализации. 5.1. Коллизия ключа идемпотентности notification-service дедуплицирует события match.found по match_id: если событие с таким ключом уже приходило, сервис его отбрасывает. Поэтому у каждого нового оффера должен быть свой match_id: первичный оффер и каждое переназначение — это разные события для потребителя. В реализации Claude все переназначения получают один и тот же match_id. Модель хранит одну запись MatchResult и заново публикует её после каждого отказа, не создавая новый ключ для новой попытки.
HandleMatchFound
func (uc *NotificationUseCase) HandleMatchFound(event domain.MatchFoundEvent) { dedupKey := event.MatchID if dedupKey == "" { dedupKey = event.TaskID } if !uc.dedup.MarkProcessed(dedupKey) { log.Printf("duplicate match.found for %s, skipping", dedupKey) return } log.Printf("[STUB] client notified: task %s reassigned to worker %s", event.TaskID, event.WorkerID) }
Первое уведомление отправится, а следующие переназначения будут отброшены как дубликаты. Для системы это критично: новый оффер создан, но потребитель события его не обработал. Локальные тесты это не подсвечивают. notification-service правильно отбрасывает дубликаты, а matching-service правильно публикует событие о найденном матче. Ошибка появляется только в связке: разные попытки оффера не должны использовать один и тот же ключ идемпотентности. На ревью такой баг легко пропустить, потому что правило "один match_id на одну попытку оффера" не записано ни у отправителя, ни у получателя события. Это правило относится к их взаимодействию, а не к логике одного сервиса. 5.2. Новый путь записи без Outbox В проекте есть правило: если операция меняет состояние и должна отправить событие, состояние и событие записываются вместе через Outbox. Например, task-service делает это через CreateWithEvent и UpdateWithEvent. В реализации Claude api-gateway публикует offer.declined напрямую в брокер сообщений. После этого matching-service получает событие и отдельным вызовом записывает отказ в task-service.
DeclineOffer
func (h *GatewayGRPCHandler) DeclineOffer(ctx context.Context, req *gatewayv1.DeclineOfferRequest) (*gatewayv1.DeclineOfferResponse, error) { if req.GetTaskId() == "" || req.GetWorkerId() == "" { return nil, status.Error(codes.InvalidArgument, "task_id and worker_id are required") } if h.nc == nil { return nil, status.Error(codes.Unavailable, "messaging unavailable") } payload, err := json.Marshal(offerDeclinedPayload{TaskID: req.GetTaskId(), WorkerID: req.GetWorkerId()}) if err != nil { return nil, status.Errorf(codes.Internal, "marshal: %v", err) } if err := h.nc.Publish(natsSubjectOfferDeclined, payload); err != nil { return nil, status.Errorf(codes.Internal, "publish: %v", err) } return &gatewayv1.DeclineOfferResponse{Success: true}, nil }
Проблема в том, что событие и изменение состояния больше не атомарны. Событие может уйти в брокер сообщений, а запись отказа в task-service не произойдёт. Или наоборот, повторное событие может быть обработано как новый отказ. Локально это выглядит как нормальная event-driven схема: gateway принял запрос, отправил событие, matching-service его обработал. Но для этой операции нужен общий контракт: отказ от оффера меняет состояние задачи, значит событие отказа должно появляться через Outbox того сервиса, который владеет состоянием задачи. 5.3. N+1 при получении рейтингов Для ранжирования нужны рейтинги всех кандидатов. В реализации Claude matching-service вызывает GetAverageRating внутри цикла: один кандидат — один запрос в review-service.
GetAverageRating
candidates := make([]candidateEntry, 0, len(workers)) for _, w := range workers { rating, err := uc.ratings.GetAverageRating(ctx, w.ID) if err != nil { log.Printf("failed to get rating for worker %s: %v", w.ID, err) continue } candidates = append(candidates, candidateEntry{ workerID: w.ID, name: w.Name, cityID: w.CityID, rating: rating, distanceKm: math.Inf(1), }) }
Если кандидатов двадцать, сервис делает двадцать сетевых вызовов за рейтингами. Правильнее было добавить batch-метод и получить рейтинги одним запросом. Это не ошибка компиляции и не нарушение существующего proto: в review-service есть только GetAverageRating для одного исполнителя. Ошибка в другом: при межсервисном вызове в цикле нужно явно проверять, нужен ли batch API. В этой спеке такое правило не было записано. 5.4. Потерянный переход состояния Если кандидаты закончились раньше лимита в три переназначения, задачу нужно перевести в финальный статус failed. В реализации Claude matching-service только пишет лог и выходит, не меняя состояние задачи.
Log
if int(count) >= len(result.Candidates) { log.Printf("ProcessOfferDeclined: candidates exhausted for task %s (count=%d, candidates=%d)", taskID, count, len(result.Candidates)) return }
В результате задача остаётся в прежнем статусе, например open или assigned, хотя следующего кандидата уже нет. Клиент не получает уведомление, потому что событие task.failed не публикуется. Правило здесь простое: невосстанавливаемый сценарий должен менять состояние, а не только логировать проблему. В требованиях был описан лимит в три переназначения, но ситуация, когда кандидаты закончились раньше лимита, осталась неявной. Модель её не достроила. 5.5. Ещё две ошибки, коротко Несовместимые представления города Для расчёта расстояния нужны идентификаторы городов. В task-service у задачи есть поле City, где лежит отображаемое имя города вроде Moscow. А geo-service в GetDistancesBatch работает с парами идентификаторов городов. В реализации Claude matching-service передаёт в GetDistancesBatch пару из city_id исполнителя и строки City из задачи:
candidates
pairs := make([][2]string, len(candidates)) for i, c := range candidates { pairs = [2]string{c.cityID, city} }
Получаются разные форматы в одном запросе: например city-1 и Moscow. geo-service не может корректно посчитать расстояние, и система может выбрать не ближайшего кандидата. Это не ошибка одного поля или одного метода. Контракт должен был явно сказать, какое представление города передаётся между task-service, matching-service и geo-service: отображаемое имя или city_id. Проигнорированное уточнение Во время брейншторма Claude спросил, как должен выглядеть внешний вызов отказа от оффера. Я ответил: DeclineOffer(taskId). Исполнителя нужно брать из auth-токена на сервере, а не из запроса клиента. В реализации появился метод DeclineOffer(taskId, workerId), который принимает worker_id от клиента. В итоге клиент может передать чужой worker_id и отказаться от оффера за другого исполнителя. Моё уточнение как раз должно было закрыть этот риск, но в финальной реализации оно потерялось. 6. Почему это происходит Проблема не в том, что Claude не прочитал файлы. Он прочитал CLAUDE.md по сервисам, задал уточняющие вопросы и написал подробную спеку. Но эти документы описывали в основном каждый сервис отдельно, а не правила, которые связывают несколько сервисов. Вот какие правила не были явно зафиксированы:
- новый оффер должен получать новый match_id, иначе получатель события примет его за дубликат;
- операция, которая меняет состояние задачи и отправляет событие, должна идти через Outbox;
- если сервис вызывает другой сервис в цикле, нужно проверить, нужен ли batch API;
- если сценарий дальше продолжить нельзя, задача должна перейти в финальный статус, а не просто записать лог;
- в контракте должен быть указан формат города: отображаемое имя или city_id.
End-to-end тесты помогают поймать часть таких ошибок, но обычно уже после реализации. Мне хотелось сдвинуть проверку раньше: на момент, когда LLM собирает требования и предлагает план. Для этого нужен не только Markdown с описанием сервисов, а структурный контракт: граф вызовов, контракты ручек, ключи идемпотентности, правила Outbox, batch-вызовы и переходы состояния. Такой контракт можно валидировать на коммите, показывать в PR как понятный диф и давать LLM как контекст перед реализацией. Свободный Markdown для этого не подходит: он легко устаревает, плохо показывает структурный диф и не заставляет явно описывать правила между сервисами. 7. Что дальше Ответ, к которому я пришёл: нужен не ещё один свободный Markdown, а машиночитаемый контракт на каждый сервис. В нём должны быть не только endpoints и зависимости, но и правила, которые обычно теряются между сервисами: ключи идемпотентности, Outbox, batch-вызовы, переходы состояния и формат данных на границах. Во второй части я покажу /archspec:init. Он проходит по всем двенадцати сервисам, вытаскивает из кода endpoints, зависимости и топики брокера сообщений, а затем собирает для каждого сервиса YAML-контракт архитектуры. На основании этой спеки archspec генерирует C1/C2 и sequence-диаграммы, которые легко читать человеку и ревьюить в PR вместе с дифом архитектурного контракта. В третьей части я вернусь к Smart Task Reassignment — фиче автоматического переназначения задачи после отказа фрилансера — через /archspec:investigate. Там инструмент читает контракты затронутых сервисов до реализации, предлагает изменения в архитектурной спеке и выдаёт план, где уже учтены межсервисные ограничения из этой статьи. Оба репозитория открыты:
archspec уже можно пробовать как плагин. Если найдёте баг, неудобный сценарий или правило, которого не хватает, заводите issue в репозитории. Часть 2 — на подходе.-Источник
|