SaaS Starter
Architecture

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 userIdorgId 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 below

Use 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.createdemails 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.

On this page