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 userId ↔ orgId 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 belowUse 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ê.