Records the Plan 9 architectural decision (schemas in use-case file, runtime output validation, presenter pattern, feature-scoped error middleware, ./ui subpath split). ADR-012 gets a one-line cross- reference to the new ADR. Refactor log gets a Summary section with commit table and conformance checklist. Plan 9 complete. The deferred doc-update pass (CLAUDE.md / AGENTS.md / guides) — combined with the still-pending Plan 8 items — is the next follow-up. Refactor log: Summary, doc-update checklist Spec: R29, R30
8.5 KiB
ADR-012: Lazar Nikolov Pattern Conformance
Status: Accepted
Date: 2026-05-05
Supersedes: none — extends ADR-006 (vertical-feature-packages) and ADR-008 (per-feature DI containers)
Spec: docs/superpowers/specs/2026-05-05-lazar-pattern-conformance-design.md
Plan: docs/superpowers/plans/2026-05-05-plan-8-lazar-conformance.md
Refactor log: docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md
Context
The vertical-feature monorepo refactor (ADRs 001-010) and the TDD foundation (ADR-011) established Clean Architecture per feature, but the per-layer code shape diverged from the canonical reference implementation by Lazar Nikolov (nikolovlazar/nextjs-clean-architecture).
Specifically, before this ADR:
- Use cases called
<feature>Container.get()inside their bodies (locator pattern), making them hard to test in isolation. - Controllers were multi-method classes (
articles.controller.tswithgetBySlug,create,listmethods on one symbol). - Entities lived flat at
entities/<x>.ts; errors atentities/errors.ts. - Mock implementations used a
mock-prefix (mock-articles.repository.ts), separating them visually from the real impl. - Real Payload-backed implementations used a
payload-prefix (payload-articles.repository.ts). - Repository/service interface filenames used a
-separator (articles-repository.interface.ts) instead of the canonical dot (articles.repository.interface.ts). - Tests rebound the DI container in
beforeEachrather than constructing mocks and injecting directly.
Decision
Bring every feature into structural conformance with Lazar's reference pattern, with four intentional divergences (§Adaptations below).
What we adopted
-
Factory-function use cases and controllers — every use case and every controller is a factory:
(deps) => async (input) => result. Each file exportsexport type I*UseCase = ReturnType<typeof xUseCase>(or the analogousI*Controller) so consumers can depend on the type without depending on the impl. -
Entity layout —
entities/models/<x>.ts(Zod schema + type) andentities/errors/<domain>.ts(domain-grouped error classes) +entities/errors/common.ts(InputParseError). -
Naming — dot-separated qualifiers throughout:
- Real repo impl:
<noun>.repository.ts(nopayload-prefix) - Mock repo impl:
<noun>.repository.mock.ts(suffix, not prefix) - Repo interface:
<noun>.repository.interface.ts - Service variants follow the same pattern.
- Real repo impl:
-
One controller per use case — no multi-method controllers. Each verb-noun pair has its own file:
sign-in.controller.ts,get-articles.controller.ts,delete-media.controller.ts. -
InversifyJS
.toDynamicValue()for factory bindings:bind<ISignInUseCase>(AUTH_SYMBOLS.ISignInUseCase).toDynamicValue((ctx) => signInUseCase( ctx.container.get<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository), ctx.container.get<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService), ), ); -
Direct injection in tests — construct mocks and pass them into the factory; no container rebinding for unit/use-case/controller tests:
const users = new MockUsersRepository(); const auth = new MockAuthenticationService(users); const useCase = signInUseCase(users, auth); -
Real Payload-backed
UsersRepositoryandAuthenticationServiceforauth— previously only mocks existed. Some methods onAuthenticationService(session create/validate/invalidate) are deferred behindNotImplementedErroruntil the cookie-strategy decision is finalized; see refactor log §7. -
mediais now a complete Clean Architecture feature — entities, application, infrastructure, interface-adapters, DI, integrations/api, factories, contract, feature test. Previously it was just a Payload collection.
Intentional divergences from the reference (kept from prior ADRs)
| Aspect | Reference | Ours | Reason |
|---|---|---|---|
| DI library | @evyweb/ioctopus |
inversify |
Already integrated; equivalent expressive power via .toDynamicValue(). |
| DI scope | One global ApplicationContainer |
One per feature (authContainer, blogContainer, …) |
Vertical-feature isolation (ADR-008). |
| Test placement | tests/unit/... mirror |
Colocated *.test.{ts,tsx} |
Established by ADR-011 / Plan 7; clearer per-file ownership. |
| Instrumentation | Sentry/observability service wrapping | Not adopted | Out of scope; revisit when observability becomes a requirement. |
InputParseError is also duplicated per feature (~6 lines × 5
features) instead of sharing a global class — feature independence
beats DRY for a class this small.
Consequences
Positive
- Trivially testable use cases & controllers. Factory functions take deps as arguments — tests inject mocks directly, no container involvement, no shared mutable state across tests.
- One reason to change per controller file. Single-responsibility per file makes diffs and code review cleaner.
- Type aliases (
I*UseCase/I*Controller) decouple consumers. The tRPC router and any other caller depends on the type, not the factory impl. - Naming consistency with widely-shared community pattern, lowering ramp-up cost for engineers familiar with the reference.
- Real auth + media complete the architectural symmetry — every feature now demonstrates the full layer stack.
Negative
- More files. Per-use-case controllers added ~6 controller files
across
blogandmarketing-pages;mediaadded ~30 files from scratch. - DI bindings are more verbose.
.toDynamicValue()blocks are longer than.to()for class bindings. Acceptable in exchange for factory-function purity. - Two AuthenticationService methods remain
NotImplementedErrorpending session-cookie strategy. Documented in refactor log §7; unblocks development that doesn't depend on session lifecycle. - Doc churn. All references to old paths/patterns across guides, per-feature AGENTS, and the spec required updating in this follow-up pass.
Alternatives considered
- Adopt
@evyweb/ioctopus— rejected. Already on inversify; switching DI libraries is high-risk for low gain. - Move tests to
tests/unit/mirror layout — rejected. Colocation is established (ADR-011);*.test.tsnext to source is unambiguous per-file ownership. - Move to a single global container — rejected. Per-feature containers (ADR-008) are load-bearing for vertical-feature isolation.
- Keep multi-method controllers — rejected. The single-responsibility controller-per-use-case pattern wins on readability and testability.
- Skip real
AuthenticationService— rejected forUsersRepository(keep mock only) — partial real impl with documentedNotImplementedErrorfor session methods is the better trade because it unblocks theauthintegration without forcing a premature cookie-strategy choice.
Acceptance verification (Task 10, 2026-05-05)
- All 325 tests passing (was 244 pre-Plan-8; +81 net, +33%).
pnpm typecheck,pnpm lint,pnpm turbo boundariesclean.- No
entities/<x>.tsfiles at root level. - No
mock-*.tsfiles in feature packages. - No
payload-*.tsfiles anywhere. - Every use case has
export type I*UseCase = ReturnType<typeof ...>. - Every controller has
export type I*Controller = ReturnType<typeof ...>.
See refactor log for full file-by-file inventory.
References
- Spec:
docs/superpowers/specs/2026-05-05-lazar-pattern-conformance-design.md - Plan:
docs/superpowers/plans/2026-05-05-plan-8-lazar-conformance.md - Refactor log:
docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md - Reference repo: https://github.com/nikolovlazar/nextjs-clean-architecture
- Prior ADRs: ADR-006 (vertical-feature-packages), ADR-008 (per-feature DI containers), ADR-011 (TDD foundation)
Update — 2026-05-06
Plan 9 (ADR-013) further unifies the input/output schema story:
schemas now live in the use-case file (a refinement of §What we
adopted #1's "factory-function use cases"); controllers gain a
co-located function presenter (extending §What we adopted #4's
"one-controller-per-use-case"); domain error → TRPCError
translation runs through a per-feature middleware factory (a new
concern not in this ADR). See docs/decisions/adr-013-input-output-unification.md.