Skip to main content

ADR-003: Domain package shape — domain / application / infrastructure / presentation

  • Status: Accepted
  • Date: 2026-05-23
  • Deciders: CTO / Founding Engineering
  • Relates to: ADR-001, ADR-002

Context

Each bounded context in packages/<domain>/ must be:

  1. Reviewable on its own without touching other domains.
  2. Carve-out-ready when extraction triggers fire (ADR-001).
  3. Unit-testable without a database.

A flat src/ layout couples domain rules to the ORM and HTTP framework. Carving out a flat src/ domain later requires a rewrite.

Decision

Inside packages/<domain>/backend/:

domain/ pure business rules and value objects.
NO @nestjs/*, NO @prisma/client.
application/ use cases, application services, ports (interfaces).
Depends on domain/. Defines ports for infrastructure.
infrastructure/ adapters implementing ports — Prisma repos, HTTP clients,
Kafka producers, third-party SDKs.
presentation/ NestJS controllers, DTOs, guards. Translates HTTP into
use-case calls. NO business logic.
<domain>.module.ts wires ports → impls, registers controllers.

Alternatives considered

  • Flat src/ per domain. Rejected — couples domain rules to NestJS + Prisma; makes carve-out a rewrite.
  • Per-feature folders (payroll/run/, payroll/approve/) instead of horizontal layers. Rejected for the bedrock layers, but acceptable within application/ once a domain grows past a handful of use cases.
  • Sharing a single src/ layout across all domains by convention. Rejected — we want the layout enforced by lint, not by reviewer vigilance.

Consequences

Positive

  • Carving out a domain into a Go service is a layer-by-layer port, not a rewrite. domain/ translates directly; application/ is the use-case API the new service exposes; infrastructure/ gets a different impl.
  • Pure-TS domain/ and application/ are unit-testable with fakes in milliseconds.
  • New engineers learn one layout, work across all domains.

Negative

  • A small domain has more folders than its code seems to justify. Pay-once for the structure; recoup on every refactor.
  • Two levels of indirection (use-case → port → repo) instead of "just call Prisma."

Follow-ups

  • ESLint rule (custom, ships in tooling/eslint/) forbids @prisma/client and @nestjs/* in backend/{domain,application}/**.
  • pnpm gen:domain <name> generator scaffolds the structure correctly.
  • A separate tests/ directory hosts integration tests; pure unit tests live alongside their source.