Visión general
Capas DDD-lite, building blocks, y las reglas que aplica ESLint.
Por qué DDD
No DDD purista — DDD-lite: aggregates, value objects, branded IDs, Result<T, E>. El mínimo que evita que un backend colapse en una masa framework-driven a medida que crece.
El boilerplate trae siete bounded contexts ya cableados así: iam, tenancy, billing, notifications, storage, audit, feature-flags. Cada uno es self-contained — tocar uno no ondula al siguiente.
Capas
interfaces/http/ ← Controladores Express, schemas Zod, registración de rutas
↓
application/ ← Use cases, ports, DTOs
↓
domain/ ← Entidades, value objects, repository interfaces
↑
infrastructure/ ← Repos Prisma, adapters de terceros, mappers (implementa ports de domain/application)La regla: las dependencias apuntan hacia adentro. Domain no sabe nada de Express, Prisma, ni de ningún framework. Infrastructure implementa las interfaces declaradas en domain/ y application/.
Esto no es una guideline — está aplicado por packages/eslint-config. Los archivos en domain/ ni siquiera pueden importar express o @prisma/client; el lint falla.
Building blocks
AggregateRoot
Clase base en apps/server/src/shared/aggregate-root.ts. Encapsula invariantes, acumula DomainEvents, expone métodos de dominio (sin 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();
}
}Después que un use case persiste el aggregate, publica aggregate.pullEvents() a través del event bus.
Branded IDs
Tipos opacos sobre string para que el sistema de tipos detecte la confusión userId ↔ orgId en compile time:
type UserId = Brand<string, 'UserId'>;
type OrganizationId = Brand<string, 'OrganizationId'>;Definidos en packages/shared/src/types/.
Result<T, E>
No throws dentro de código de domain o application. Los errores son valores. Los use cases devuelven Result.ok(value) o Result.err(error). Los controllers los mapean a HTTP:
const result = await createUser.execute(input);
if (result.isErr()) return sendError(res, result.error);
return res.status(201).json(result.value);sendError (en infrastructure/http/responses.ts) sabe cómo convertir un DomainError al status code correcto.
Value objects
Inmutables, equal-by-value. Email, Money, Slug, Permission. La validación vive en el constructor / static factory; el código downstream puede asumir valores bien formados.
const email = Email.create('[email protected]');
if (email.isErr()) return Result.err(email.error);
// safe to use email.value belowUse cases
Implementan UseCase<Input, Output> desde 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>> {
// ...
}
}Cableado en Awilix en bootstrap/container.ts. Los tests inyectan fakes con la misma key.
Eventos de dominio + event bus
AggregateRoot.addDomainEvent(...) acumula eventos. El use case los publica vía el IEventBus en memoria (infrastructure/events/event-bus.ts). Los listeners se suscriben en módulos bootstrap/; pueden a su vez encolar jobs de BullMQ (ej. user.created → queue emails → welcome email).
Ver Cómo hacer: usar el event bus.
DI con Awilix
bootstrap/container.ts registra repos, services, use cases, adapters. Los routers entran al container para construir sus handlers; los tests construyen un container más chico con fakes registrados contra las mismas keys.
El container es tipado (ContainerRegistry) — un typo en una key falla el build.
ADRs
Las decisiones grandes viven en docs/adr/ en la raíz del repo. Si un PR contradice una ADR, el PR actualiza la ADR primero. Las ADRs documentan por qué, no qué — el código es el qué.