SaaS Starter

Visão geral

Camadas DDD-lite, building blocks, e as regras que o ESLint aplica.

Por que DDD

Não DDD purista — DDD-lite: aggregates, value objects, branded IDs, Result<T, E>. O mínimo que impede um backend de colapsar em uma massa framework-driven à medida que cresce.

O boilerplate traz sete bounded contexts já cabeados assim: iam, tenancy, billing, notifications, storage, audit, feature-flags. Cada um é self-contained — mexer em um não respinga no próximo.

Camadas

interfaces/http/    ← Controladores Express, schemas Zod, registro de rotas

application/        ← Use cases, ports, DTOs

domain/             ← Entities, value objects, repository interfaces

infrastructure/     ← Repos Prisma, adapters de terceiros, mappers (implementa ports de domain/application)

A regra: dependências apontam para dentro. Domain não sabe nada de Express, Prisma, ou de qualquer framework. Infrastructure implementa as interfaces declaradas em domain/ e application/.

Isto não é uma guideline — é aplicado por packages/eslint-config. Arquivos em domain/ nem mesmo podem importar express ou @prisma/client; o lint falha.

Building blocks

AggregateRoot

Classe base em apps/server/src/shared/aggregate-root.ts. Encapsula invariantes, acumula DomainEvents, expõe métodos de domínio (sem setters anêmicos).

export class User extends AggregateRoot<UserId> {
  static create(input: CreateUserInput): Result<User, DomainError> {
    // validar invariantes, addDomainEvent(...), return Result.ok(user)
  }

  changeEmail(email: Email): Result<void, DomainError> {
    // ...
    this.addDomainEvent({ name: 'user.email_changed', /* ... */ });
    return Result.ok();
  }
}

Depois que um use case persiste o aggregate, ele publica aggregate.pullEvents() através do event bus.

Branded IDs

Tipos opacos sobre string para que o sistema de tipos pegue a confusão userIdorgId em tempo de compilação:

type UserId = Brand<string, 'UserId'>;
type OrganizationId = Brand<string, 'OrganizationId'>;

Definidos em packages/shared/src/types/.

Result<T, E>

Sem throws dentro do código de domain ou application. Erros são valores. Use cases retornam Result.ok(value) ou Result.err(error). Controllers os mapeiam para HTTP:

const result = await createUser.execute(input);
if (result.isErr()) return sendError(res, result.error);
return res.status(201).json(result.value);

sendError (em infrastructure/http/responses.ts) sabe como transformar um DomainError no status code certo.

Value objects

Imutáveis, equal-by-value. Email, Money, Slug, Permission. A validação vive no constructor / static factory; código downstream pode assumir valores bem formados.

const email = Email.create('[email protected]');
if (email.isErr()) return Result.err(email.error);
// safe to use email.value below

Use cases

Implementam UseCase<Input, Output> de 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>> {
    // ...
  }
}

Cabeado no Awilix em bootstrap/container.ts. Testes injetam fakes pela mesma key.

Eventos de domínio + event bus

AggregateRoot.addDomainEvent(...) acumula eventos. O use case os publica via o IEventBus em memória (infrastructure/events/event-bus.ts). Listeners se inscrevem em módulos bootstrap/; eles podem por sua vez enfileirar jobs BullMQ (ex. user.created → queue emails → welcome email).

Veja Como fazer: usar o event bus.

DI com Awilix

bootstrap/container.ts registra repos, services, use cases, adapters. Routers entram no container para construir seus handlers; testes constroem um container menor com fakes registrados nas mesmas keys.

O container é tipado (ContainerRegistry) — um typo numa key faz o build falhar.

ADRs

Decisões grandes vivem em docs/adr/ na raiz do repo. Se um PR contradiz um ADR, o PR atualiza o ADR primeiro. ADRs documentam por quê, não o quê — o código é o quê.

Nesta página