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.

LayerLives inRoleDrizzle / @/db
Presentationroutes/, lib/jobs/processors/, webhooksHTTP and batch adapters: parse, dispatch, respond; paging, per-item error policyNever
Service Layerservices/Fowler’s Service Layer for user-initiated operations: ServiceActor, authorization, Zod validation, i18n error mapping, DTO shapingNo new code (legacy allowlisted)
Domainbackend/src/domain/<x>/ (interim: lib/file-operations/, lib/billing/, …)Operations, invariants, queries, multi-table transactions; actor-free; constructor DISole 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 readingdomain/<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 — the ProcessorContainer type (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 domain slice alongside infrastructure, crossCutting, services — see Dependency Injection.

Enforcement — ratcheted Strangler Fig

  • scripts/check-persistence-topology.sh runs 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