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>
175 lines
8.2 KiB
Markdown
175 lines
8.2 KiB
Markdown
# 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 layout** — `entities/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:**
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```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/<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`.
|