6.9 KiB
Deepening
How to deepen a cluster of shallow modules in this repo, given its dependencies. In this repo "module" defaults to "feature" — most deepenings operate on or within a feature (packages/<name>/). Narrower scopes (use case, controller, repository) follow the same dependency-category logic. Assumes the vocabulary in LANGUAGE.md — module, interface, seam, adapter.
Dependency categories
When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam.
1. In-process
Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed.
Where this lives in our repo:
entities/models/**— Zod schemas + types + pure helpersentities/errors/**— domain error classesapplication/use-cases/**— pure orchestration (factories take ports as deps)interface-adapters/controllers/**+ their colocatedpresenterfunctions- Pure helpers in
core-shared/conformance/,core-shared/instrumentation/(the non-OTel parts)
For category 1 modules: merge, then test the result through its interface. No mocks. The test surface IS the new interface.
2. Local-substitutable
Dependencies that have local test stand-ins. In this repo every infrastructure port already has both a real implementation and a mock side-by-side — the mock IS the test stand-in.
Pattern (from ADR-012):
<x>.repository.interface.ts— the port (seam)<x>.repository.ts— Payload-backed adapter (real)<x>.repository.mock.ts— in-memory adapter (test stand-in)
The deepened module is tested with the .mock.ts adapter injected directly via the factory function. No container rebinding (ADR-012).
Common category-2 ports in this repo:
IUsersRepository,IArticlesRepository,IMediaRepository, etc. → Mock + Payload adaptersIAuthenticationService→ Mock + real adapterIJobQueue(fromcore-shared/jobs/) →InMemoryJobQueue+PayloadJobQueueIEventBus(fromcore-events/) →InMemoryEventBus+PayloadJobsEventBusITracer,ILogger,IMetrics(fromcore-shared/instrumentation/) →Noop*+Otel*+Recording*(the third one lives incore-testingfor assertions)
If the deepening touches a category-2 port, the recommendation shape is: "Merge X into Y, keep the port boundary at the existing <x>.repository.interface.ts; both adapters survive unchanged."
3. Remote but owned
Our own services across a network boundary. In this repo, cross-feature communication is already this pattern via the event bus (ADR-015).
The port is IEventBus.publish(descriptor, payload) + IEventBus.subscribe(descriptor, consumerFeature, handler). Adapters:
InMemoryEventBus(test + dev-seed) — synchronous fan-outPayloadJobsEventBus(production) — Payload tasks fan-out durably across the network if features are split into separate deploys
If a deepening proposal would introduce a NEW cross-feature seam, the answer is almost always "use the event bus" — don't invent a new transport. Rule E0 forbids in-feature use of the bus (in-feature reactions are direct use-case calls); E1 keeps consumer handlers private.
If the deepening proposal would EXPOSE one feature's internals to another, that's a boundary violation — reject and suggest events instead.
4. True external
Third-party services we don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter.
Where this lives in our repo:
- Payload CMS itself — features depend on
IXRepository, not onpayloaddirectly. The real adapter (<x>.repository.ts) is the only place Payload is touched. - Sentry / OpenTelemetry exporters — features use
ITracer/ILogger/IMetricsfromcore-shared/instrumentation/. The OTel SDK lives ONLY incore-shared/instrumentation/otel/(ESLint-enforced via rule R52). - Socket.IO — features use
IRealtimeBroadcaster;socket.ioitself lives only in@repo/core-realtime(rule R2).
If a deepening proposal would import a vendor SDK from a feature package, that's an ADR-014/ADR-016/ADR-017 violation — reject and route through the existing port.
Seam discipline
- One adapter means a hypothetical seam. Two adapters means a real one. Don't introduce a port unless at least two adapters are justified (typically production + test). Single-adapter ports in this repo are usually a smell — check if the mock is missing or if the port itself is unnecessary.
- Internal seams vs external seams. A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them.
- The DI symbol is the seam contract.
*_SYMBOLS.IXRepositoryplus<x>.repository.interface.tstogether define what callers depend on. Adapter swaps happen at bind time inbind-production.ts/bind-dev-seed.ts. feature.manifest.tsis the structural-conformance seam. If a deepening moves use cases across features, the manifests of BOTH features change — the conformance ESLint rules +assertFeatureConformanceboot check enforce that the move is reflected in declarations, not just code.
Testing strategy: replace, don't layer
- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them. Our coverage thresholds (ADR-020) reward this: collapsing N shallow modules + their N test files into one deep module + one test file at its interface keeps coverage 100% without test bloat.
- Write new tests at the deepened module's interface. The interface is the test surface.
- Tests assert on observable outcomes through the interface, not internal state.
- Tests should survive internal refactors — if a test has to change when the implementation changes, it's testing past the interface.
- L1 diff coverage (
pnpm coverage:diff) will surface uncovered lines after the refactor — every deepened module needs its new tests to cover the merged behaviour before the refactor PR is mergeable.
Conformance check (run before claiming the deepening is complete)
After deepening, the following must all stay green:
pnpm typecheck # TS brand-slot enforcement
pnpm lint # ESLint conformance + boundaries
pnpm test --filter @repo/<feature> -- --coverage # per-layer L0 thresholds
pnpm conformance # cross-feature event closure
pnpm fallow:audit # whole-codebase audit + dead-export sweep
pnpm coverage:diff -- --base origin/main # cover-the-diff (ADR-020 L1)
If pnpm dev was running, it should still boot — assertFeatureConformance will fail loudly on brand-slot or manifest drift if the deepening forgot a binding update.