Files
agentic-dev-template/docs/decisions/adr-012-feature-conventions.md
Danijel Martinek 2edc76002a refactor(docs): strip residual Phase/Plan setup-history references
Final sweep for setup-process bookkeeping not caught by template-reset-v1.
ADRs drop Plan-N qualifiers; spec collapses the historical 11-phase
migration table; scaffolding guide drops "Phase added" column; comment
prefixes referencing R-numbers in test describes / eslint inline comments
are normalized. Architecture-level rule IDs (R40, R52, E0, J0, etc.) are
preserved where they serve as stable cross-references in ADRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:28:31 +02:00

8.2 KiB
Raw Blame History

ADR-012: Feature Conventions

Status: Accepted Date: 2026-05-05 Supersedes: none — extends ADR-006 (vertical-feature-packages) and ADR-008 (per-feature DI containers)

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 was inconsistent across features and needed to be standardized.

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.ts with getBySlug, create, list methods on one symbol).
  • Entities lived flat at entities/<x>.ts; errors at entities/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 beforeEach rather than constructing mocks and injecting directly.

Decision

Bring every feature into structural conformance with the canonical Clean Architecture pattern, with four intentional divergences (§Adaptations below).

What we adopted

  1. Factory-function use cases and controllers — every use case and every controller is a factory: (deps) => async (input) => result. Each file exports export type I*UseCase = ReturnType<typeof xUseCase> (or the analogous I*Controller) so consumers can depend on the type without depending on the impl.

  2. Entity layoutentities/models/<x>.ts (Zod schema + type) and entities/errors/<domain>.ts (domain-grouped error classes) + entities/errors/common.ts (InputParseError).

  3. Naming — dot-separated qualifiers throughout:

    • Real repo impl: <noun>.repository.ts (no payload- prefix)
    • Mock repo impl: <noun>.repository.mock.ts (suffix, not prefix)
    • Repo interface: <noun>.repository.interface.ts
    • Service variants follow the same pattern.
  4. 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.

  5. 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,
        ),
      ),
    );
    
  6. 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);
    
  7. Real Payload-backed UsersRepository and AuthenticationService for auth — previously only mocks existed. Some methods on AuthenticationService (session create/validate/invalidate) are deferred behind NotImplementedError until the cookie-strategy decision is finalized.

  8. media is 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 (kept from prior ADRs)

Aspect Community default 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; 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 convention, lowering ramp-up cost for engineers familiar with Clean Architecture patterns.
  • 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 blog and marketing-pages; media added ~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 NotImplementedError pending 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.ts next 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 for UsersRepository (keep mock only) — partial real impl with documented NotImplementedError for session methods is the better trade because it unblocks the auth integration without forcing a premature cookie-strategy choice.

Acceptance criteria

  • All tests passing.
  • pnpm typecheck, pnpm lint, pnpm turbo boundaries clean.
  • No entities/<x>.ts files at root level.
  • No mock-*.ts files in feature packages.
  • No payload-*.ts files anywhere.
  • Every use case has export type I*UseCase = ReturnType<typeof ...>.
  • Every controller has export type I*Controller = ReturnType<typeof ...>.

References

  • Prior ADRs: ADR-006 (vertical-feature-packages), ADR-008 (per-feature DI containers), ADR-011 (TDD foundation)

Update — 2026-05-06

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.