Overview
DDD-lite layering, building blocks, and the rules ESLint enforces.
Why DDD
Not purist DDD — DDD-lite: aggregates, value objects, branded IDs, Result<T, E>. The minimum that keeps a backend from collapsing into framework-driven goo as it grows.
The boilerplate ships seven bounded contexts already wired this way: iam, tenancy, billing, notifications, storage, audit, feature-flags. Each is self-contained — touching one doesn't ripple into another.
Layers
interfaces/http/ ← Express controllers, Zod schemas, route registration
↓
application/ ← Use cases, ports, DTOs
↓
domain/ ← Entities, value objects, repository interfaces
↑
infrastructure/ ← Prisma repos, third-party adapters, mappers (implements domain/application ports)The rule: dependencies point inward. Domain knows nothing of Express, Prisma, or any framework. Infrastructure implements the interfaces declared in domain/ and application/.
This is not a guideline — it is enforced by packages/eslint-config. domain/ files can't even import express or @prisma/client; the lint fails.
Building blocks
AggregateRoot
Base class in apps/server/src/shared/aggregate-root.ts. Encapsulates invariants, accumulates DomainEvents, exposes domain methods (no anemic setters).
export class User extends AggregateRoot<UserId> {
static create(input: CreateUserInput): Result<User, DomainError> {
// validate invariants, addDomainEvent(...), return Result.ok(user)
}
changeEmail(email: Email): Result<void, DomainError> {
// ...
this.addDomainEvent({ name: 'user.email_changed', /* ... */ });
return Result.ok();
}
}After a use case persists the aggregate, it publishes aggregate.pullEvents() through the event bus.
Branded IDs
Opaque types over string so the type system catches userId ↔ orgId confusion at compile time:
type UserId = Brand<string, 'UserId'>;
type OrganizationId = Brand<string, 'OrganizationId'>;Defined in packages/shared/src/types/.
Result<T, E>
No throws inside domain or application code. Errors are values. Use cases return Result.ok(value) or Result.err(error). Controllers map them to HTTP:
const result = await createUser.execute(input);
if (result.isErr()) return sendError(res, result.error);
return res.status(201).json(result.value);sendError (in infrastructure/http/responses.ts) knows how to turn a DomainError into the right status code.
Value objects
Immutable, equal-by-value. Email, Money, Slug, Permission. Validation lives in the constructor / static factory; downstream code can assume well-formed values.
const email = Email.create('[email protected]');
if (email.isErr()) return Result.err(email.error);
// safe to use email.value belowUse cases
Implement UseCase<Input, Output> from apps/server/src/shared/usecase.ts:
export class CreateUser implements UseCase<CreateUserInput, User> {
constructor(
private readonly users: UserRepository,
private readonly bus: IEventBus,
) {}
async execute(input: CreateUserInput): Promise<Result<User, DomainError>> {
// ...
}
}Wired into Awilix in bootstrap/container.ts. Tests inject fakes by the same key.
Domain events + event bus
AggregateRoot.addDomainEvent(...) accumulates events. The use case publishes them via the in-memory IEventBus (infrastructure/events/event-bus.ts). Listeners subscribe in bootstrap/ modules; they can in turn enqueue BullMQ jobs (e.g. user.created → emails queue → welcome email).
See How-to: use the event bus.
DI with Awilix
bootstrap/container.ts registers repos, services, use cases, adapters. Routers reach into the container to build their handlers; tests build a smaller container with fakes registered against the same keys.
The container is typed (ContainerRegistry) — a typo in a key fails the build.
ADRs
Big decisions live in docs/adr/ at the repo root. If a PR contradicts an ADR, the PR updates the ADR first. ADRs document why, not what — the code is the what.