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 --> F4Conclusion
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.