Первая статья из цикла из трёх частей.

Часть 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: domainusecaserepositorygatewayhandlerinfra. У 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-profileportfolio-service и verification-service изолированы NetworkPolicy, поэтому ходить к ним напрямую нельзя; единственная разрешённая точка входа — worker-facadenotification-service слушает match.found и отправляет уведомления, geo-service считает расстояния, а config-service в этом сценарии не участвует.

В проекте заложены шесть архитектурных ловушек. Это обычные для микросервисов ограничения, но LLM легко пропускает их, когда читает сервисы по одному.

  1. Закрытые сервисы нельзя вызывать напрямуюworker-profileportfolio-service и verification-service доступны только через worker-facade; это закреплено NetworkPolicy, то есть правилом, которое ограничивает сетевой доступ к сервисам. Если LLM вызовет их напрямую, код может выглядеть нормальным, но архитектурная граница будет нарушена.

  2. У skill-analyzer один метод анализа текста. В proto есть только AnalyzeText. Методов вроде ExtractSkills или DetectUrgency нет, хотя LLM легко может их придумать по названию задачи.

  3. Данные о городе уже есть в профиле исполнителяworker-profile отдаёт city_nameregion_name и timezonegeo-service нужен не для этих данных, а для расчёта расстояния по city_id.

  4. Имя автора отзыва уже хранится в review-service. В отзыве есть поле author_name, поэтому review-service не должен ходить за именем автора обратно через worker-facade. Иначе легко получить цепочку вызовов review → worker-facade → review.

  5. Массовые запросы должны идти batch-методами. Для этого уже есть GetWorkersBatch и GetDistancesBatch. Иначе подбор кандидатов легко превращается в N+1: вместо одного запроса сервис делает отдельный запрос на каждого кандидата.

  6. Состояние и событие должны записываться вместе. Для этого используется 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
Промпт, который я дал Claude
Уточнение 1 — Claude задаёт вопросы по API и данным
Уточнение 1 — Claude задаёт вопросы по API и данным
Уточнение 2 — Claude уточняет поведение на edge cases
Уточнение 2 — Claude уточняет поведение на edge cases
Три предложенных подхода — я выбрал event-driven
Три предложенных подхода — я выбрал 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[i] = [2]string{c.cityID, city}
}

Получаются разные форматы в одном запросе: например city-1 и Moscowgeo-service не может корректно посчитать расстояние, и система может выбрать не ближайшего кандидата.

Это не ошибка одного поля или одного метода. Контракт должен был явно сказать, какое представление города передаётся между task-servicematching-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 — на подходе.