Why Spec-Driven Development Doesn't Work Well for Microservices: Part 1. Where Context Gets Lost

I work at a large product company with a thousand microservices. In such a system, even a small feature often traverses multiple services, events, and internal contracts.

1. Introduction

I work at a large product company with a thousand microservices. In a system like this, even a small feature often spans multiple services, events, and internal contracts. LLM-powered spec-driven development is already used in some teams for feature planning and review, so it was important for me to understand where this approach delivers value, and where it starts to fall short. As long as a task stays within a single service, everything usually runs smoothly: the spec is short, and the description and implementation fit within the model's context window. But as soon as a feature spans multiple services, problems start to emerge. Individually, each component looks perfectly fine: layered architecture, naming that adheres to code style guidelines, passing tests and code reviews. But the system as a whole does not function as intended. Typical errors include: lack of idempotency, LLM missing scenarios and edge cases, and circular service calls. The more changes you make, the more errors it introduces.

For the experiment, I set up a separate test environment: Go project — a platform for finding freelancers. It contains 12 microservices connected via gRPC and a message broker; NATS serves as the message broker in this project. Some services store tasks and freelancer profiles, others match candidates, calculate distances, review portfolios, and send notifications. The project was intentionally designed with six categories of architectural pitfalls: these issues do not manifest within a single service, but at the boundaries between services.

The experimental feature was designed as follows: if a selected freelancer declines an offer, the platform must automatically find the next suitable candidate, send them a new offer, and notify the client of the reassignment.

Claude wrote the spec, implementation, and unit tests, but the full decline and reassignment scenario did not work end-to-end. Two independent reviews found the exact same set of errors: individually, the services looked fine, but they did not work as intended when integrated.

You can respond to this by saying that an end-to-end test for the entire scenario is needed, but this does not fully resolve the issue. End-to-end tests are not available everywhere, they are expensive to maintain, and they do not cover all branching paths: especially rare edge cases, duplicate events, race conditions, and rare combinations of conditions. The key issue lies elsewhere: during the spec-driven development phase, the model is intended to help gather requirements, constraints, and context, and this is exactly where it often makes mistakes.

A developer also does not always know in advance where a problem is hidden. They may be aware of Outbox, notification deduplication, or specific input data requirements for a particular service, but fail to formulate these as constraints for a new feature. The LLM reads service documentation, asks clarifying questions, and can still miss the connection between them.

As a result, the spec ends up detailed but incomplete: it includes service-specific local changes, but lacks the system-wide invariants that apply across services. The implementation may be properly layered, individual component tests may pass, but the error is only discovered at the scenario or review stage.

2. Demo Project

The setup is as follows: twelve Go microservices, gRPC for synchronous calls, and a message broker for asynchronous events. Each service follows the same Clean Architecture structure: domain, usecase, repository, gateway, handler, infra. task-service and matching-service both have a Transactional Outbox to ensure state changes and corresponding events are written together.

Brief overview of the services: api-gateway accepts client requests and proxies them via gRPC, task-service stores tasks and publishes the task.created event via Outbox, while matching-service orchestrates the matching workflow: it invokes skill-analyzer, then worker-facade, then review-service, ranks candidates, and publishes the match.found event. The internal services worker-profile, portfolio-service and verification-service are isolated by NetworkPolicy, so they cannot be accessed directly; the only permitted entry point is worker-facade. notification-service listens for the match.found event and sends out notifications, geo-service calculates distances, and config-service does not take part in this scenario.

The project includes six built-in architectural pitfalls. These are common constraints in microservice architectures, but the LLM easily overlooks them when processing services individually.

  1. Closed services cannot be called directly. worker-profile, portfolio-service and verification-service are only accessible via worker-facade; this is enforced by the NetworkPolicy, a rule that restricts network access to services. If an LLM calls them directly, the code may look functionally correct, but the architectural boundary will be violated.

  2. The skill-analyzer has only one text analysis method. The proto only defines the AnalyzeText method. Methods such as ExtractSkills or DetectUrgency do not exist, even though an LLM can easily invent them based on the task name.

  3. City data is already included in the contractor's profile. worker-profile provides city_name, region_name and timezone. geo-service is not needed for this data; it is used to calculate distance based on city_id.

  4. The review author's name is already stored in review-service. Reviews have an author_name field, so review-service should not fetch the author's name back via worker-facade. Otherwise, you can easily end up with a call chain of review → worker-facade → review.

  5. Bulk requests should be sent via batch methods. Batch methods for this purpose already exist: GetWorkersBatch and GetDistancesBatch. Otherwise, candidate selection easily turns into an N+1 problem: instead of one request, the service sends a separate request for every candidate.

  6. State and events must be written together. The Outbox pattern is used for this, for example the CreateWithEvent and UpdateWithEvent methods, while the consumer performs event deduplication: it stores already processed keys and discards duplicates. If state writing and event publishing are separated, you can end up with data desynchronization.

3. Task

The experimental feature was named Smart Task Reassignment. In business terms, this is automatic task reassignment: the client has already identified a suitable freelancer, but the freelancer has declined the offer. At this point, the platform should not route the task to manual processing, but instead automatically select the next candidate and send them a new offer.

Reassignment rules:

  • A freelancer's decline triggers a new candidate search;

  • Candidates are ranked by their rating, and in case of equal ratings, by distance to the task's city;

  • The client receives a notification about the reassignment;

  • After three failed reassignments, the task transitions to failed.

These requirements omit the most critical details: where the boundary of closed services lies, what key is used for notification deduplication, what format to use to pass the city, when a batch request is needed, and which service should atomically write the state alongside the event.

It is these exact details that ultimately determine whether the feature will work as intended.

4. How I Ran the Experiment

Each service has its own CLAUDE.md that outlines its scope of responsibility, public RPCs, events, and the list of services it can call. A project-level CLAUDE.md sits above them, containing a list of all services and links to architectural documentation. The idea was simple: before implementation, Claude Code should read these files and understand the overall project structure.

For planning, I used the superpowers:brainstorming skill on Sonnet 4.6. I shared a prompt describing the feature, Claude asked two clarifying questions and proposed three implementation options. I selected the event-driven option, after which Claude prepared a full specification.

How the brainstorming session actually unfolded

Claude provided a plan for approximately 180 lines and then implemented the feature based on that plan.

Only then did I run two independent reviews: Claude Opus in a separate session and Codex with a comparison against the reference solution and the project checklist. Both reviews showed the same thing: the complete failure and reassignment scenario does not work as it should.

5. Inter-service errors in the implementation

For the plan, Claude attached a sequence diagram:

The diagram accounts for important things: Outbox, idempotency, and distance calculation to select the nearest candidate in case of an equal rating. If you only read the plan, especially without running the entire scenario, there's almost nothing to criticize; the problems start lower, already in the implementation.

5.1. Idempotency key collision

notification-service deduplicates match.found events by match_id: if an event with that key has already arrived, the service discards it. Therefore, each new offer must have its own match_id: the primary offer and each reassignment are different events for the consumer.

In Claude's implementation, all reassignments receive the same match_id. The model stores one MatchResult record and republishes it after each rejection, without creating a new key for the new attempt.

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)
}

The first notification will be sent, and subsequent reassignments will be rejected as duplicates. For the system, this is critical: a new offer is created, but the event consumer did not process it.

Local tests do not highlight this. notification-service correctly discards duplicates, and matching-service correctly publishes the match found event. The error appears only in combination: different offer attempts should not use the same idempotency key.

During code review, such a bug is easy to miss because the rule "one match_id per offer attempt" is not documented for either the sender or the receiver of the event. This rule pertains to their interaction, not to the logic of a single service.

5.2. New write path without Outbox

There is a rule in the project: if an operation changes state and needs to send an event, the state and event are written together via Outbox. For example, task-service does this via CreateWithEvent and UpdateWithEvent.

In Claude's implementation, api-gateway publishes offer.declined directly to the message broker. After that, matching-service receives the event and records the decline in task-service with a separate call.

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
}

The problem is that the event and state change are no longer atomic. The event may be sent to the message broker, but the decline record may not be written to task-service. Or vice versa, a duplicate event may be processed as a new decline.

Locally, this looks like a normal event-driven scheme: the gateway accepted the request, sent the event, and the matching-service processed it. But this operation requires a shared contract: since declining an offer changes the task state, the decline event should be emitted via the Outbox of the service that owns the task state.

5.3. N+1 when fetching ratings

Ranking requires ratings for all candidates. In Claude's implementation, matching-service calls GetAverageRating inside a loop: one candidate corresponds to one request to 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),
    })
}

If there are twenty candidates, the service makes twenty network calls to fetch ratings. The correct approach would have been to add a batch method and retrieve all ratings in a single request.

This is not a compilation error, nor does it violate the existing proto: review-service only has GetAverageRating for a single contractor. The mistake lies elsewhere: when making inter-service calls inside a loop, you must explicitly check whether a batch API is needed. This rule was not documented in this spec.

5.4. Missing State Transition

If candidates run out before the three reassignment limit is reached, the task should be moved to the final failed status. In Claude's implementation, matching-service only writes a log and exits without changing the task's state.

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
}

As a result, the task remains in its previous status, e.g. open or assigned, even though there are no more candidates left. The client does not receive a notification, since the task.failed event is not published.

The rule here is simple: an unrecoverable scenario must update the state, not just log the issue. The requirements described the limit of three reassignments, but the scenario where candidates run out before reaching this limit was left implicit. The model failed to account for it.

5.5. Two More Errors, Briefly

Incompatible City Representations

City identifiers are required to calculate distance. In task-service, the task has a City field that stores the displayed city name, such as Moscow. Meanwhile, geo-service works with pairs of city identifiers in the GetDistancesBatch method.

In Claude's implementation, matching-service passes a pair consisting of the executor's city_id and the City string from the task to GetDistancesBatch:

candidates
pairs := make([][2]string, len(candidates))
for i, c := range candidates {
    pairs[i] = [2]string{c.cityID, city}
}

This results in different formats in a single request: for example, city-1 and Moscow. geo-service cannot calculate the distance correctly, so the system may select a candidate that is not the closest.

This is not an error of a single field or a single method. The contract should have explicitly specified which city representation is passed between task-service, matching-service and geo-service: the displayed name or city_id.

Ignored Clarification

During the brainstorming session, Claude asked what the external call for declining an offer should look like. I replied: DeclineOffer(taskId). The executor should be retrieved from the auth token on the server, not from the client request.

The implementation ended up including a DeclineOffer(taskId, workerId) method that accepts worker_id from the client. As a result, the client can pass another person's worker_id and decline an offer on behalf of a different executor.

My clarification was specifically intended to mitigate this risk, but it was omitted from the final implementation.

6. Why This Happens

The problem is not that Claude failed to read the files. It read the CLAUDE.md file covering the services, asked clarifying questions, and wrote a detailed specification. However, these documents mostly described each service individually, rather than the rules that connect multiple services.

Here are the rules that were not explicitly documented:

  • A new offer must be assigned a new match_id, otherwise the event recipient will treat it as a duplicate;

  • The operation that modifies a task's state and sends an event must go through Outbox;

  • If a service calls another service in a loop, you need to check whether a batch API is required;

  • If a scenario cannot be continued any further, the task must transition to a final status instead of only writing a log;

  • The contract must specify the city format: either the display name or city_id.

End-to-end tests help catch some of these errors, but usually only after implementation is complete. I wanted to move the check earlier: to the point where the LLM gathers requirements and proposes a plan. For this, you don't just need Markdown with service descriptions, but a structured contract: a call graph, endpoint contracts, idempotency keys, Outbox rules, batch calls, and state transitions.

Such a contract can be validated at commit time, displayed as an easy-to-understand diff in pull requests, and provided to the LLM as context before implementation. Freeform Markdown is not suitable for this: it easily goes out of date, displays structural diffs poorly, and does not force explicit description of rules between services.

7. What's Next

The answer I arrived at is: you don't need yet another freeform Markdown document, but a machine-readable contract for each service. It should include not just endpoints and dependencies, but also rules that are usually lost between services: idempotency keys, Outbox rules, batch calls, state transitions, and data formats at service boundaries.

In the second part, I will show /archspec:init. It iterates over all twelve services, extracts endpoints, dependencies, and message broker topics from the code, then assembles a YAML architecture contract for each service. Based on this specification, archspec generates C1/C2 and sequence diagrams that are easy for humans to read and review in pull requests alongside the architecture contract diff.

In the third part, I will revisit Smart Task Reassignment — the feature for automatically reassigning a task after a freelancer becomes unavailable — via /archspec:investigate. There, the tool reads the contracts of the affected services before implementation, proposes changes to the architecture specification, and outputs a plan that already accounts for the cross-service constraints outlined in this article.

Both repositories are public:

archspec is already available to try as a plugin. If you find a bug, an inconvenient scenario, or a missing rule, please open an issue in the repository.

Comments

    Also read