Persistence Topology
Since I#2013 (June 2026) the backend enforces a PoEAA three-layer scheme for database access. Spec: docs/superpowers/specs/2026-06-12-persistence-topology-poeaa-layering-design.md in bookish-broccoli.
| Layer | Lives in | Role | Drizzle / @/db |
|---|---|---|---|
| Presentation | routes/, lib/jobs/processors/, webhooks | HTTP and batch adapters: parse, dispatch, respond; paging, per-item error policy | Never |
| Service Layer | services/ | Fowler’s Service Layer for user-initiated operations: ServiceActor, authorization, Zod validation, i18n error mapping, DTO shaping | No new code (legacy allowlisted) |
| Domain | backend/src/domain/<x>/ (interim: lib/file-operations/, lib/billing/, …) | Operations, invariants, queries, multi-table transactions; actor-free; constructor DI | Sole owner |
Vocabulary — which “domain” this is
“Domain” means opposite things in two schools. Clean/Hexagonal/Onion: domain = innermost ring, persistence-free by definition. PoEAA / modular monolith: domain = middle layer that owns its Data Source code. RENEWA One follows the PoEAA reading — domain/<x>/ modules import @/db and run transactions; that is correct here, not a violation. There is deliberately no repository layer over Drizzle: Drizzle is already a typed query DSL.
Rules
- Jobs and services are peer clients of the domain layer. A processor never consumes
container.services— theProcessorContainertype (Pick<ServiceContainer, 'domain' | 'infrastructure' | 'crossCutting'>) makes that a compile-time property. There is no SystemActor: an operation a job needs is actor-free by definition; user flows wrap the same operation with an actor-checking service method. - One invariant, one owner. A query that encodes another module’s invariant moves next to that invariant (motivating incident: a processor’s candidates query mirroring a domain op’s idempotency guard — PR#2005).
- The DI container has a
domainslice alongsideinfrastructure,crossCutting,services— see Dependency Injection.
Enforcement — ratcheted Strangler Fig
scripts/check-persistence-topology.shruns in CI (persistence-topology-check, blocking via ci-gate): a gated file importing@/db(static or dynamic import) that is not allowlisted fails; an allowlist entry that no longer violates also fails — the number only goes down. Allowlist at gate-introduction: 221 files.- quick-lint rule 11 gives the same feedback per-file at edit time.
- Migrate on touch: a PR substantially touching an allowlisted file moves the touched queries into the owning domain module and removes the allowlist entry in the same PR. No big-bang re-layering.
See Also
- Service Layer Pattern — the services/ conventions above the domain layer
- Backend Architecture — overall layering diagram
- Dependency Injection — container slices
- Background Jobs — processors as batch presentation