Почему spec-driven development плохо работает на микросервисах: часть 1. Где теряется контекст. claude code.. claude code. Clean Architecture.. claude code. Clean Architecture. code review.. claude code. Clean Architecture. code review. Go.. claude code. Clean Architecture. code review. Go. llm.. claude code. Clean Architecture. code review. Go. llm. microservices.. claude code. Clean Architecture. code review. Go. llm. microservices. Spec-Driven Development.. claude code. Clean Architecture. code review. Go. llm. microservices. Spec-Driven Development. system design.. claude code. Clean Architecture. code review. Go. llm. microservices. Spec-Driven Development. system design. Анализ и проектирование систем.. claude code. Clean Architecture. code review. Go. llm. microservices. Spec-Driven Development. system design. Анализ и проектирование систем. архитектура.. claude code. Clean Architecture. code review. Go. llm. microservices. Spec-Driven Development. system design. Анализ и проектирование систем. архитектура. искусственный интеллект.. claude code. Clean Architecture. code review. Go. llm. microservices. Spec-Driven Development. system design. Анализ и проектирование систем. архитектура. искусственный интеллект. Микросервисы.. claude code. Clean Architecture. code review. Go. llm. microservices. Spec-Driven Development. system design. Анализ и проектирование систем. архитектура. искусственный интеллект. Микросервисы. Проектирование и рефакторинг.. claude code. Clean Architecture. code review. Go. llm. microservices. Spec-Driven Development. system design. Анализ и проектирование систем. архитектура. искусственный интеллект. Микросервисы. Проектирование и рефакторинг. Распределённые системы.
Почему spec-driven development плохо работает на микросервисах: часть 1. Где теряется контекст - 1

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

Часть 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, чтобы изменение состояния и событие записывались вместе.

Почему spec-driven development плохо работает на микросервисах: часть 1. Где теряется контекст - 2

Коротко по сервисам: 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-диаграмму:

Почему spec-driven development плохо работает на микросервисах: часть 1. Где теряется контекст - 7

На схеме учтены важные вещи: 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 — на подходе.

Автор: krus210

Источник