
Первая статья из цикла из трёх частей.
Часть 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 выдал план примерно на 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[i] = [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: https://github.com/krus210/archspecfreelance-marketplace(демо-проект): https://github.com/krus210/freelance-marketplace
archspec уже можно пробовать как плагин. Если найдёте баг, неудобный сценарий или правило, которого не хватает, заводите issue в репозитории.
Часть 2 — на подходе.
