·Spec-Driven Development·3 min read·Senior developers

OpenSpec in a Monorepo: Keeping AI-Generated Code Consistent Across 12 Services

Specs work great for one feature. They start to fight each other in a monorepo with a dozen services, each prompting AI tools with its own conventions. Here's how to share specs without making every team write YAML by hand.

The auth service had a clean OpenSpec setup. The billing team rolled their own. The payments service had no specs and was generating code that called the auth service incorrectly. Each team's AI assistant was confident. The system, taken as a whole, was inconsistent. The problem wasn't the specs. It was that they were islands.

If you've ever felt your monorepo tilt slightly toward chaos, this is the same energy.

The monorepo-specific problem

A single-service OpenSpec setup is easy. One folder of specs, one validator, one prompt template. In a monorepo, you have N services and M shared concepts. Auth, billing IDs, error envelopes, observability conventions. If each service redefines those concepts in its own specs, you've just moved the inconsistency from prompts to YAML files. Slightly more structured chaos.

The fix is the same as for any large codebase. A shared layer. Treat the cross-cutting specs as a package and import them.

Layout that scales

# /specs/shared/auth.openspec.yaml — owned by platform team
concept: AuthContext
fields:
  user_id: string  # uuid
  tenant_id: string
  roles: list[string]
invariants:
  - user_id matches uuid v4
  - tenant_id is non-empty
  - "admin" role only present if user has 2FA enabled
# /services/billing/specs/charge_card.openspec.yaml
feature: charge_card
imports:
  - shared/auth.openspec.yaml
input:
  auth: AuthContext   # imported concept
  amount_cents: int
  card_token: string
output:
  charge_id: string
  status: enum[succeeded, failed, pending]
invariants:
  - amount_cents > 0
  - on status=succeeded, charge_id is non-empty
  - auth.roles contains "billing:write"

Why it works

The shared concept is defined once. Every service that touches AuthContext imports it instead of redefining it. The AI assistant sees consistent fields, consistent invariants, consistent semantics across services. The code it generates for service A is shaped to fit service B's expectations. Conway's Law works against you in a monorepo. Shared specs are how you push back.

When to introduce the shared layer

Once you have three services or more all referring to the same concept (User, Order, Tenant) in slightly different ways. The pain shows up before the layer does. Listen to your own code reviews. If you keep saying "make this match the auth service," extract that into shared specs and stop saying it.

When to keep specs per-service

Greenfield monorepos with fewer than three services, or repos where services genuinely have nothing in common. An internal CMS and a public marketing site, for instance. Premature shared infrastructure is the same problem as any other premature abstraction. I've cleaned up the wreckage more than once.

Spec ownership across the repo

flowchart TD
    subgraph Shared ["/specs/shared - platform team owns"]
        A[auth.openspec.yaml]
        E[errors.openspec.yaml]
        I[ids.openspec.yaml]
    end
    subgraph S1 ["services/auth"]
        F1[login.openspec.yaml]
        F2[refresh.openspec.yaml]
    end
    subgraph S2 ["services/billing"]
        F3[charge_card.openspec.yaml]
        F4[refund.openspec.yaml]
    end
    subgraph S3 ["services/notifications"]
        F5[send_email.openspec.yaml]
    end
    A --> F1
    A --> F3
    A --> F5
    E --> F1
    E --> F3
    I --> F3
    I --> F4

Conclusion

Find the concept your services define inconsistently most often. Probably "user" or "error." Write that spec first. The shared layer earns its keep when it deletes duplication, not when it adds policy. Start with one shared concept, prove it, then grow.