SSOT + Invariants Flow
Not everything needs a config file or an API route on day one. This flow helps you decide what to centralize and when.
The decision pipeline
Every new feature passes through these gates. Most stop at step two.
SSOT feeds hotspots
One source, many consumers. Agents read from the SSOT. They never write to each other.
Decision checklist
Run through these five questions for every piece of shared state. If the answer is no at any point, stop. You do not need more infrastructure yet.
Is this value used by more than one file?
Yes
Move it to a central config or data file (SSOT).
No
Keep it local. You can extract later when a second consumer appears.
Could two agents edit this value independently?
Yes
It needs a single owner file with a clear import path.
No
Co-location is fine. One file, one owner.
Does this value have a shape contract (types, enums)?
Yes
Define the TypeScript interface next to the data. Export both.
No
Add a type now. Untyped shared state drifts within days.
If this value is wrong, does the build break?
Yes
That is an invariant. Add a typecheck or build-time assertion.
No
It is a preference, not a rule. Document it, do not enforce it.
Does this need a server round-trip (API, webhook, DB)?
Yes
Wire the infra. Define the route, guard it with auth, add error handling.
No
Keep it client-side or static. Do not add infra you do not need yet.
When each layer shows up
Four real examples showing when SSOT, invariants, and infra enter the picture. None of them arrive at the same time.
Auth (Clerk)
Module 0 — environment setup- SSOT
- Clerk keys in .env.local, middleware in proxy.ts
- Invariant
- Every protected route calls auth() and redirects if no userId
- Infra
- ClerkProvider wraps the app. Webhook for metadata sync is optional until billing.
Auth is foundational infra. Set it up once, early, and do not revisit until you add a new auth surface.
Pro / Founding Member
After first deploy — when you need gating- SSOT
- isFoundingMember lives in Clerk publicMetadata. One write path (webhook or admin grant).
- Invariant
- Dashboard checks metadata before rendering. No metadata means redirect to /pricing.
- Infra
- Stripe webhook writes metadata on checkout.session.completed. Admin endpoint as fallback.
Billing state should live in your auth provider, not a separate database. One source, two read paths.
Notifications
After core features ship — when users need to know things changed- SSOT
- Notification preferences in user metadata or a simple preferences table.
- Invariant
- Never send a notification without checking the user opted in.
- Infra
- Email provider API route. Queue if volume grows. Start with direct sends.
Do not build notification infra before you have users who need notifications. Start with email, add push later.
Realtime (WebSocket / SSE)
Only when polling is visibly too slow for users- SSOT
- Connection state managed in a single provider component.
- Invariant
- Reconnect on disconnect. Show stale-data indicator if the socket is down.
- Infra
- WebSocket server or SSE endpoint. Consider a managed service before self-hosting.
Realtime is expensive to build and maintain. Most apps do not need it until they have concurrent editing or live dashboards.
The rule is simple
If two files need the same value, move it to a single source. If a wrong value breaks the build, make it an invariant. If the value needs a server, wire the infra. If none of those are true, leave it where it is.