Parte VI · Ingeniería

20. Arquitectura, SOLID, patrones de diseño y Clean Code

Hasta aquí el libro ha explicado cómo funcionan Angular, NestJS y MikroORM. Este capítulo trata de algo distinto y más duradero: cómo organizar el código que se escribe con ellos para que dentro de tres años siga siendo modificable. La arquitectura no es un diagrama bonito ni una carpeta llamada domain: es el conjunto de decisiones que determinan cuánto cuesta el próximo cambio. Aquí están los principios, los patrones y los criterios para tomarlas, pero también —y esto importa igual— para saber cuándo no aplicarlos.

CORE Tiempo de lectura: ~120 min Prerrequisitos: capítulos 1, 9, 10, 13 y 14

20.1 Qué vas a poder hacer al terminar

20.2 Qué es la arquitectura de software

La definición más útil y más citada es la de Ralph Johnson, popularizada por Martin Fowler: la arquitectura son las decisiones importantes, y son importantes las decisiones que son difíciles de cambiar. La dificultad de cambio es el criterio, no el tamaño del diagrama.

La prueba del martes Pregúntate: «si el martes que viene decidimos lo contrario, ¿qué cuesta?». Cambiar el nombre de un método privado cuesta treinta segundos. Cambiar la librería de fechas cuesta una tarde. Cambiar PostgreSQL por MongoDB cuesta meses si las entidades de MikroORM están esparcidas por toda la aplicación, y cuesta días si el acceso a datos está detrás de un puñado de interfaces. Solo lo segundo y lo tercero son arquitectura.

20.2.1 Atributos de calidad: los requisitos que nadie escribe

Los requisitos funcionales dicen qué hace el sistema; los atributos de calidad dicen cómo de bien lo hace. La arquitectura existe fundamentalmente para satisfacerlos, porque los requisitos funcionales se pueden implementar con cualquier arquitectura, incluso con un único archivo de diez mil líneas.

AtributoPregunta que respondeCómo se materializa en este stack
Mantenibilidad¿Cuánto cuesta añadir una funcionalidad o corregir un fallo?Módulos con fronteras claras, dependencias en una sola dirección, nombres honestos
Testabilidad¿Puedo verificar esta lógica sin levantar la base de datos?Inyección de dependencias, puertos, lógica de dominio sin E/S
Rendimiento¿Cuánto tarda una petición en el percentil 95?Estrategia de carga de relaciones, índices, caché, OnPush y señales en el cliente
Escalabilidad¿Qué pasa si multiplico por diez el tráfico o los datos?Procesos sin estado, colas, pool de conexiones, particionado
Seguridad¿Dónde se decide quién puede hacer qué?Guards, validación en el límite, autorización en la capa de aplicación, no en la vista
ObservabilidadSi algo falla a las 3 de la mañana, ¿puedo saber por qué?Logs estructurados con identificador de correlación, métricas, trazas, health checks

Estos atributos compiten entre sí. Una capa de abstracción mejora la testabilidad y empeora la inmediatez con la que se lee el código. Una caché mejora el rendimiento y empeora la consistencia. Un diseño distribuido mejora la escalabilidad independiente y empeora la depuración. Arquitectura es elegir explícitamente qué sacrificas, y dejarlo por escrito (20.15).

20.2.2 El coste del acoplamiento

Cuando dos piezas están acopladas, un cambio en una obliga a mirar (y a menudo a tocar) la otra. El coste no crece de forma lineal, sino con el número de caminos entre piezas. Un módulo que conoce a otros tres tiene tres caminos; diez módulos que se conocen todos con todos tienen cuarenta y cinco. Por eso un sistema de tamaño medio con fronteras difusas se vuelve inmanejable de golpe, no poco a poco.

  ACOPLAMIENTO EN MALLA (n·(n-1)/2 caminos)      FRONTERAS EXPLÍCITAS (n-1 caminos)

     ┌───┐   ┌───┐   ┌───┐                          ┌───┐   ┌───┐   ┌───┐
     │ A │───│ B │───│ C │                          │ A │   │ B │   │ C │
     └─┬─┘╲ ╱└─┬─┘╲ ╱└─┬─┘                          └─┬─┘   └─┬─┘   └─┬─┘
       │   ╳    │   ╳   │                             └───────┼───────┘
     ┌─┴─┐╱ ╲┌──┴┐ ╱ ╲┌─┴─┐                              ┌────┴────┐
     │ D │───│ E │───│ F │                              │  núcleo  │
     └───┘   └───┘   └───┘                              │ (puertos)│
                                                        └──────────┘
   15 caminos: cualquier cambio se               5 caminos: el cambio se detiene
   propaga en cualquier dirección                en la frontera

20.2.3 Deuda técnica: deliberada frente a accidental

La metáfora es de Ward Cunningham: escribir código que no encaja del todo con lo que has aprendido del dominio es como pedir un préstamo. Te permite entregar antes, pero pagas intereses en forma de cambios más lentos hasta que devuelves el principal (refactorizando). Fowler amplió la idea con un cuadrante: deliberada o accidental y prudente o temeraria.

                    PRUDENTE                          TEMERARIA
              ┌──────────────────────────┬──────────────────────────┐
   DELIBERADA │ "Salimos con el repo     │ "No hay tiempo para      │
              │  acoplado al ORM y lo    │  diseñar."               │
              │  aislamos en el sprint 4"│  → negligencia, no una   │
              │  → anótalo, ponle fecha  │    decisión              │
              ├──────────────────────────┼──────────────────────────┤
   ACCIDENTAL │ "Ahora que está hecho,   │ "¿Qué es una capa?"      │
              │  sabemos cómo deberíamos │  → falta de formación;   │
              │  haberlo hecho"          │    se arregla aprendiendo│
              │  → refactorización sana  │    no con más frameworks │
              └──────────────────────────┴──────────────────────────┘

La única casilla realmente peligrosa a largo plazo es la temeraria-deliberada, porque se repite en cada entrega y nunca se registra. La deuda prudente-deliberada es una herramienta legítima de gestión: se toma a conciencia, se escribe en el backlog con una fecha y se paga.

Sobre-arquitecturar hace tanto daño como no arquitecturar Un proyecto con cuatro capas, un puerto por cada clase, un caso de uso por método y un mapeador entre cada par de modelos es igual de imposible de mantener que un service.ts de tres mil líneas: en el primero pierdes media hora persiguiendo la implementación real de una interfaz; en el segundo, media hora buscando la línea que importa. La medida correcta depende del tamaño del equipo, de la vida esperada del sistema y de la volatilidad del dominio. Un CRUD interno para veinte usuarios no necesita arquitectura hexagonal.
Analogía: la instalación eléctrica Nadie discute que una vivienda necesita cuadro eléctrico, circuitos separados y diferenciales: es lo que permite cambiar una lámpara sin dejar la casa a oscuras. Pero tampoco se instala en un piso de 60 m² el cuadro de un hospital, con generador redundante y conmutación automática. La arquitectura de software funciona igual: hay un mínimo no negociable (fronteras, dependencias en una dirección) y a partir de ahí cada elemento adicional debe justificarse con un riesgo concreto.

20.3 Cohesión y acoplamiento

Son los dos conceptos que Larry Constantine formuló en los años setenta y de los que se derivan, con distinto envoltorio, casi todos los principios modernos. La regla, en una línea: alta cohesión dentro de cada módulo, bajo acoplamiento entre módulos. Cohesión es cuánto tienen que ver entre sí los elementos que viven en la misma unidad: alta cohesión significa que todo lo que hay dentro cambia por la misma razón. Acoplamiento es cuánto necesita saber una unidad sobre otra para funcionar: bajo acoplamiento significa que puedes cambiar el interior de una sin tocar la otra.

20.3.1 Tipos de acoplamiento, del peor al mejor

TipoQué significaEjemplo en este stack
De contenido (el peor)Un módulo manipula los interiores de otroUn servicio que toca (entidad as any).__helper o el estado privado de otro servicio
ComúnVarios módulos comparten estado global mutableUn objeto exportado a nivel de módulo con la configuración, que cualquiera muta
ExternoDependencia compartida de un formato o protocolo impuesto fueraMedia aplicación conoce la forma exacta del JSON de un proveedor externo
De controlUn módulo pasa a otro una bandera que decide su flujo internocrear(dto, true, false); el llamante sabe cómo funciona por dentro el llamado
De marca (stamp)Se pasa una estructura completa cuando solo se necesita una partePasar la entidad User entera a un método que solo usa user.email
De datosSe pasan exactamente los datos necesariosenviarBienvenida(email: string, nombre: string)
De mensaje (el mejor)Comunicación por eventos; el emisor no conoce al receptoreventBus.publish(new TaskCompleted(taskId)); quien escuche, que escuche

Cuidado con leer la tabla como un ranking a maximizar. El acoplamiento de mensaje es el más laxo, pero también el que hace más difícil seguir el flujo con el depurador y el que introduce consistencia eventual. La mayoría del código de una aplicación debería vivir cómodamente en «acoplamiento de datos»; los eventos se reservan para cruzar fronteras entre contextos (20.7).

20.3.2 Cohesión, de la peor a la mejor

Organiza por funcionalidad, no por tipo técnico Una estructura controllers/ services/ dtos/ entities/ tiene cohesión lógica: para tocar «tareas» abres cuatro carpetas distintas y en cada una hay treinta archivos ajenos. Una estructura tasks/ projects/ billing/, con dentro de cada una su controlador, sus servicios y sus entidades, tiene cohesión funcional: el cambio se concentra en una carpeta. Es la razón de ser de los módulos de Nest y de las features de Angular.

20.3.3 Cómo se mide en la práctica

Las métricas clásicas de Robert C. Martin siguen siendo útiles porque se calculan solo con los import: Ca (acoplamiento aferente) es cuántos módulos dependen de este —alto significa mucha responsabilidad—; Ce (eferente) es de cuántos depende él —alto significa fragilidad—; y la inestabilidad I = Ce / (Ca + Ce) resume ambas. Un módulo de dominio debería tender a 0 (estable) y un adaptador de infraestructura a 1. El principio de dependencias estables dice que las flechas deben apuntar hacia lo más estable: si tu dominio importa el adaptador de correo, la tienes al revés. Lo importante es que esto no se queda en teoría, porque se puede verificar automáticamente:

.dependency-cruiser.cjs · convertir la regla en un test de CI
module.exports = {
  forbidden: [
    { name: 'dominio-no-depende-de-infraestructura', severity: 'error',
      comment: 'La regla de dependencia: el dominio no conoce Nest, MikroORM ni HTTP.',
      from: { path: '^src/[^/]+/domain' },
      to:   { path: '^src/[^/]+/(infrastructure|presentation)|^@mikro-orm|^@nestjs' } },
    { name: 'sin-ciclos', severity: 'error', from: {}, to: { circular: true } },
  ],
  options: { tsConfig: { fileName: 'tsconfig.json' } },
};

Una regla así en el pipeline vale más que veinte páginas de documento de arquitectura: convierte una convención opinable en un fallo de compilación. El equivalente en ESLint es eslint-plugin-boundaries o no-restricted-imports con patrones por carpeta; en Angular con Nx, @nx/enforce-module-boundaries.

Señales de acoplamiento que no necesitan herramienta Un archivo con veinte import de veinte carpetas distintas. Un cambio de una línea que rompe tests de tres módulos ajenos. Un pull request que toca quince archivos para añadir un campo. Un test unitario que necesita seis mocks para arrancar. Todas son la misma enfermedad medida con distinto termómetro.

20.4 Arquitectura en capas

Es la arquitectura por defecto y la que deberías conocer mejor, porque todas las demás (hexagonal, onion, clean) son variaciones sobre la misma idea: agrupar el código por su distancia al mundo exterior y obligar a que las dependencias vayan siempre en la misma dirección.

  ┌─────────────────────────────────────────────────────────────────────┐
  │  PRESENTACIÓN        controladores HTTP, resolvers GraphQL,         │
  │                      gateways WebSocket, componentes de Angular     │
  │                      · traduce protocolo ↔ caso de uso              │
  │                      · NO contiene reglas de negocio                │
  └───────────────────────────────┬─────────────────────────────────────┘
                                  │ depende de ▼
  ┌───────────────────────────────┴─────────────────────────────────────┐
  │  APLICACIÓN          casos de uso, orquestación, transacciones,     │
  │                      autorización, publicación de eventos           │
  │                      · el "guion" de lo que pasa, sin reglas        │
  └───────────────────────────────┬─────────────────────────────────────┘
                                  │ depende de ▼
  ┌───────────────────────────────┴─────────────────────────────────────┐
  │  DOMINIO             entidades, value objects, agregados,           │
  │  (el núcleo)         servicios de dominio, invariantes, puertos     │
  │                      · NO depende de NADA: ni Nest, ni MikroORM,    │
  │                        ni HTTP, ni del reloj del sistema            │
  └─────────────────────────────────────────────────────────────────────┘
                                  ▲ implementa los puertos del dominio
  ┌───────────────────────────────┴─────────────────────────────────────┐
  │  INFRAESTRUCTURA     repositorios MikroORM, cliente de correo,      │
  │                      pasarela de pago, caché Redis, reloj, UUID     │
  └─────────────────────────────────────────────────────────────────────┘

  REGLA DE DEPENDENCIA: las flechas de código fuente apuntan hacia dentro.
  El flujo de EJECUCIÓN va hacia fuera (el caso de uso llama al repositorio),
  pero la DEPENDENCIA DE CÓDIGO no: el caso de uso conoce una interfaz que
  vive en el dominio, no la clase de infraestructura que la implementa.

20.4.1 Qué va exactamente en cada capa

CapaNo
PresentaciónRutas, códigos de estado, DTO de entrada y salida, validación de formato, serialización, documentación OpenAPIConsultas, cálculos de negocio, decisiones sobre el estado del dominio, EntityManager
AplicaciónUn método por caso de uso, control de transacción, permisos, coordinación de varios agregados, eventos de integraciónReglas invariantes del negocio (van al dominio), SQL, detalles de HTTP
DominioEstado y comportamiento del negocio, invariantes, cálculos, políticas, eventos de dominio, interfaces de los puertosDecoradores de Nest, importaciones de MikroORM, fetch, Date.now() directo
InfraestructuraImplementaciones concretas: MikroORM, Redis, S3, SMTP, Stripe, reloj real, generador de UUIDReglas de negocio disfrazadas de «lógica del repositorio»
estructura de carpetas · un módulo de Nest por contexto
src/
├── tasks/                          # módulo de Nest = frontera del contexto
│   ├── domain/                     # task.entity.ts (agregado con comportamiento), task-status.vo.ts,
│   │                               # task.errors.ts (no HttpException), task.repository.ts (PUERTO)
│   ├── application/                # complete-task.use-case.ts, list-tasks.use-case.ts, dto/
│   ├── infrastructure/             # mikro-task.repository.ts (ADAPTADOR de salida)
│   ├── presentation/               # tasks.controller.ts (ADAPTADOR de entrada), dto/ con class-validator
│   └── tasks.module.ts             # aquí se atan puertos con adaptadores
├── shared/                         # kernel compartido: Result, tipos base, utilidades puras
└── app.module.ts
Esta estructura no es obligatoria en todos los módulos En un proyecto real conviven módulos con las cuatro carpetas (los que concentran el negocio) y módulos planos con un controlador y un servicio (catálogos, tablas maestras, endpoints de administración). Imponer las cuatro capas a un CRUD de países es ceremonia pura. Lo que sí debe ser uniforme es la dirección de las dependencias, no la profundidad de las carpetas.

20.4.2 El mismo caso de uso, mal y bien repartido

Caso de uso: completar una tarea. Reglas: no se puede completar una tarea archivada, no se puede completar dos veces, y al completarla se registra la fecha y se notifica al responsable del proyecto.

tasks.controller.tsINCORRECTO
@Controller('tasks')
export class TasksController {
  constructor(private readonly em: EntityManager) {}

  @Patch(':id/complete')
  async complete(@Param('id') id: string) {
    // 1. El controlador consulta la base de datos
    const task = await this.em.findOne(Task, { id }, { populate: ['project.owner'] });
    if (!task) throw new NotFoundException();
    // 2. ...y contiene las REGLAS DE NEGOCIO
    if (task.archivedAt !== null) throw new BadRequestException('archivada');
    if (task.status === 'done') throw new BadRequestException('ya completada');
    // 3. ...y muta el estado a mano
    task.status = 'done';
    task.completedAt = new Date();
    task.project.pendingCount -= 1;
    await this.em.flush();
    // 4. ...y habla con un servicio externo
    await this.mailer.send(task.project.owner.email, 'Completada', `${task.title}...`);
    return task;   // 5. ...y devuelve la ENTIDAD, con todo dentro
  }
}
presentation/tasks.controller.tsCORRECTO
@Controller('tasks')
export class TasksController {
  constructor(private readonly completeTask: CompleteTaskUseCase) {}

  @Patch(':id/complete')
  async complete(
    @Param('id', ParseUUIDPipe) id: string,
    @CurrentUser() user: AuthUser,
  ): Promise<TaskResponseDto> {
    // La presentación solo traduce: HTTP -> caso de uso -> HTTP.
    const task = await this.completeTask.execute({ taskId: id, actorId: user.id });
    return TaskResponseDto.from(task);
  }
}
// ─────────── application/complete-task.use-case.ts ───────────
@Injectable()
export class CompleteTaskUseCase {
  constructor(
    @Inject(TASK_REPOSITORY) private readonly tasks: TaskRepository,
    private readonly uow: UnitOfWorkPort,
    private readonly events: DomainEventPublisher,
    private readonly clock: ClockPort,
  ) {}

  async execute(cmd: CompleteTaskCommand): Promise<Task> {
    return this.uow.transactional(async () => {
      const task = await this.tasks.byId(cmd.taskId);
      if (!task) throw new TaskNotFound(cmd.taskId);
      task.complete(this.clock.now());   // la REGLA vive en el dominio
      await this.tasks.save(task);
      this.events.publishAll(task.pullEvents());
      return task;
    });
  }
}
domain/task.entity.ts · las reglas viven aquí y solo aquí
@Entity()
export class Task {
  @PrimaryKey() id!: string;
  @Property() title!: string;
  @Enum(() => TaskStatus) status: TaskStatus = TaskStatus.Pending;
  @Property({ nullable: true }) completedAt: Date | null = null;
  @Property({ nullable: true }) archivedAt: Date | null = null;
  @ManyToOne(() => Project, { ref: true }) project!: Ref<Project>;
  private events: DomainEvent[] = [];

  /** Invariante de negocio: solo una tarea viva y pendiente puede completarse. */
  complete(now: Date): void {
    if (this.archivedAt !== null) throw new TaskArchived(this.id);   // error de DOMINIO, no HTTP
    if (this.status === TaskStatus.Done) throw new TaskAlreadyCompleted(this.id);
    this.status = TaskStatus.Done;
    this.completedAt = now;                     // el tiempo entra como parámetro
    this.events.push(new TaskCompleted(this.id, this.project.id, now));
  }

  pullEvents(): DomainEvent[] {
    const pending = this.events;
    this.events = [];
    return pending;
  }
}

Fíjate en tres detalles del bloque correcto. Primero, el tiempo se inyecta: complete(now) en lugar de new Date() dentro del método, lo que permite testear el vencimiento sin manipular el reloj del sistema. Segundo, el dominio lanza errores de dominio, no BadRequestException; un filtro de excepciones en la presentación los traduce a códigos HTTP, de modo que el mismo caso de uso sirve para un endpoint REST, un comando de CLI y un consumidor de cola. Tercero, el efecto secundario (el correo) no está en el caso de uso: se emite un evento de dominio y un manejador se encarga, con lo que añadir una notificación push mañana no toca este archivo.

El pragmatismo obligatorio sobre MikroORM en el dominio La entidad anterior lleva decoradores de MikroORM, y un purista diría que eso ya viola la regla de dependencia. Es cierto en la teoría. En la práctica, separar el modelo de dominio del de persistencia obliga a mantener dos clases y un mapeador por agregado, y a renunciar al change tracking automático del Unit of Work. Nuestra recomendación: convive con los decoradores mientras el dominio no importe el EntityManager ni el QueryBuilder. Esa es la línea roja que sí duele cruzar. Si el modelo de dominio y el relacional divergen de verdad (es raro y lo notarás), entonces —y solo entonces— separa las dos clases.

20.5 Arquitectura hexagonal: puertos y adaptadores

Alistair Cockburn la publicó en 2005 con un objetivo muy concreto: «permitir que una aplicación sea manejada indistintamente por usuarios, programas, tests automatizados o scripts, y que se desarrolle y pruebe de forma aislada de sus dispositivos y bases de datos». El hexágono no significa nada (dibujó seis lados para tener sitio donde poner puertos); el nombre técnico es puertos y adaptadores.

        ADAPTADORES DE ENTRADA                        ADAPTADORES DE SALIDA
        (driving / primarios)                          (driven / secundarios)

   ┌──────────────┐                                       ┌────────────────────┐
   │ Controlador  │──┐                                 ┌──│ MikroTaskRepository│
   │    HTTP      │  │                                 │  └────────────────────┘
   └──────────────┘  │    ┌───────────────────────┐    │  ┌────────────────────┐
   ┌──────────────┐  │    │                       │    ├──│ InMemoryTaskRepo   │ (tests)
   │  Consumidor  │──┼───►│    NÚCLEO             │◄───┤  └────────────────────┘
   │   de cola    │  │    │  dominio +            │    │  ┌────────────────────┐
   └──────────────┘  │    │  casos de uso         │    ├──│ SmtpMailer         │
   ┌──────────────┐  │    │                       │    │  └────────────────────┘
   │  Comando CLI │──┤    └───────────────────────┘    │  ┌────────────────────┐
   └──────────────┘  │      ▲                  ▲       └──│ SystemClock        │
   ┌──────────────┐  │      │                  │          └────────────────────┘
   │  Test e2e    │──┘   PUERTO DE          PUERTO DE
   └──────────────┘      ENTRADA            SALIDA
                         (interfaz del      (interfaz que el núcleo
                          caso de uso)       DECLARA y otro implementa)

   Lo esencial: la flecha de dependencia de código SIEMPRE entra al núcleo.
   El núcleo no sabe si al otro lado hay Postgres, un fichero o un array.

20.5.1 Puertos de entrada y de salida

20.5.2 Cómo se implementa con la inyección de dependencias de Nest

Aquí aparece la fricción técnica clásica: las interfaces de TypeScript no existen en tiempo de ejecución, y la inyección de Nest se basa en metadatos de tipos emitidos por el compilador. No puedes escribir constructor(private repo: TaskRepository) y esperar que Nest resuelva la interfaz: en el JavaScript generado ese tipo se ha borrado. La solución idiomática son tres piezas: interfaz + token + proveedor.

tasks/domain/task.repository.ts · el PUERTO
import { Task } from './task.entity';

/** Puerto de salida. Vive en el dominio y habla su lenguaje: nada de
 *  "findOne", "where" ni "populate". Uno por agregado. */
export interface TaskRepository {
  byId(id: string): Promise<Task | null>;
  pendingOfProject(projectId: string): Promise<Task[]>;
  save(task: Task): Promise<void>;
  remove(task: Task): Promise<void>;
  nextIdentity(): string;
}

/** Token de inyección: el puente entre una interfaz que se borra y el DI que no. */
export const TASK_REPOSITORY = Symbol('TaskRepository');
infrastructure/mikro-task.repository.ts
import { EntityManager } from '@mikro-orm/postgresql';
import { v4 as uuid } from 'uuid';

@Injectable()
export class MikroTaskRepository implements TaskRepository {
  constructor(private readonly em: EntityManager) {}

  byId(id: string): Promise<Task | null> {
    return this.em.findOne(Task, { id });
  }

  pendingOfProject(projectId: string): Promise<Task[]> {
    return this.em.find(Task,
      { project: projectId, status: TaskStatus.Pending, archivedAt: null },
      { orderBy: { dueDate: 'asc' } });
  }

  async save(task: Task): Promise<void> {
    // persist() es idempotente; el flush lo controla la
    // unidad de trabajo del caso de uso.
    this.em.persist(task);
  }

  async remove(task: Task): Promise<void> { this.em.remove(task); }

  nextIdentity(): string { return uuid(); }
}
testing/in-memory-task.repository.ts
/** Adaptador en memoria: un "fake", no un mock. Implementa el contrato
 *  de verdad, así que los tests verifican comportamiento, no llamadas. */
export class InMemoryTaskRepository implements TaskRepository {
  private readonly store = new Map<string, Task>();
  private seq = 0;

  async byId(id: string): Promise<Task | null> {
    return this.store.get(id) ?? null;
  }

  async pendingOfProject(projectId: string): Promise<Task[]> {
    return [...this.store.values()].filter(
      (t) => t.project.id === projectId
          && t.status === TaskStatus.Pending
          && t.archivedAt === null,
    );
  }

  async save(task: Task): Promise<void> { this.store.set(task.id, task); }

  async remove(task: Task): Promise<void> { this.store.delete(task.id); }

  nextIdentity(): string { return `task-${++this.seq}`; }
}
tasks.module.ts · aquí se ata el puerto al adaptador
@Module({
  imports: [MikroOrmModule.forFeature([Task, Project])],
  controllers: [TasksController],
  providers: [
    CompleteTaskUseCase,
    ListTasksUseCase,
    { provide: TASK_REPOSITORY, useClass: MikroTaskRepository },
    { provide: CLOCK, useClass: SystemClock },
    { provide: NOTIFICATION_SENDER, useClass: SmtpNotificationSender },
  ],
  exports: [CompleteTaskUseCase],
})
export class TasksModule {}
complete-task.use-case.spec.ts · lo que se gana
describe('CompleteTaskUseCase', () => {
  const NOW = new Date('2026-03-01T10:00:00Z');
  let repo: InMemoryTaskRepository;
  let useCase: CompleteTaskUseCase;

  beforeEach(() => {
    repo = new InMemoryTaskRepository();
    useCase = new CompleteTaskUseCase(repo, new NoopUnitOfWork(),
      new RecordingEventPublisher(), { now: () => NOW });  // reloj fijo
  });

  it('completa con la fecha del reloj', async () => {
    await repo.save(TaskMother.pending({ id: 't-1' }));
    await useCase.execute({ taskId: 't-1', actorId: 'u-1' });
    const stored = await repo.byId('t-1');
    expect(stored!.status).toBe(TaskStatus.Done);
    expect(stored!.completedAt).toEqual(NOW);
  });

  it('rechaza una tarea archivada', async () => {
    await repo.save(TaskMother.archived({ id: 't-2' }));
    await expect(useCase.execute({ taskId: 't-2', actorId: 'u-1' }))
      .rejects.toBeInstanceOf(TaskArchived);
  });
});
// Sin Postgres, sin contenedor, sin TestingModule: 4 ms por test.

Qué ganas

  • Tests de negocio rápidos y deterministas, sin contenedores ni datos de prueba.
  • Sustituir un proveedor (SMTP por SendGrid, Postgres por otra fuente) toca un archivo y una línea del módulo.
  • El dominio se lee como el negocio, sin ruido técnico intercalado.
  • Varios adaptadores de entrada gratis: el mismo caso de uso sirve a HTTP, a una cola y a un comando de CLI.

Qué cuesta

  • Más archivos y más saltos. Ir del endpoint a la consulta SQL pasa por tres archivos.
  • Ceremonia del token en cada puerto: interfaz, símbolo y registro en el módulo.
  • Se pierden atajos del ORM si el puerto es demasiado estrecho: los informes con agregaciones no caben en un repositorio de agregados (usa consultas de lectura aparte).
  • Tentación de abstraerlo todo: un puerto por cada clase es el antipatrón de la sección 20.11.
Criterio pragmático de cuándo poner un puerto Pon un puerto cuando se cumpla al menos una de estas condiciones: (1) hay una segunda implementación real o previsible; (2) la dependencia es lenta, cara o poco fiable en los tests (base de datos, red, correo, pagos, reloj, aleatoriedad); (3) es un servicio de terceros que podrías cambiar. Si no se cumple ninguna, usa la clase concreta y añade el puerto el día que lo necesites: extraer una interfaz de una clase existente es una refactorización de treinta segundos con cualquier IDE.

20.6 Clean Architecture, onion y la comparación honesta

Onion Architecture (Jeffrey Palermo, 2008) y Clean Architecture (Robert C. Martin, 2012) son reformulaciones de la misma idea de Cockburn. Las tres comparten el invariante fundamental: las dependencias de código apuntan hacia el núcleo de negocio, y todo lo que sea un detalle reemplazable (base de datos, framework, protocolo, interfaz de usuario) queda fuera.

Hexagonal (2005)Onion (2008)Clean (2012)
MetáforaPuertos y adaptadoresCapas concéntricasCírculos con regla de dependencia
AportaLa simetría entrada/salida y la idea del test como otro adaptadorNombrar las capas del núcleo: modelo, servicios de dominio, servicios de aplicaciónVocabulario explícito (entidades, casos de uso, adaptadores de interfaz) y la regla del cruce de fronteras con DTOs
RiesgoProliferación de puertos trivialesCapas dentro de capasCeremonia: request/response models, presenters y mapeadores por todas partes
Recomendación pragmática Para el 90 % de los proyectos Angular + NestJS, una arquitectura en capas bien disciplinada con puertos solo en las dependencias externas ya da casi todo el beneficio con una fracción del coste. Reserva el despliegue completo de Clean Architecture (puerto de entrada por caso de uso, modelos de petición y respuesta propios, presenters) para sistemas con vida esperada larga, dominio complejo y varios equipos. Y una advertencia útil: lo que aporta valor no es la forma del diagrama, es la regla de dependencia. Si la respetas, el nombre de la arquitectura da igual.

20.7 Domain-Driven Design táctico

DDD (Eric Evans, 2003) es antes que nada una metodología de comunicación: el código y el negocio deben hablar el mismo idioma. Los patrones tácticos son las herramientas para que ese idioma quepa en clases de TypeScript.

20.7.1 Los bloques de construcción

ConceptoDefiniciónTraducción a este stack
EntidadTiene identidad propia que persiste aunque cambien sus atributosClase con @Entity() y @PrimaryKey()
Value objectSe define por su valor, es inmutable y no tiene identidad@Embeddable(), o un tipo con @Property({ type: CustomType })
AgregadoGrupo de objetos que se trata como una unidad de consistenciaUn grafo de entidades con reglas de cascada y una única puerta de entrada
Raíz de agregadoLa única entidad del agregado accesible desde fueraLa entidad que expone el repositorio
RepositorioColección de agregados con apariencia de colección en memoriaPuerto + adaptador MikroORM. Uno por raíz de agregado, no uno por tabla
Servicio de dominioLógica de negocio que no pertenece a ninguna entidad concretaClase sin estado en domain/, sin decoradores de Nest si puede evitarse
Servicio de aplicaciónOrquesta un caso de uso; no contiene reglas@Injectable() en application/, controla la transacción
Evento de dominioAlgo relevante que ha ocurrido, en pasadoClase inmutable + EventEmitter2 o publicación tras el commit
EspecificaciónRegla de selección o validación reutilizable y componibleObjeto que produce un FilterQuery<T> de MikroORM
FábricaEncapsula la creación compleja de un agregado válidoMétodo estático Task.schedule(...) o clase fábrica si necesita dependencias

20.7.2 Diseño de agregados: la parte que más se equivoca

   AGREGADO "Project"                        AGREGADO "Invoice"
  ┌──────────────────────────────┐          ┌────────────────────────────┐
  │  ▄▄▄ Project  (RAÍZ) ▄▄▄     │          │  ▄▄▄ Invoice (RAÍZ) ▄▄▄    │
  │      id, name, ownerId       │          │      id, customerId        │
  │      ┌────────────────────┐  │          │      ┌──────────────────┐  │
  │      │ Task (interna)     │  │          │      │ InvoiceLine      │  │
  │      │ id, title, status  │  │          │      │ concepto, precio │  │
  │      └────────────────────┘  │          │      └──────────────────┘  │
  │   invariante: no más de 500  │          │  invariante: total = Σ     │
  │   tareas activas por proyecto│          │  líneas, y no se toca si   │
  └──────────────┬───────────────┘          │  está emitida              │
                 │                          └─────────────┬──────────────┘
                 │  referencia POR IDENTIDAD (nunca por objeto)
                 └──────────────► customerId: string ◄────┘

  ┌──────────────────────────────────────────────────────────────────────┐
  │ 1 transacción = 1 agregado modificado.                               │
  │ ¿Necesitas cambiar dos? → evento de dominio + consistencia eventual. │
  └──────────────────────────────────────────────────────────────────────┘

Las reglas de diseño de agregados, en el orden en que Vaughn Vernon las formuló: (1) modela invariantes verdaderas dentro de límites de consistencia —si dos datos deben cuadrar siempre, en el mismo instante, van en el mismo agregado; si basta con que cuadren «en unos segundos», no—; (2) diseña agregados pequeños, porque un agregado grande es una unidad de bloqueo grande y dos usuarios que tocan cosas distintas chocan sin motivo; (3) referencia otros agregados por identidad (customerId: string, no customer: Customer), lo que evita cargar medio grafo sin querer y deja la frontera visible; y (4) usa consistencia eventual fuera del límite, coordinando con eventos de dominio.

Por qué «una transacción, un agregado» No es purismo: es contención. Si tu transacción escribe en tres agregados, bloqueas tres conjuntos de filas y multiplicas la probabilidad de deadlock y de conflicto de bloqueo optimista. Además, ese límite es exactamente por donde tendrás que cortar el día que separes servicios. Ahora bien, sé honesto: en un monolito con una sola base de datos, tocar dos agregados en una transacción funciona y a veces es lo más simple. Hazlo a sabiendas, no por descuido, y no lo conviertas en la norma.

20.7.3 Value object, fábrica y especificación en TypeScript

domain/money.vo.ts · value object con @Embeddable
@Embeddable()
export class Money {
  @Property({ type: 'int' }) readonly cents!: number;      // enteros: nunca float para dinero
  @Property({ length: 3 })   readonly currency!: string;

  private constructor(cents: number, currency: string) {
    if (!Number.isInteger(cents)) throw new InvalidMoney('céntimos no enteros');
    this.cents = cents;
    this.currency = currency;
  }

  static fromCents(cents: number, currency = 'EUR'): Money { return new Money(cents, currency); }

  add(other: Money): Money {                                // inmutable: devuelve uno nuevo
    if (other.currency !== this.currency) throw new CurrencyMismatch();
    return new Money(this.cents + other.cents, this.currency);
  }

  equals(other: Money): boolean {                           // igualdad por VALOR
    return this.cents === other.cents && this.currency === other.currency;
  }
}

Un value object no es «una clase pequeña»: es la diferencia entre que calcularTotal(precio: number, iva: number) se pueda invocar con los argumentos intercambiados sin que el compilador diga nada, y que calcularTotal(precio: Money, iva: TaxRate) falle en compilación. Es el remedio del primitive obsession.

domain/task.entity.ts · fábrica
export class Task {
  // Constructor privado: no se puede crear una Task inválida desde fuera.
  private constructor(id: string, title: string, project: Ref<Project>) {
    this.id = id;
    this.title = title;
    this.project = project;
  }

  /** Fábrica: nombre del negocio + invariantes de creación. */
  static schedule(input: {
    id: string; title: string; project: Ref<Project>; dueDate: Date; now: Date;
  }): Task {
    if (input.title.trim().length < 3) throw new InvalidTaskTitle(input.title);
    if (input.dueDate < input.now) throw new DueDateInThePast(input.dueDate);
    const task = new Task(input.id, input.title.trim(), input.project);
    task.dueDate = input.dueDate;
    task.events.push(new TaskScheduled(task.id, input.dueDate));
    return task;
  }
}
domain/task.specs.ts · especificación componible
export interface Specification<T> {
  toQuery(): FilterQuery<T>;
  isSatisfiedBy(candidate: T): boolean;
}

export class OverdueTasks implements Specification<Task> {
  constructor(private readonly now: Date) {}
  toQuery(): FilterQuery<Task> {
    return { dueDate: { $lt: this.now }, status: TaskStatus.Pending };
  }
  isSatisfiedBy(t: Task): boolean {
    return t.dueDate !== null && t.dueDate < this.now && t.status === TaskStatus.Pending;
  }
}

export function and<T>(...specs: Specification<T>[]): Specification<T> {
  return {
    toQuery: () => ({ $and: specs.map((s) => s.toQuery()) }) as FilterQuery<T>,
    isSatisfiedBy: (c) => specs.every((s) => s.isSatisfiedBy(c)),
  };
}
// Uso: this.tasks.matching(and(new OfProject(id), new OverdueTasks(now)))

La ventaja de la especificación es que la misma regla sirve para consultar y para validar en memoria: el filtro de la consulta SQL y la comprobación sobre un objeto ya cargado no pueden divergir porque están escritos una sola vez. Su coste es que filtra la sintaxis de MikroORM hacia el dominio con FilterQuery; si eso te molesta, define tu propio árbol de criterios y tradúcelo en el adaptador, a cambio de bastante más código.

20.7.4 Eventos de dominio en Nest

application/domain-event.publisher.ts + manejador
@Injectable()
export class DomainEventPublisher {
  constructor(private readonly emitter: EventEmitter2) {}
  /** Se llama DESPUÉS del commit: si la transacción falla, nadie recibe nada. */
  publishAll(events: readonly DomainEvent[]): void {
    for (const event of events) this.emitter.emit(event.name, event);
  }
}

@Injectable()
export class NotifyOwnerOnTaskCompleted {
  constructor(@Inject(NOTIFICATION_SENDER) private readonly sender: NotificationSender) {}
  @OnEvent('task.completed', { async: true })
  async handle(event: TaskCompleted): Promise<void> {
    await this.sender.send({ to: event.ownerEmail, template: 'task-completed', data: event });
  }
}
El orden importa: publicar dentro de la transacción es un error clásico Si emites el evento antes del flush y la transacción se deshace, ya has enviado el correo de una tarea que no se completó. Publica siempre después del commit; y si el efecto no puede perderse bajo ningún concepto (un cobro, un mensaje a otro sistema), necesitas el patrón Outbox de la sección 20.10, porque «después del commit» significa que un fallo del proceso justo ahí pierde el evento.

20.7.5 DDD estratégico en tres párrafos

20.8 Los cinco principios SOLID

El acrónimo lo popularizó Michael Feathers a partir de los principios que Robert C. Martin publicó desde finales de los noventa. No son leyes: son heurísticas para reducir el coste del cambio. Aplicados con criterio, quitan trabajo; aplicados por dogma, lo multiplican. Cada apartado incluye deliberadamente el matiz de cuándo no vale la pena.

20.8.1 SRP · Principio de responsabilidad única

Definición. «Una clase debe tener una sola razón para cambiar». La formulación que el propio Martin acabó dando es mejor: un módulo debe ser responsable ante un único actor. No se trata de que haga «una sola cosa», sino de que solo un grupo de interesados pueda pedir que cambie. Síntoma: el archivo aparece en pull requests de temas que no tienen nada que ver entre sí; su test necesita mocks de cinco cosas distintas; su nombre contiene «y» o es tan genérico (TaskManager) que no compromete a nada.

tasks.service.tsINCORRECTO
@Injectable()
export class TasksService {
  constructor(private em: EntityManager, private mailer: MailerService,
              private stripe: StripeService, private pdf: PdfService) {}

  async complete(id: string, dto: CompleteDto) {
    // (1) validación de formato
    if (!dto.comment || dto.comment.length > 500) throw new BadRequestException('inválido');
    const task = await this.em.findOne(Task, { id });
    // (2) regla de negocio
    if (task!.status === 'done') throw new ConflictException();
    task!.status = 'done';
    await this.em.flush();                                     // (3) persistencia
    await this.mailer.send(task!.owner.email, 'Hecho', '...'); // (4) notificación
    // (5) facturación y (6) generación de documentos
    const invoice = await this.stripe.charge(task!.owner.customerId, 900);
    const buffer = await this.pdf.render('invoice', invoice);
    await this.mailer.sendWithAttachment(task!.owner.email, buffer);
  }
}
// Cambia el formato del PDF    -> se toca este archivo.
// Cambia la pasarela de pago   -> se toca este archivo.
// Cambia una regla de tareas   -> se toca este archivo.
// Tres actores, un solo archivo: conflictos de merge garantizados.
application/complete-task.use-case.tsCORRECTO
// La validación de formato vive en el DTO de presentación (class-validator).
// La regla de negocio, en la entidad. La facturación y el PDF, en su módulo,
// activados por un evento. Este caso de uso solo orquesta.
@Injectable()
export class CompleteTaskUseCase {
  constructor(
    @Inject(TASK_REPOSITORY) private readonly tasks: TaskRepository,
    private readonly uow: UnitOfWorkPort,
    private readonly events: DomainEventPublisher,
    private readonly clock: ClockPort,
  ) {}

  async execute(cmd: CompleteTaskCommand): Promise<void> {
    const task = await this.uow.transactional(async () => {
      const t = await this.tasks.byId(cmd.taskId);
      if (!t) throw new TaskNotFound(cmd.taskId);
      t.complete(this.clock.now());
      await this.tasks.save(t);
      return t;
    });
    this.events.publishAll(task.pullEvents());
  }
}
// billing/on-task-completed.handler.ts       -> cobra
// notifications/on-task-completed.handler.ts -> avisa
// Cada actor tiene su archivo; los cambios no se pisan.
Cuándo SRP es contraproducente Cuando se interpreta como «una clase por método». Un servicio de 120 líneas que gestiona coherentemente las operaciones de un agregado no viola SRP: tiene un solo actor. Trocearlo en ocho clases de quince líneas que se llaman en cadena empeora la legibilidad sin reducir el acoplamiento. La pregunta correcta no es «¿cuántas cosas hace?», sino «¿quién puede pedir que cambie?».

20.8.2 OCP · Principio de abierto/cerrado

Definición. Bertrand Meyer, 1988: un módulo debe estar abierto a la extensión y cerrado a la modificación. En la práctica moderna: debería poder añadir un comportamiento nuevo añadiendo código, no editando código que ya funciona y está probado. Síntoma: un switch o una cadena de if/else if sobre un tipo, que crece cada vez que aparece un caso nuevo. Peor aún: el mismo switch repetido en tres archivos.

notifications.service.tsINCORRECTO
@Injectable()
export class NotificationsService {
  async send(kind: NotificationKind, to: string, payload: unknown) {
    switch (kind) {
      case 'email': return this.mailer.send(to, payload);
      case 'sms':   return this.twilio.messages.create({ to, body: String(payload) });
      case 'push':  return this.fcm.send({ token: to, data: payload as any });
      // Cada canal nuevo (Slack, WhatsApp, webhook...) obliga a editar esta clase
      // (que ya funcionaba), inyectar otra dependencia y re-testear lo anterior.
      default: throw new Error(`canal no soportado: ${kind}`);
    }
  }
}
notifications/channel.strategy.tsCORRECTO
export interface NotificationChannel {
  readonly kind: NotificationKind;
  send(to: string, payload: NotificationPayload): Promise<void>;
}
export const NOTIFICATION_CHANNELS = Symbol('NotificationChannels');

@Injectable()
export class EmailChannel implements NotificationChannel {
  readonly kind = 'email' as const;
  constructor(private readonly mailer: MailerPort) {}
  async send(to: string, p: NotificationPayload) { await this.mailer.send(to, p); }
}

@Injectable()
export class NotificationsService {
  private readonly byKind: ReadonlyMap<NotificationKind, NotificationChannel>;
  constructor(@Inject(NOTIFICATION_CHANNELS) channels: NotificationChannel[]) {
    this.byKind = new Map(channels.map((c) => [c.kind, c]));
  }
  async send(kind: NotificationKind, to: string, p: NotificationPayload) {
    const channel = this.byKind.get(kind);
    if (!channel) throw new UnsupportedChannel(kind);
    return channel.send(to, p);
  }
}
// notifications.module.ts — añadir un canal es AÑADIR, no editar:
// { provide: NOTIFICATION_CHANNELS, useFactory: (...c: NotificationChannel[]) => c,
//   inject: [EmailChannel, SmsChannel, PushChannel, SlackChannel] }
Cuándo OCP es contraproducente Cuando hay dos casos y no se prevén más. Un if con dos ramas es más legible que dos clases, una interfaz, un token y un registro en el módulo. La regla práctica es la «regla de tres»: al primer caso escribe el código, al segundo aguanta la duplicación o el if, y al tercero extrae la abstracción, que para entonces ya sabrás cuál es la correcta.

20.8.3 LSP · Principio de sustitución de Liskov

Definición. Barbara Liskov, 1987: si S es subtipo de T, los objetos de tipo T deben poder sustituirse por objetos de tipo S sin alterar la corrección del programa. En cristiano: una subclase no puede exigir más ni prometer menos que su base. Síntoma: un método heredado que lanza NotImplementedError; un if (x instanceof Y) en el cliente para tratar «el caso raro»; documentación que dice «esta implementación no soporta...».

replica-task.repository.tsINCORRECTO
export class BaseTaskRepository {
  async byId(id: string): Promise<Task | null> { /* ... */ }
  async save(task: Task): Promise<void> { /* ... */ }
  async remove(task: Task): Promise<void> { /* ... */ }
}

/** Repositorio contra una réplica de solo lectura. */
export class ReplicaTaskRepository extends BaseTaskRepository {
  override async save(): Promise<void> {
    throw new Error('la réplica es de solo lectura');   // ROMPE LSP
  }
  override async remove(): Promise<void> {
    throw new Error('la réplica es de solo lectura');
  }
}
// Cualquier caso de uso que reciba un BaseTaskRepository puede explotar en
// runtime según qué instancia le haya inyectado el módulo: el tipo miente.
task.repository.tsCORRECTO
// Separar el contrato de lectura del de escritura: cada consumidor pide
// exactamente la capacidad que necesita y el compilador impide el error.
export interface TaskReader {
  byId(id: string): Promise<Task | null>;
  matching(spec: Specification<Task>): Promise<Task[]>;
}
export interface TaskWriter {
  save(task: Task): Promise<void>;
  remove(task: Task): Promise<void>;
}
export type TaskRepository = TaskReader & TaskWriter;
export const TASK_READER = Symbol('TaskReader');
export const TASK_REPOSITORY = Symbol('TaskRepository');

// La réplica implementa SOLO TaskReader: no promete lo que no cumple.
export class ReplicaTaskReader implements TaskReader { /* ... */ }

// El caso de uso de listado pide TaskReader; el de completar, TaskRepository.
// No hay ninguna instancia que lance por un método que "no soporta".
El matiz de LSP El 95 % de las violaciones de Liskov desaparecen si dejas de usar herencia para reutilizar código. La herencia debe expresar «es un», no «se parece a». En este stack casi siempre hay una alternativa mejor: composición, interfaces pequeñas o funciones. Cuando de verdad necesitas una jerarquía (una excepción base de dominio, una clase abstracta con Template Method), respeta las reglas: no fortalezcas precondiciones, no debilites postcondiciones y no rompas invariantes de la base.

20.8.4 ISP · Principio de segregación de interfaces

Definición. Ningún cliente debe verse obligado a depender de métodos que no usa. Una interfaz gorda acopla a todos sus consumidores entre sí: cambiar un método por uno de ellos recompila y re-testea a todos. Síntoma: interfaces llamadas IAlgoService con veinte métodos; mocks de test en los que rellenas quince funciones vacías para probar una.

user.service.interface.tsINCORRECTO
export interface IUserService {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  create(dto: CreateUserDto): Promise<User>;
  update(id: string, dto: UpdateUserDto): Promise<User>;
  delete(id: string): Promise<void>;
  changePassword(id: string, old: string, next: string): Promise<void>;
  resetPassword(email: string): Promise<void>;
  verifyEmail(token: string): Promise<void>;
  enableTwoFactor(id: string): Promise<string>;
  assignRole(id: string, role: Role): Promise<void>;
  listPermissions(id: string): Promise<Permission[]>;
  exportGdprData(id: string): Promise<Buffer>;
  anonymize(id: string): Promise<void>;
  /* ...y siete más */
}
// Para testear un componente que solo necesita findById,
// hay que construir un doble con VEINTE métodos.
users/ports/*.tsCORRECTO
// Interfaces definidas POR EL CONSUMIDOR, con lo que ese consumidor usa.
export interface UserFinder { byId(id: string): Promise<User | null>; }
export interface PasswordChanger {
  change(userId: string, current: string, next: string): Promise<void>;
}
export interface GdprExporter { export(userId: string): Promise<Buffer>; }
export const USER_FINDER = Symbol('UserFinder');

// Una única clase puede implementar varias: la segregación es
// del CONTRATO, no necesariamente de la implementación.
@Injectable()
export class UsersService implements UserFinder, PasswordChanger { /* ... */ }

// Y el test se vuelve trivial:
const finder: UserFinder = { byId: async () => UserMother.active() };
Cuándo ISP es contraproducente Cuando produce un enjambre de interfaces de un solo método usadas una sola vez. Si dos capacidades siempre se consumen juntas, separarlas solo añade nombres que recordar. El objetivo es reducir el acoplamiento entre consumidores distintos; si solo hay un consumidor, no hay nada que segregar.

20.8.5 DIP · Principio de inversión de dependencias

Definición. Los módulos de alto nivel no deben depender de los de bajo nivel; ambos deben depender de abstracciones. Y las abstracciones no dependen de los detalles: la interfaz pertenece a quien la usa, no a quien la implementa. Ese matiz es lo que convierte DIP en algo más que «usa interfaces». Síntoma: un archivo de domain/ con import ... from '@mikro-orm/core' o from 'axios'; un test de negocio que necesita base de datos.

domain/pricing.service.tsINCORRECTO
import { EntityManager } from '@mikro-orm/postgresql';
import axios from 'axios';

@Injectable()
export class PricingService {
  constructor(private readonly em: EntityManager) {}

  async priceFor(projectId: string): Promise<number> {
    // El servicio de DOMINIO conoce el ORM...
    const project = await this.em.findOne(Project, { id: projectId }, { populate: ['tasks'] });
    // ...hace una llamada HTTP directa...
    const { data } = await axios.get('https://api.fx.example/eur-usd');
    // ...y lee el reloj del sistema.
    const isWeekend = [0, 6].includes(new Date().getDay());
    const base = project!.tasks.count() * 100;
    return base * data.rate * (isWeekend ? 1.2 : 1);
  }
}
// Testear esto exige: base de datos, red y viajar en el tiempo.
domain/pricing.service.tsCORRECTO
// Puertos declarados por el DOMINIO, con su vocabulario:
export interface ExchangeRates { rate(from: Currency, to: Currency): Promise<number>; }
export interface ClockPort { now(): Date; }
export const EXCHANGE_RATES = Symbol('ExchangeRates');
export const CLOCK = Symbol('Clock');

/** Servicio de dominio puro: sin decoradores, sin E/S, sin reloj global. */
export class PricingService {
  constructor(private readonly rates: ExchangeRates, private readonly clock: ClockPort) {}

  async priceFor(project: Project, to: Currency): Promise<Money> {
    const rate = await this.rates.rate('EUR', to);
    const surcharge = isWeekend(this.clock.now()) ? 1.2 : 1;
    const base = project.activeTaskCount() * 100;
    return Money.fromCents(Math.round(base * rate * surcharge), to);
  }
}
// El módulo de Nest ata los cables (infraestructura -> puertos):
// { provide: EXCHANGE_RATES, useClass: HttpExchangeRates },
// { provide: PricingService, useFactory: (r, c) => new PricingService(r, c),
//   inject: [EXCHANGE_RATES, CLOCK] }
Inyección de dependencias no es lo mismo que inversión de dependencias Es la confusión más extendida. Inyectar EntityManager por constructor es inyección, pero la dependencia sigue apuntando del dominio hacia MikroORM: no hay inversión ninguna. Solo inviertes cuando la abstracción la define el consumidor y vive con él. Dicho esto, el matiz honesto: en un módulo de infraestructura pura (un adaptador de S3, un servicio de caché) depender directamente del SDK concreto es lo correcto; envolverlo todo «por si acaso» es abstracción especulativa.

20.9 Patrones de diseño aplicados al stack

Los veintitrés patrones del libro de la «banda de los cuatro» (Gamma, Helm, Johnson y Vlissides, 1994) no son inventos: son nombres para soluciones que ya existían. Su valor hoy es sobre todo de vocabulario: decir «esto es una estrategia registrada por token» ahorra un párrafo de explicación. Y conviene interiorizar algo: ya estás usando la mitad de ellos, porque Angular, Nest y MikroORM están construidos con patrones. Reconocerlos es más rentable que implementarlos desde cero.

20.9.1 Creacionales

storage/storage.module.ts · Abstract Factory con useFactory
export interface FileStorage { put(key: string, data: Buffer): Promise<string>; get(key: string): Promise<Buffer>; }
export const FILE_STORAGE = Symbol('FileStorage');

@Module({
  providers: [{
    provide: FILE_STORAGE,
    inject: [ConfigService],
    useFactory: (config: ConfigService): FileStorage => {
      // Una sola decisión, en un solo sitio: el resto de la aplicación
      // solo conoce la interfaz FileStorage.
      switch (config.get('STORAGE_DRIVER')) {
        case 's3':    return new S3Storage(config.get('S3_BUCKET')!);
        case 'local': return new LocalDiskStorage(config.get('UPLOAD_DIR')!);
        default:      return new InMemoryStorage();   // desarrollo y tests
      }
    },
  }],
  exports: [FILE_STORAGE],
})
export class StorageModule {}
El switch del factory no viola OCP Puede parecer contradictorio con la sección 20.8.2, pero no lo es: la selección de implementación tiene que ocurrir en algún sitio, y ese sitio es la composición del módulo (la composition root). Lo que OCP prohíbe es que ese switch esté dentro de la lógica de negocio y se repita por toda la aplicación.

20.9.2 Estructurales y de comportamiento

PatrónPara quéDónde lo tienes ya
AdapterHacer que una interfaz ajena encaje con la que tú necesitasCada implementación de un puerto; la capa anticorrupción con una API de terceros
DecoratorAñadir comportamiento sin tocar el objeto originalInterceptores de Nest (caché, logging, transformación) y el decorator provider con useFactory que envuelve otro servicio
FacadeUna puerta simple a un subsistema complejoUn servicio de Angular que agrupa varias llamadas HTTP y estado de señales para una pantalla
ProxyUn sustituto que controla el acceso al objeto realLas referencias perezosas de MikroORM (Ref<T>, Collection): parecen la entidad pero solo la cargan al acceder
CompositeTratar igual a un objeto y a un grupo de objetosEl árbol de componentes y de inyectores de Angular; tareas con subtareas
dashboard/dashboard.facade.ts · fachada en Angular
@Injectable({ providedIn: 'root' })
export class DashboardFacade {
  private readonly tasks = inject(TasksApi);
  private readonly projects = inject(ProjectsApi);
  private readonly state = signal<DashboardState>({ loading: true, tasks: [], projects: [] });
  /** El componente solo ve esto: un estado de solo lectura y dos acciones. */
  readonly vm = this.state.asReadonly();
  readonly overdueCount = computed(() => this.vm().tasks.filter((t) => t.overdue).length);

  async load(projectId: string): Promise<void> {
    this.state.update((s) => ({ ...s, loading: true }));
    const [tasks, projects] = await Promise.all([
      firstValueFrom(this.tasks.pending(projectId)),
      firstValueFrom(this.projects.all()),
    ]);
    this.state.set({ loading: false, tasks, projects });
  }
}

La fachada hace que el componente sea tonto y, por tanto, testeable y reutilizable: no sabe cuántas peticiones hacen falta ni en qué orden. El riesgo es que se convierta en el «servicio dios» de la sección 20.11; se evita teniendo una fachada por pantalla o por flujo, no una por aplicación.

En cuanto a los patrones de comportamiento: Strategy ya lo has visto en OCP (precios por tipo de cliente, políticas de envío, políticas de reintento). Observer es RxJS entero, más EventEmitter2 en el backend. Chain of Responsibility es el pipeline de Nest: middleware, guards, interceptores, pipes y filtros, donde cada eslabón decide si sigue o corta. Template Method fija un esqueleto y deja huecos (importadores de ficheros, procesadores de cola), aunque hoy suele ser mejor Strategy por composición, porque Template Method ata a la herencia. Command es la base de CQRS: cada intención es un objeto con su manejador, lo que permite encolar, registrar, reintentar y auditar de forma uniforme. Mediator es el CommandBus de @nestjs/cqrs. Y State es la respuesta a un status: string con if repartidos por seis archivos:

domain/task-state.ts · máquina de estados explícita (patrón State)
type Transition = Readonly<Record<TaskStatus, readonly TaskStatus[]>>;

/** Una sola tabla de verdad, en lugar de ifs repartidos por seis archivos. */
const ALLOWED: Transition = {
  [TaskStatus.Pending]:    [TaskStatus.InProgress, TaskStatus.Cancelled],
  [TaskStatus.InProgress]: [TaskStatus.Done, TaskStatus.Pending, TaskStatus.Cancelled],
  [TaskStatus.Done]:       [],
  [TaskStatus.Cancelled]:  [TaskStatus.Pending],
};

export function assertTransition(from: TaskStatus, to: TaskStatus): void {
  if (!ALLOWED[from].includes(to)) throw new IllegalTaskTransition(from, to);
}

20.9.3 Tabla resumen

PatrónProblema que resuelveDónde aparece ya en tu stack
Factory MethodElegir la implementación en tiempo de arranqueuseFactory de Nest, APP_INITIALIZER en Angular
Abstract FactoryCrear familias coherentes de objetosFactoría que devuelve driver + repositorio + unidad de trabajo
BuilderConstruir un objeto complejo paso a pasoQueryBuilder de MikroORM, FormBuilder de Angular
SingletonUna única instancia compartidaProviders de Nest, providedIn: 'root' (no lo hagas a mano)
AdapterEncajar una interfaz ajena en la propiaAdaptadores de puertos, HttpClient envolviendo fetch
DecoratorAñadir responsabilidades sin herenciaInterceptores de Nest, HttpInterceptor de Angular
FacadeSimplificar un subsistemaServicios fachada de Angular, @nestjs/config
ProxyControlar el acceso a un objeto caroRef<T> y Collection perezosas de MikroORM
CompositeUniformar hoja y compuestoÁrbol de componentes y de inyectores de Angular
StrategyIntercambiar algoritmosEstrategias de Passport, validadores, canales de notificación
ObserverNotificar a N interesadosRxJS, EventEmitter2, hooks de MikroORM
Chain of ResponsibilityProcesar en etapas con corte anticipadoMiddleware, guards, interceptores y pipes de Nest
Template MethodEsqueleto fijo con pasos variablesClases base de procesadores de cola e importadores
CommandReificar una intención@nestjs/cqrs, trabajos de BullMQ
StateComportamiento dependiente del estadoMáquinas de estado de dominio, Router de Angular
MediatorDesacoplar emisores de receptoresCommandBus y EventBus

20.10 Patrones de arquitectura de datos

PatrónEn qué consisteEn este stack
Data MapperUn mapeador traduce entre objetos y filas; el objeto no sabe que se persisteEs el modelo de MikroORM (y de Doctrine e Hibernate)
Active RecordLa propia entidad sabe guardarse: task.save()TypeORM lo ofrece; MikroORM tiene una API similar opcional. Cómodo al principio, acopla dominio y persistencia para siempre
RepositoryColección de agregados con lenguaje de dominioPuerto + adaptador (sección 20.5)
Unit of WorkAcumula cambios y los escribe en una sola transacciónEl EntityManager lo implementa: flush() calcula el diff y ordena los INSERT/UPDATE/DELETE
DAOObjeto de acceso a datos orientado a la tabla, no al agregadoÚtil para consultas de lectura e informes; no confundir con un repositorio
SpecificationCriterios componibles y reutilizablesObjetos que producen FilterQuery<T> (sección 20.7)
CQRSSeparar el modelo de escritura del de lecturaAgregados para escribir; proyecciones planas con QueryBuilder para leer
Event SourcingEl estado es la suma de los eventos, que son la fuente de verdadRara vez justificado; ver el aviso más abajo
OutboxPublicar mensajes con la misma transacción que escribe los datosTabla outbox + trabajador que la vacía
¿Hace falta un repositorio si MikroORM ya es un Data Mapper? Es un debate legítimo y no hay una única respuesta correcta. A favor de no ponerlo: el EntityManager ya desacopla la entidad de la base de datos, y un repositorio que solo delega es una capa vacía. A favor de ponerlo: no está ahí para abstraer la base de datos, sino para nombrar las consultas del negocio (pendingOfProject en vez de un objeto de filtro repetido en cinco casos de uso) y para poder testear sin base de datos. Criterio práctico: pon repositorio en los módulos con lógica de dominio; usa el EntityManager directamente en los CRUD y en las consultas de lectura. Lo que no tiene defensa es el repositorio genérico IRepository<T> con findAll/findOne/save/delete: no aporta vocabulario y filtra los detalles del ORM igualmente.

20.10.1 CQRS con y sin bases de datos separadas

CQRS solo dice esto: el modelo con el que escribes no tiene por qué ser el modelo con el que lees. La versión ligera —la que deberías usar— vive en la misma base de datos y en el mismo módulo: para escribir cargas el agregado y ejecutas sus métodos; para leer haces una consulta que devuelve exactamente el DTO que necesita la pantalla, sin hidratar entidades.

application/queries/task-board.query.ts · lado de lectura
@Injectable()
export class TaskBoardQuery {
  constructor(private readonly em: EntityManager) {}
  /** Sin entidades, sin agregados: la forma exacta que pinta la pantalla. */
  async execute(projectId: string): Promise<TaskBoardRow[]> {
    return this.em.getConnection().execute<TaskBoardRow[]>(
      `SELECT t.id, t.title, t.status, u.name AS assignee, COUNT(c.id) AS comments
         FROM task t
         LEFT JOIN "user" u ON u.id = t.assignee_id
         LEFT JOIN comment c ON c.task_id = t.id
        WHERE t.project_id = ? AND t.archived_at IS NULL
        GROUP BY t.id, u.name
        ORDER BY t.due_date ASC NULLS LAST`, [projectId]);
  }
}

La versión pesada (base de datos de lectura separada, alimentada por eventos) multiplica la complejidad operativa e introduce consistencia eventual visible para el usuario: «he guardado y no aparece». Solo se justifica con volúmenes de lectura que no caben en la base de escritura, y llega mucho después de haber agotado índices, caché y réplicas de solo lectura.

Event Sourcing: qué cuesta de verdad Guardar todos los eventos y reconstruir el estado a partir de ellos da auditoría perfecta, capacidad de «viajar en el tiempo» y proyecciones a medida. A cambio: no puedes cambiar el esquema de un evento ya escrito (hace falta versionado y upcasting), necesitas snapshots para agregados con miles de eventos, las consultas ad hoc dejan de ser triviales y todo el equipo tiene que entenderlo. Si necesitas auditoría —que suele ser la razón real por la que alguien lo propone—, una tabla de auditoría o las extensiones temporales de PostgreSQL cuestan mil veces menos.

20.10.2 Outbox transaccional

  ┌──────────────────── UNA SOLA TRANSACCIÓN ────────────────────┐
  │  UPDATE task SET status='done' ...                           │
  │  INSERT INTO outbox (id, type, payload, published_at) ...    │
  └───────────────────────────┬──────────────────────────────────┘
                              │ commit atómico: o las dos, o ninguna
                              ▼
        ┌───────────────────────────────────────────┐
        │  Trabajador (cron / BullMQ) cada N ms:    │
        │  SELECT ... WHERE published_at IS NULL    │
        │  FOR UPDATE SKIP LOCKED  LIMIT 100        │──► broker / correo / webhook
        │  → publica → UPDATE published_at = now()  │
        └───────────────────────────────────────────┘
   Garantía: "al menos una vez". El consumidor DEBE ser idempotente.

El problema que resuelve es el de la doble escritura: no existe forma de escribir en la base de datos y publicar en un broker de forma atómica. Si publicas antes del commit, puedes anunciar algo que no ocurrió; si publicas después, un fallo del proceso pierde el mensaje. El Outbox convierte las dos escrituras en una sola transacción local y delega la entrega en un proceso aparte.

20.11 Antipatrones

Un antipatrón no es simplemente «código malo»: es una solución que parece razonable, se repite mucho y empeora las cosas. Reconocerlos por su nombre ayuda a discutirlos sin que la conversación suene a ataque personal.

AntipatrónSíntomaEjemplo típicoRemedio
Modelo de dominio anémicoEntidades con solo getters y setters; toda la lógica en serviciosclass Task { title; status; } + TasksService de 800 líneasMover al agregado las reglas que dependen de su estado (ver debate abajo)
God object / servicio diosUna clase que lo sabe y lo hace todoAppService, CoreService, SharedServiceDividir por actor y por caso de uso; aplicar SRP
Controlador gordoConsultas, reglas y efectos dentro del @ControllerEl ejemplo incorrecto de la sección 20.4Extraer caso de uso; el controlador solo traduce protocolo
Big ball of mudNo hay fronteras: todo importa a todo, ciclos por doquierCualquier proyecto de tres años sin regla de dependencias en CITrazar módulos, prohibir ciclos con dependency-cruiser, extraer poco a poco
Singleton global mutableEstado compartido fuera del inyectorexport const cache = new Map() a nivel de móduloProvider con ámbito controlado; si es por petición, AsyncLocalStorage
Primitive obsessionTodo son string y numbertransfer(from: string, to: string, amount: number)Value objects y tipos marcados (branded types)
Feature envyUn método usa más datos de otro objeto que del suyoif (task.status === 'done' && task.dueDate < now) fuera de TaskMover el método al objeto dueño de los datos
Shotgun surgeryUn cambio pequeño obliga a tocar muchos archivosAñadir un estado de tarea implica editar ocho switchCentralizar la decisión: polimorfismo o tabla de transiciones
Sobre-abstracción prematuraInterfaces y capas «por si acaso», con una sola implementaciónIEmailServiceFactoryProviderYAGNI: la abstracción se extrae cuando aparece el segundo caso
Arquitectura de currículumSe elige la tecnología por lo que luce, no por el problemaKafka, microservicios y event sourcing para 200 usuarios internosADR con criterios explícitos (20.15) y revisión por pares
El debate honesto sobre el modelo anémico Fowler lo llamó antipatrón en 2003 y tiene razón en dominios con reglas: si toda la lógica vive en servicios, las entidades son estructuras de datos y no hay forma de garantizar invariantes. Pero hay que reconocer por qué es tan común en TypeScript: los DTO entran y salen como objetos planos, el ORM hidrata objetos sin pasar por el constructor, la serialización a JSON favorece los objetos tontos y buena parte de las aplicaciones son, sinceramente, CRUD con validación. Cuándo es aceptable: módulos sin invariantes reales (catálogos, tablas maestras, configuración), integraciones y proyecciones de lectura. Cuándo no lo es: en cuanto una regla se repite en dos servicios distintos, o en cuanto el estado admite transiciones ilegales. Ahí, la lógica pertenece al agregado.
tasks.service.tsINCORRECTO · feature envy + anemia
// La entidad es una bolsa de datos...
@Entity()
export class Task {
  @PrimaryKey() id!: string;
  @Property() status!: TaskStatus;
  @Property({ nullable: true }) dueDate: Date | null = null;
}

// ...y esta regla aparece, con variaciones, en cuatro archivos:
@Injectable()
export class TasksService {
  isOverdue(task: Task): boolean {
    return task.status !== TaskStatus.Done
        && task.dueDate !== null
        && task.dueDate < new Date();
  }
}
// reports.service.ts:    t.dueDate && t.dueDate < new Date()   (olvida el estado)
// tasks.controller.ts:   t.dueDate! < today                     (olvida el nulo)
// task-row.component.ts: task.dueDate < Date.now()              (compara mal los tipos)
domain/task.entity.tsCORRECTO
@Entity()
export class Task {
  @PrimaryKey() id!: string;
  @Enum(() => TaskStatus) private _status: TaskStatus = TaskStatus.Pending;
  @Property({ nullable: true }) private _dueDate: Date | null = null;

  get status(): TaskStatus { return this._status; }
  get dueDate(): Date | null { return this._dueDate; }

  /** La regla vive donde viven los datos. Una vez. */
  isOverdue(now: Date): boolean {
    return this._status !== TaskStatus.Done
        && this._dueDate !== null
        && this._dueDate < now;
  }

  reschedule(newDate: Date, now: Date): void {
    if (this._status === TaskStatus.Done) throw new TaskAlreadyCompleted(this.id);
    if (newDate < now) throw new DueDateInThePast(newDate);
    this._dueDate = newDate;
  }
}
// El servicio, el informe y el componente llaman a task.isOverdue(now).
// Cambiar la definición de "vencida" es cambiar UNA línea.

20.12 Clean Code aplicado

Clean Code (Robert C. Martin, 2008) tiene partes discutibles —el fanatismo por las funciones de tres líneas o la prohibición casi total de comentarios— pero su tesis central es incontestable: el código se lee muchas más veces de las que se escribe, y optimizarlo para la lectura es la mejor inversión disponible.

20.12.1 Nombres y funciones

20.12.2 Comentarios

Comentarios que valen

  • El porqué: «usamos bloqueo pesimista porque el optimista provocaba reintentos en cascada en el cierre de mes».
  • Invariantes y contratos: «precondición: la tarea pertenece al proyecto del actor».
  • Decisiones y enlaces: referencia al ADR o al ticket que explica una rareza.
  • Avisos: «no cambiar el orden: la migración 0042 depende de él».
  • TSDoc en las APIs públicas de una librería compartida.

Comentarios que estorban

  • Narrar el código: // incrementa el contador sobre count++.
  • Código comentado. Para eso está Git; ahí solo genera dudas sobre si hace falta.
  • Cabeceras rituales con autor y fecha: el control de versiones lo sabe mejor y no miente.
  • Comentarios que mienten: los que describen algo que cambió hace dos años. Uno desactualizado es peor que ninguno.
  • Separadores decorativos de sesenta guiones para dividir una clase que debería ser dos.

20.12.3 Manejo de errores, principios y formato

reports.service.tsINCORRECTO
@Injectable()
export class ReportsService {
  async generate(projectId: string, type: string, send: boolean, format: number) {
    let r: any = {};
    const p = await this.em.findOne(Project, { id: projectId }, { populate: ['tasks', 'owner'] });
    if (p) {
      if (type == 'monthly') {
        let s = 0;
        for (let i = 0; i < p.tasks.length; i++) {
          if (p.tasks[i].status == 'done'
              && p.tasks[i].completedAt!.getMonth() == new Date().getMonth()) {
            s = s + p.tasks[i].hours * 45;   // 45 = tarifa (¿de dónde sale?)
          }
        }
        r.total = s;
        r.title = 'Informe mensual de ' + p.name;
      } else if (type == 'yearly') { /* casi lo mismo copiado */ }
      if (format == 1) r.body = JSON.stringify(r);
      else if (format == 2) r.body = await this.pdf.render('report', r);
      if (send) await this.mailer.send(p.owner.email, r.title, r.body);
    }
    return r;
  }
}
application/generate-report.use-case.tsCORRECTO
/** Un nivel de abstracción: se lee como el enunciado del caso de uso. */
@Injectable()
export class GenerateReportUseCase {
  constructor(
    @Inject(PROJECT_REPOSITORY) private readonly projects: ProjectRepository,
    @Inject(REPORT_PERIODS) private readonly periods: Map<ReportPeriod, PeriodPolicy>,
    @Inject(REPORT_RENDERERS) private readonly renderers: Map<ReportFormat, ReportRenderer>,
    private readonly clock: ClockPort,
  ) {}

  async execute(cmd: GenerateReportCommand): Promise<RenderedReport> {
    const project = await this.projects.byId(cmd.projectId);
    if (!project) throw new ProjectNotFound(cmd.projectId);       // guarda temprana

    const policy = this.periods.get(cmd.period) ?? raise(new UnknownPeriod(cmd.period));
    const report = project.billableReport(policy.rangeAt(this.clock.now()));
    return this.renderers.get(cmd.format)!.render(report);        // Strategy por formato
  }
}
// El envío por correo ya no está aquí: lo dispara un evento ReportGenerated.
// La tarifa es un value object del proyecto, no un 45 suelto.
// Las banderas booleanas y el "format: number" han desaparecido del contrato.

20.13 Refactorización

Refactorizar es cambiar la estructura interna del código sin alterar su comportamiento observable (Fowler, 1999). Lo que no es: reescribir desde cero, «limpiar mientras arreglo un bug», ni cambiar la funcionalidad «de paso». Si el comportamiento cambia, no estás refactorizando: estás desarrollando, y el riesgo es otro. Mantenerlos separados —en commits distintos— es lo que permite revisar y revertir con seguridad. La regla del campamento resume el cuándo: deja el código un poco mejor de como lo encontraste; no una reforma integral, sino un nombre mejor, una función extraída, un test que faltaba. El mejor momento es justo antes de añadir una funcionalidad al código que la va a recibir, y justo después de hacerla funcionar. El peor: a dos días de una entrega, en código que va a ser eliminado, o sin tests que respalden el comportamiento actual.

RefactorizaciónCuándoEjemplo en este stack
Extraer métodoUn bloque necesita un comentario para entenderseSacar el cálculo de vencimiento a isOverdue(now)
Extraer claseUn grupo de campos y métodos tiene vida propiaSacar el envío de correos del servicio de tareas
Introducir objeto parámetroMás de tres argumentos, o argumentos que viajan juntosexecute(cmd: CompleteTaskCommand)
Reemplazar condicional por polimorfismoUn switch sobre un tipoCanales de notificación como estrategias (20.8.2)
Reemplazar primitivo por objetoUn string o number con reglas propiasMoney, Email, TaskId
Mover métodoEl método usa más datos de otra clase que de la suyaLlevar la regla del servicio a la entidad
Condicional anidado por guardasEscaleras de ifSalir pronto en los casos de error
Cómo refactorizar código heredado sin tests El orden importa y es contraintuitivo, porque no puedes testear lo que no puedes instanciar. (1) Escribe tests de caracterización: no comprueban lo que el código debería hacer, sino lo que hace hoy, incluidos los comportamientos raros; ejecuta, copia la salida real y fíjala como expectativa. (2) Encuentra una costura: un punto donde puedas sustituir una dependencia sin cambiar la lógica (extraer un método y sobrescribirlo en una subclase de test, o convertir un new interno en un parámetro del constructor). (3) Refactoriza en pasos diminutos, ejecutando los tests después de cada uno. (4) Solo entonces cambia el comportamiento, y hazlo modificando el test primero. Si un paso te obliga a tocar más de un archivo a la vez, el paso era demasiado grande.

20.14 La testabilidad como criterio de diseño

Hay una heurística que vale por medio capítulo: si cuesta escribir el test, el diseño está mal. El test es el primer cliente del código y, por tanto, el primer indicador honesto de su acoplamiento.

Dificultad al testearLo que revelaArreglo de diseño
Necesito la base de datos para probar una reglaLa regla vive en la capa equivocadaMover al dominio; puerto para el acceso a datos
El test falla los martes o después de medianocheDependencia oculta del reloj o de la zona horariaInyectar ClockPort
Necesito seis mocks para instanciar la claseDemasiadas responsabilidades (SRP)Dividir por actor; usar un fake en lugar de mocks
El test comprueba «se llamó a save una vez»Verificas la implementación, no el comportamientoRepositorio en memoria y asertar sobre el estado resultante
Tengo que espiar un método privadoEse método quiere ser una clase aparteExtraer clase y testearla por su interfaz pública
Renombro un método y se rompen 40 testsLos tests están acoplados a los detallesTestear por la frontera del módulo, no clase a clase

De ahí las tres palancas de diseño que más testabilidad aportan: inyección de dependencias (nada de new de colaboradores dentro de la clase ni de importaciones de singletons), funciones puras para el cálculo (misma entrada, misma salida, sin efectos: se testean con una línea) y límites explícitos con puertos en todo lo que sea E/S. Y el corolario incómodo: el exceso de mocks es un olor de diseño, no una técnica avanzada. Un test lleno de jest.fn() queda verde aunque el sistema real no funcione, porque solo verifica que tu código llama a tus suposiciones. Prefiere fakes con comportamiento real (el repositorio en memoria de 20.5) y reserva los mocks para las fronteras que de verdad no puedes ejecutar.

20.15 Documentar decisiones: los ADR

Un Architecture Decision Record (Michael Nygard, 2011) es un documento corto, numerado e inmutable que registra una decisión, su contexto y sus consecuencias. Se guarda en el repositorio (docs/adr/0007-orm.md) y se revisa como código. Su valor no es burocrático: evita que dentro de dos años alguien deshaga una decisión sin conocer el motivo, y evita la discusión circular en cada incorporación al equipo. Una decisión no se edita: si cambia, se escribe otro ADR que la sustituye.

docs/adr/0007-orm.md · plantilla y ejemplo real
# ADR 0007 · Usar MikroORM como capa de persistencia

## Estado
Aceptada (2026-02-14). Sustituye a la ADR 0003.

## Contexto
- Dominio con invariantes que queremos expresar en entidades ricas.
- Necesitamos transacciones explícitas y control fino del SQL en los informes.
- Equipo de 6 personas, tres con experiencia previa en Doctrine/Hibernate.
- PostgreSQL 16 ya decidido (ADR 0004).

## Decisión
Adoptamos MikroORM 6 con el patrón Data Mapper y su Unit of Work.

## Alternativas consideradas
- **Prisma**: excelente experiencia de desarrollo y tipado del cliente, pero su modelo es
  Active-Record-like sobre objetos planos: no hay entidades con comportamiento ni Identity Map,
  y el esquema vive en un DSL propio fuera de TypeScript.
- **TypeORM**: adopción amplia, pero Data Mapper y Active Record mezclados e historial de
  inconsistencias en el motor de migraciones.
- **SQL a mano (Kysely)**: máximo control, pero perdemos Unit of Work e Identity Map, que son
  justo lo que sostiene el diseño por agregados.

## Consecuencias
+ Entidades ricas, cambio detectado automáticamente, una transacción por caso de uso.
+ Los tests de dominio no necesitan base de datos (repositorio en memoria).
- Curva de aprendizaje: hay que entender el Identity Map y cuándo hace flush.
- Menos ejemplos en la comunidad que Prisma; documentaremos los patrones internos.
- Riesgo asumido: si el proyecto derivase a lecturas masivas, lo revisaríamos con una ADR nueva.

Los criterios de elección tecnológica, en este orden: encaje con el problema real, madurez y mantenimiento activo, conocimiento del equipo, coste de salida si te equivocas, calidad de la documentación y tamaño de la comunidad. El rendimiento bruto suele estar más abajo de lo que la gente cree, porque casi nunca es el cuello de botella. Y ojo al sesgo de la novedad: la tecnología recién salida tiene un atractivo desproporcionado porque los artículos hablan de sus ventajas y todavía nadie ha escrito sobre sus problemas a los dos años. El antídoto es exigir que el ADR incluya al menos tres inconvenientes concretos de la opción elegida y una vía de salida; si nadie sabe enumerar sus desventajas, es que aún no la conocéis lo suficiente para adoptarla.

20.16 Monolito modular frente a microservicios

Es la decisión más cara de revertir de todo el capítulo y, por tanto, la más arquitectónica. La ley de Conway lo explica: una organización que diseña un sistema producirá un diseño que copia su estructura de comunicación. Si tienes un equipo de ocho personas, tendrás un sistema con las fronteras de un equipo de ocho personas, aunque lo despliegues en veinte contenedores. La «maniobra de Conway inversa» consiste en organizar los equipos como quieres que sea el sistema, no al revés.

  MONOLITO MODULAR                          MICROSERVICIOS
  ┌────────────────────────────────┐        ┌─────────┐  ┌─────────┐  ┌─────────┐
  │ ┌────────┐ ┌────────┐ ┌──────┐ │        │ tasks   │  │ billing │  │ notify  │
  │ │ tasks  │ │billing │ │notify│ │        │ +  DB   │  │ +  DB   │  │ +  DB   │
  │ └────────┘ └────────┘ └──────┘ │        └────┬────┘  └────┬────┘  └────┬────┘
  │   llamadas en proceso, tipadas │             └───red──────┴────red─────┘
  │   1 transacción, 1 despliegue  │        contratos versionados, reintentos,
  │   1 base de datos              │        idempotencia, sagas, trazas
  └────────────────────────────────┘
  Refactor entre módulos: minutos          Refactor entre servicios: semanas
  Depuración: un stack trace               Depuración: correlación entre 5 logs
Coste real de los microserviciosQué implica en la práctica
OperaciónN pipelines, N despliegues, N configuraciones de secretos, N cuadros de mando, orquestador
LatenciaLo que era una llamada de función pasa a ser una petición de red que puede fallar, tardar o duplicarse
ConsistenciaSe acabaron las transacciones ACID entre módulos: sagas, compensaciones e idempotencia obligatoria
DepuraciónUn fallo requiere trazas distribuidas y correlación de identificadores entre servicios
ContratosCada cambio de API necesita versionado y compatibilidad hacia atrás durante el despliegue
DatosNada de JOIN entre servicios: duplicación de datos y consistencia eventual
El camino recomendado Empieza con un monolito modular: un despliegue, una base de datos, pero módulos con fronteras reales (un módulo de Nest por contexto delimitado, sin importaciones cruzadas salvo por su API pública, regla verificada en CI). Ese diseño da el 80 % del beneficio (equipos que no se pisan, código comprensible) sin ninguno de los costes distribuidos. Y lo más importante: si las fronteras son buenas, extraer un servicio después es trabajo de días; si no lo son, ninguna arquitectura distribuida te salvará, solo convertirá llamadas a métodos en llamadas de red que fallan.

Señales objetivas de que ha llegado el momento de dividir (hacen falta varias, no una): un módulo tiene necesidades de escalado radicalmente distintas al resto (procesado de vídeo frente a un CRUD); dos equipos se bloquean sistemáticamente en el mismo despliegue; una parte del sistema exige un ciclo de publicación o un cumplimiento normativo diferente; el tiempo de arranque o de CI se ha vuelto insoportable y ya has agotado las opciones sencillas. Y una señal que no lo es: «el monolito está hecho un lío». Un lío repartido en la red sigue siendo un lío, pero ahora con latencia.

20.17 Errores comunes y cómo solucionarlos

ErrorCausaSolución
Capas que se saltan: el controlador consulta el EntityManagerPrisa, o falta de un caso de uso donde poner la lógicaProhibir el import en CI; crear el caso de uso aunque solo delegue
El dominio depende del ORM (QueryBuilder en una entidad)Se confunde inyección con inversión de dependenciasPuerto declarado por el dominio, adaptador en infraestructura
El DTO acaba convertido en la entidadObject.assign(entity, dto) por comodidadConstructor o fábrica con validación; mapeo explícito campo a campo
Abstracción con una sola implementación «por si acaso»Culto al cargo arquitectónicoYAGNI: clase concreta y extracción de la interfaz cuando aparezca el segundo caso
Repositorio genérico que filtra detalles del ORMIRepository<T> copiado de un tutorialUn repositorio por agregado, con métodos con nombre de negocio
Interfaces de un solo método usadas en todas partesISP mal entendidoAgrupar por capacidad del consumidor; a veces un tipo de función basta
Dependencias circulares entre módulosDos contextos que se llaman mutuamenteExtraer lo común, invertir con un evento, o admitir que son un solo contexto
Entidades devueltas tal cual por la APIFalta de DTO de salidaDTO de respuesta explícito; evita filtrar campos y relaciones perezosas

20.18 Buenas y malas prácticas

Haz esto

  • Una dirección de dependencia, verificada automáticamente en CI. Es la única regla innegociable.
  • Organiza por funcionalidad, no por tipo técnico: tasks/, no services/.
  • Pon las reglas donde están los datos: entidades con comportamiento, servicios que orquestan.
  • Un repositorio por agregado, con métodos que hablen el idioma del negocio.
  • Inyecta el reloj, el azar y los identificadores: son dependencias, aunque no lo parezcan.
  • Errores de dominio en el dominio y un filtro que los traduzca a HTTP en la frontera.
  • Escribe un ADR para cada decisión que sea cara de revertir.
  • Refactoriza en pasos pequeños y en commits separados de los cambios funcionales.
  • Elige la abstracción al tercer caso, no al primero.

Evita esto

  • Copiar una arquitectura de una charla sin entender qué problema resolvía allí.
  • Un puerto por cada clase: la indirección sin motivo se paga en cada lectura del código.
  • Servicios «gestores» (Manager, Helper, Utils): el nombre delata que nadie sabe de qué son responsables.
  • Transacciones que abarcan varios agregados por costumbre.
  • Publicar eventos antes del commit.
  • Deduplicar código que solo se parece: acabarás con parámetros booleanos.
  • Tests que verifican llamadas en lugar de comportamiento.
  • Microservicios para arreglar un problema de diseño o de organización.
  • Reescribir desde cero lo que se podía refactorizar por partes.

20.19 Preguntas frecuentes

¿Merece la pena la arquitectura hexagonal en un proyecto pequeño?
Casi nunca en su versión completa. Lo que sí merece la pena siempre es la parte barata: separar presentación de lógica, no meter reglas en el controlador y aislar las dependencias externas lentas o inestables. Los puertos y adaptadores completos se justifican cuando hay varias implementaciones reales, varios equipos o una vida esperada de años. Extraer una interfaz más adelante cuesta minutos; deshacer veinte abstracciones inútiles cuesta semanas.
Si MikroORM ya implementa Data Mapper y Unit of Work, ¿para qué un repositorio?
No para abstraer la base de datos, sino para dar nombre a las consultas del negocio y poder testear casos de uso sin base de datos. tasks.pendingOfProject(id) es un concepto del dominio; em.find(Task, { project: id, status: 'pending', archivedAt: null }) es un detalle que, repetido en cinco sitios, se desincroniza. Dicho esto, en módulos CRUD sin reglas usar el EntityManager directamente es defendible.
¿Puedo tener decoradores de MikroORM en mis entidades de dominio?
En la práctica sí, y es lo que recomendamos para el 90 % de los proyectos. La línea que no debes cruzar es otra: que el dominio importe EntityManager, QueryBuilder o lance consultas. Separar entidad de dominio y de persistencia en dos clases con un mapeador es correcto en la teoría, pero duplica el modelo, obliga a mantener el mapeo y hace perder el change tracking. Hazlo solo si los dos modelos divergen de verdad.
Pregunta de entrevista: explica SOLID en una frase por principio.
SRP: un módulo, un actor que puede pedir que cambie. OCP: añadir comportamiento añadiendo código, no editando el que ya funciona. LSP: una subclase no puede exigir más ni prometer menos que su base. ISP: ningún cliente debe depender de métodos que no usa. DIP: ambos lados dependen de una abstracción, y la abstracción pertenece a quien la consume. Si preguntan cuál es el más importante, la respuesta razonada es DIP: es el que hace posible el resto.
¿Cuál es la diferencia entre inyección de dependencias e inversión de dependencias?
La inyección es un mecanismo: alguien te pasa los colaboradores en vez de que tú los crees. La inversión es una decisión de diseño: la dirección de la dependencia de código apunta hacia el consumidor. Puedes inyectar sin invertir (inyectar el EntityManager en un servicio de dominio). Nest te da el mecanismo; la inversión la decides tú al elegir qué tipo pones en el constructor.
¿Strategy o un simple objeto de funciones?
Si las estrategias no tienen dependencias ni estado, un Record<Kind, (x: T) => R> es más simple y más que suficiente. Las clases con interfaz se justifican cuando cada estrategia necesita sus propias dependencias inyectadas (un cliente HTTP, un repositorio) o cuando quieres que Nest las descubra por token. Elegir clases «porque es el patrón» es ceremonia.
¿Es el modelo anémico siempre un error?
No. Lo es cuando hay invariantes y transiciones de estado, porque nada garantiza que se respeten. En módulos que son literalmente CRUD (catálogos, tablas maestras), en proyecciones de lectura y en integraciones, unas estructuras de datos planas más un servicio son la solución correcta y más simple. Aplicar DDD táctico a un mantenimiento de países es el equivalente a montar un andamio para cambiar una bombilla.
¿Cómo evito que el «shared» se convierta en un vertedero?
Con tres reglas. Primera: en shared/ solo entra código sin lógica de negocio (tipos base, utilidades puras, Result, errores base). Segunda: si algo lo usa un único módulo, no es compartido, vive en ese módulo. Tercera: prohíbe que shared/ importe de cualquier módulo de negocio; esa regla en CI mata el problema de raíz, porque el vertedero siempre empieza por una importación «temporal» al revés.
¿Los eventos de dominio deben ser síncronos o asíncronos?
Los eventos de dominio (dentro del mismo contexto) suelen manejarse en proceso, tras el commit: son simples y depurables. Los eventos de integración (que cruzan contextos o servicios) deben ir por un broker y necesitan Outbox si no pueden perderse. Regla práctica: si el manejador debe hacer fallar la operación original cuando falla, no es un evento, es parte del caso de uso y va en la misma transacción.
Pregunta de entrevista: ¿qué patrones de diseño usa Angular?
Observer (RxJS y el sistema de eventos), inyección de dependencias con inyectores jerárquicos (que además es Composite), Decorator (@Component, interceptores HTTP), Strategy (ChangeDetectionStrategy, validadores, estrategias de precarga del router), Facade (servicios de estado por pantalla), Composite (árbol de componentes), Template Method (los ganchos del ciclo de vida) y Adapter (HttpClient sobre XMLHttpRequest o fetch). Nombrarlos está bien; explicar qué problema resuelve cada uno ahí es lo que se valora.
¿Cómo justifico ante negocio el tiempo dedicado a refactorizar?
No hables de «código limpio», habla de coste y riesgo: cuánto tarda hoy una funcionalidad típica, cuántos fallos vuelven de producción, cuánto tarda la incorporación de una persona nueva. Presenta la deuda como lo que es: una decisión de negocio con intereses. Y trocea el trabajo: las refactorizaciones pequeñas dentro de las tareas normales se aprueban solas; un «sprint de refactor» de tres semanas no se aprueba nunca y además concentra todo el riesgo.
¿Cuántas capas debería tener mi proyecto?
Las mínimas que respeten la regla de dependencia. Para la mayoría de aplicaciones Nest, tres bastan: presentación, aplicación (con el dominio dentro si es pequeño) e infraestructura. Añade la separación explícita de dominio cuando aparezcan invariantes que merezcan una clase propia. Cada capa adicional tiene un coste fijo por cada cambio que la atraviesa: si una capa solo reenvía llamadas y no toma ninguna decisión, sobra.
¿Debería usar @nestjs/cqrs desde el principio?
No por defecto. El paquete aporta buses de comandos, consultas y eventos, útiles en dominios grandes o cuando quieres registrar y reintentar operaciones de forma uniforme. En un proyecto medio añade indirección (un objeto y un manejador por operación) sin resolver ningún problema que tuvieras. La parte valiosa de CQRS —separar el modelo de escritura del de lectura— no necesita ninguna librería: es una decisión de diseño que puedes aplicar hoy con servicios normales.
¿Qué hago si heredo un «big ball of mud» en producción?
Nada de reescritura total: es el fracaso más documentado del sector. El orden que funciona es: (1) instrumentar y medir para saber qué duele de verdad; (2) poner tests de caracterización en los flujos críticos; (3) congelar la degradación con reglas de dependencia en CI para que lo nuevo no empeore lo viejo; (4) aplicar el patrón strangler fig: cada funcionalidad nueva se escribe bien detrás de una frontera clara y va comiendo terreno a lo antiguo, módulo a módulo, con el sistema siempre funcionando.

20.20 Ejercicios

Nivel 1 · básico

20.1 Coge un controlador real de tu proyecto (o el ejemplo incorrecto de 20.4) y clasifica cada línea en una de las cuatro capas. ¿Cuántas responsabilidades hay mezcladas? Escribe la lista de actores que podrían pedir un cambio ahí.

20.2 Busca tres nombres que incumplan las reglas de 20.12.1 (abreviaturas, prefijos, booleanos negados, nombres genéricos) y renómbralos. Comprueba si algún comentario se ha vuelto innecesario después del renombrado.

20.3 Identifica en Angular, Nest y MikroORM un ejemplo real de Decorator, Strategy, Proxy y Chain of Responsibility. Explica en una frase qué problema resuelve cada uno en ese sitio concreto.

Nivel 2 · intermedio

20.4 Refactoriza este servicio, que viola SRP y DIP: valida, consulta con el EntityManager, aplica una regla de negocio, envía un correo y factura. Separa capas, extrae los puertos necesarios y deja el caso de uso en menos de quince líneas.

@Injectable()
export class SubscriptionsService {
  constructor(private em: EntityManager, private mailer: MailerService, private stripe: StripeService) {}
  async cancel(id: string, reason: string) {
    if (!reason || reason.length < 5) throw new BadRequestException('motivo requerido');
    const sub = await this.em.findOne(Subscription, { id }, { populate: ['user'] });
    if (!sub) throw new NotFoundException();
    if (sub.status === 'cancelled') throw new ConflictException('ya cancelada');
    if (sub.currentPeriodEnd < new Date()) sub.status = 'expired';
    else sub.status = 'cancelled';
    sub.cancelledAt = new Date();
    sub.cancelReason = reason;
    await this.em.flush();
    await this.stripe.subscriptions.del(sub.externalId);
    await this.mailer.send(sub.user.email, 'Suscripción cancelada', reason);
    return sub;
  }
}

20.5 Convierte este switch en estrategias registradas por token de Nest, de forma que añadir un método de envío nuevo no obligue a editar ninguna clase existente: calcularEnvio(tipo: 'estandar' | 'express' | 'recogida', peso: number, destino: string): number. Incluye un test que demuestre que se puede añadir una estrategia sin tocar el servicio.

20.6 Sustituye los primitivos de crearTarea(titulo: string, proyectoId: string, horas: number, tarifa: number) por value objects (TaskTitle, ProjectId, Hours, Money) e introduce un objeto parámetro. Comprueba qué errores empieza a detectar el compilador que antes pasaban desapercibidos.

20.7 Escribe la ADR que justifique una decisión real de tu proyecto (JWT frente a sesiones, monorepo frente a repositorios separados, elección de la librería de estado). Obligatorio: tres inconvenientes de la opción elegida y una vía de salida.

Nivel 3 · avanzado

20.8 Dado este dominio, diseña los agregados: un Pedido con líneas, un Cliente con su límite de crédito, un Almacén con existencias por producto y una Factura. Reglas: no se puede confirmar un pedido si supera el crédito disponible; al confirmar se reserva existencia; la factura se emite tras el envío y es inmutable. Indica raíces, límites de transacción, qué referencias van por identidad y qué se resuelve con eventos y consistencia eventual.

20.9 Implementa el patrón Outbox completo sobre MikroORM: entidad OutboxMessage, escritura en la misma transacción que el agregado y un procesador con FOR UPDATE SKIP LOCKED que garantice entrega «al menos una vez». Añade un test de integración que compruebe que un rollback no deja mensajes publicables.

20.10 Añade a la CI una regla de dependencias (dependency-cruiser o eslint-plugin-boundaries) que impida que el dominio de un módulo importe infraestructura y que prohíba los ciclos. Documenta cuántas violaciones existen hoy y planifica su eliminación.

20.11 Escoge una clase heredada sin tests, escribe tests de caracterización que fijen su comportamiento actual, localiza una costura para inyectar sus dependencias y refactorízala en pasos pequeños hasta poder testearla sin base de datos. Mide el tiempo de la suite antes y después.

20.12 Diseña la extracción de un módulo del monolito a un servicio independiente: qué contrato publicaría, qué datos duplicaría, qué operaciones dejarían de ser transaccionales y qué compensaciones harían falta. Estima el coste y decide, de forma razonada, si merece la pena.

Soluciones comentadas (20.4, 20.5 y 20.8)

20.4 · Refactorización del servicio. El diagnóstico: hay cuatro actores (validación de entrada, reglas de suscripción, pasarela de pago y notificaciones) y una dependencia directa del ORM y de dos SDK. La solución reparte así:

// 1. presentation/dto/cancel-subscription.dto.ts — la validación de FORMATO sale del servicio
export class CancelSubscriptionDto { @IsString() @MinLength(5) reason!: string; }

// 2. domain/subscription.entity.ts — la REGLA vive en el agregado
cancel(reason: CancellationReason, now: Date): void {
  if (this.status === SubscriptionStatus.Cancelled) throw new AlreadyCancelled(this.id);
  this.status = this.currentPeriodEnd < now ? SubscriptionStatus.Expired : SubscriptionStatus.Cancelled;
  this.cancelledAt = now;
  this.cancelReason = reason;
  this.events.push(new SubscriptionCancelled(this.id, this.userId, reason, now));
}

// 3. domain/ports.ts — puertos declarados por el dominio (DIP)
export interface SubscriptionRepository {
  byId(id: string): Promise<Subscription | null>; save(s: Subscription): Promise<void>;
}
export interface BillingGateway { cancelSubscription(externalId: string): Promise<void>; }

// 4. application/cancel-subscription.use-case.ts — solo orquesta
@Injectable()
export class CancelSubscriptionUseCase {
  constructor(
    @Inject(SUBSCRIPTION_REPOSITORY) private readonly subs: SubscriptionRepository,
    private readonly uow: UnitOfWorkPort,
    private readonly events: DomainEventPublisher,
    private readonly clock: ClockPort,
  ) {}
  async execute(cmd: CancelSubscriptionCommand): Promise<void> {
    const sub = await this.uow.transactional(async () => {
      const s = await this.subs.byId(cmd.subscriptionId);
      if (!s) throw new SubscriptionNotFound(cmd.subscriptionId);
      s.cancel(CancellationReason.of(cmd.reason), this.clock.now());
      await this.subs.save(s);
      return s;
    });
    this.events.publishAll(sub.pullEvents());   // billing y notificaciones escuchan
  }
}

Lo importante no es que haya más archivos, sino qué cambia cuando cambia algo: sustituir Stripe toca un adaptador; añadir un SMS al cancelar añade un manejador; cambiar la regla de expiración toca la entidad. Antes, las tres cosas tocaban el mismo método.

20.5 · Estrategias de envío. Define interface ShippingPolicy { readonly kind: ShippingKind; cost(w: Weight, dest: Address): Money }, una clase por política, un token SHIPPING_POLICIES con un useFactory que las reciba todas por inject, y un servicio que construya un Map por kind. El test que demuestra OCP no comprueba el cálculo: comprueba que al registrar una política nueva en un TestingModule, el servicio la resuelve sin que su código haya cambiado. Un matiz honesto: si fueran tres fórmulas sin dependencias, un objeto de funciones sería mejor solución que cinco clases.

20.8 · Diseño de agregados. Cuatro raíces: Order (con sus OrderLine dentro, porque el total y el estado de las líneas deben cuadrar siempre), Customer (que protege el invariante del crédito), StockItem por producto y almacén —no un agregado «Almacén» entero, que sería un punto de contención brutal— e Invoice. Las referencias entre ellos van por identidad: Order.customerId, Invoice.orderId. Transacciones: al confirmar el pedido se modifica solo Order, que emite OrderConfirmed; un manejador reserva existencias en los StockItem y otro actualiza el crédito consumido del cliente. Si la reserva falla, se emite StockReservationFailed y el pedido pasa a un estado de compensación: eso es una saga, y es el precio de no poder usar una única transacción. La comprobación del crédito antes de confirmar es una consulta de solo lectura, con una advertencia que hay que hacer explícita: entre la comprobación y la confirmación puede colarse otro pedido, así que el invariante duro debe reevaluarse en el manejador del cliente (o aceptarse un pequeño sobregiro, que en muchos negocios es exactamente lo que ocurre en el mundo real).

20.21 Resumen del capítulo

  • Arquitectura son las decisiones difíciles de cambiar. Su objetivo es mantener bajo el coste del cambio y satisfacer unos atributos de calidad que siempre compiten entre sí.
  • Alta cohesión, bajo acoplamiento. De ahí se derivan casi todos los demás principios; organizar por funcionalidad en lugar de por tipo técnico es la aplicación más rentable.
  • La regla de dependencia es lo único innegociable: el código apunta hacia dentro, hacia el dominio. Verifícala en CI o no existirá.
  • Hexagonal, onion y clean son la misma idea con distinto envoltorio: puertos declarados por el consumidor, adaptadores intercambiables y tests como otro adaptador más.
  • DDD táctico aporta un vocabulario preciso —entidad, value object, agregado, repositorio, evento— y una regla que ahorra muchos problemas: una transacción, un agregado.
  • SOLID son heurísticas, no leyes. Aplicadas por dogma producen ceremonia; la clave es conocer el síntoma que cada una remedia y el punto en el que dejan de compensar.
  • Los patrones ya están en tu stack. Reconocerlos en Angular, Nest y MikroORM rinde más que implementarlos desde cero.
  • Clean Code se resume en optimizar para la lectura: nombres honestos, funciones con un solo nivel de abstracción, errores que no se tragan y un DRY entendido como unicidad del conocimiento, no del texto.
  • Si cuesta testearlo, el diseño está mal. El test es el primer cliente y el detector de acoplamiento más fiable.
  • Documenta las decisiones caras con ADR y empieza por un monolito modular: extraer servicios después es fácil si las fronteras son buenas, e imposible si no lo son.

20.22 Recursos adicionales

Siguiente paso Con el diseño interno resuelto, queda llevarlo a producción de forma repetible: el capítulo 21 cierra la Parte VI con Docker, integración y despliegue continuos, migraciones en despliegue y escalado.