SaaS Starter

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

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

En esta página