# 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 `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/.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` (or the analogous `I*Controller`) so consumers can depend on the type without depending on the impl. 2. **Entity layout** — `entities/models/.ts` (Zod schema + type) and `entities/errors/.ts` (domain-grouped error classes) + `entities/errors/common.ts` (`InputParseError`). 3. **Naming** — dot-separated qualifiers throughout: - Real repo impl: `.repository.ts` (no `payload-` prefix) - Mock repo impl: `.repository.mock.ts` (suffix, not prefix) - Repo interface: `.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:** ```typescript bind(AUTH_SYMBOLS.ISignInUseCase).toDynamicValue((ctx) => signInUseCase( ctx.container.get(AUTH_SYMBOLS.IUsersRepository), ctx.container.get( 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: ```typescript 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/.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`. - Every controller has `export type I*Controller = ReturnType`. ## 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`.