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.
20.1 Qué vas a poder hacer al terminar
- Explicar qué decisiones son «arquitectura» en un proyecto Angular + NestJS + MikroORM y cuáles son detalles de implementación que puedes cambiar el martes que viene.
- Medir el acoplamiento y la cohesión de un módulo con criterios objetivos, no por intuición.
- Repartir un caso de uso entre presentación, aplicación, dominio e infraestructura, y detectar de un vistazo cuándo una capa se ha saltado a otra.
- Implementar arquitectura hexagonal real con la inyección de dependencias de Nest: puerto como interfaz, token de inyección, adaptador de MikroORM y adaptador en memoria para los tests.
- Diseñar agregados con las reglas de DDD táctico y traducirlos a entidades de MikroORM sin que el ORM se cuele en el modelo de dominio.
- Aplicar los cinco principios SOLID en este stack, reconocer el síntoma de que se está violando cada uno y argumentar cuándo aplicarlos es contraproducente.
- Identificar los patrones de diseño que ya estás usando sin saberlo (interceptores, providers con
useFactory,QueryBuilder, referencias perezosas, RxJS) y aplicarlos de forma deliberada. - Refactorizar un servicio que viola SRP y DIP con pasos pequeños y seguros, respaldado por tests.
- Escribir un ADR que justifique una decisión técnica y defenderla frente al sesgo de la novedad.
- Argumentar con datos por qué un monolito modular es casi siempre la respuesta correcta antes que los microservicios, y reconocer las señales objetivas de que ha llegado el momento de dividir.
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.
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.
| Atributo | Pregunta que responde | Có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 |
| Observabilidad | Si 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.
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.
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
| Tipo | Qué significa | Ejemplo en este stack |
|---|---|---|
| De contenido (el peor) | Un módulo manipula los interiores de otro | Un servicio que toca (entidad as any).__helper o el estado privado de otro servicio |
| Común | Varios módulos comparten estado global mutable | Un objeto exportado a nivel de módulo con la configuración, que cualquiera muta |
| Externo | Dependencia compartida de un formato o protocolo impuesto fuera | Media aplicación conoce la forma exacta del JSON de un proveedor externo |
| De control | Un módulo pasa a otro una bandera que decide su flujo interno | crear(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 parte | Pasar la entidad User entera a un método que solo usa user.email |
| De datos | Se pasan exactamente los datos necesarios | enviarBienvenida(email: string, nombre: string) |
| De mensaje (el mejor) | Comunicación por eventos; el emisor no conoce al receptor | eventBus.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
- Coincidental: las cosas están juntas por casualidad. El archivo
utils.tsconformatearFecha,slugifyycalcularIVAes el ejemplo canónico. - Lógica: agrupadas por categoría técnica, no por propósito. Una carpeta
services/con los treinta servicios del sistema: todos son servicios y ninguno tiene nada que ver con el resto. - Temporal: se ejecutan en el mismo momento. Un
onModuleInitque abre la conexión, precalienta la caché y registra métricas. - Procedimental o comunicacional: comparten secuencia o comparten datos. Aceptable.
- Funcional (la mejor): todos los elementos contribuyen a una única tarea bien definida.
PriceCalculatorcalcula precios; nada más.
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:
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.
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
| Capa | Sí | No |
|---|---|---|
| Presentación | Rutas, códigos de estado, DTO de entrada y salida, validación de formato, serialización, documentación OpenAPI | Consultas, cálculos de negocio, decisiones sobre el estado del dominio, EntityManager |
| Aplicación | Un método por caso de uso, control de transacción, permisos, coordinación de varios agregados, eventos de integración | Reglas invariantes del negocio (van al dominio), SQL, detalles de HTTP |
| Dominio | Estado y comportamiento del negocio, invariantes, cálculos, políticas, eventos de dominio, interfaces de los puertos | Decoradores de Nest, importaciones de MikroORM, fetch, Date.now() directo |
| Infraestructura | Implementaciones concretas: MikroORM, Redis, S3, SMTP, Stripe, reloj real, generador de UUID | Reglas de negocio disfrazadas de «lógica del repositorio» |
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
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.
@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
}
}
@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;
});
}
}
@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.
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
- Puerto de entrada: el contrato de lo que la aplicación sabe hacer. En Nest suele ser directamente la clase del caso
de uso (
CompleteTaskUseCase). No siempre hace falta una interfaz aquí: la clase concreta ya es un contrato y añadir una interfaz por encima suele ser ceremonia. - Puerto de salida: el contrato de lo que la aplicación necesita del mundo. Aquí la interfaz sí es
imprescindible, porque es lo que invierte la dependencia. Y —detalle importante— lo define el núcleo con su
vocabulario, no el proveedor: se llama
NotificationSender, noSendGridClient.
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.
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');
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(); }
}
/** 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}`; }
}
@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 {}
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.
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áfora | Puertos y adaptadores | Capas concéntricas | Círculos con regla de dependencia |
| Aporta | La simetría entrada/salida y la idea del test como otro adaptador | Nombrar las capas del núcleo: modelo, servicios de dominio, servicios de aplicación | Vocabulario explícito (entidades, casos de uso, adaptadores de interfaz) y la regla del cruce de fronteras con DTOs |
| Riesgo | Proliferación de puertos triviales | Capas dentro de capas | Ceremonia: request/response models, presenters y mapeadores por todas partes |
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
| Concepto | Definición | Traducción a este stack |
|---|---|---|
| Entidad | Tiene identidad propia que persiste aunque cambien sus atributos | Clase con @Entity() y @PrimaryKey() |
| Value object | Se define por su valor, es inmutable y no tiene identidad | @Embeddable(), o un tipo con @Property({ type: CustomType }) |
| Agregado | Grupo de objetos que se trata como una unidad de consistencia | Un grafo de entidades con reglas de cascada y una única puerta de entrada |
| Raíz de agregado | La única entidad del agregado accesible desde fuera | La entidad que expone el repositorio |
| Repositorio | Colección de agregados con apariencia de colección en memoria | Puerto + adaptador MikroORM. Uno por raíz de agregado, no uno por tabla |
| Servicio de dominio | Lógica de negocio que no pertenece a ninguna entidad concreta | Clase sin estado en domain/, sin decoradores de Nest si puede evitarse |
| Servicio de aplicación | Orquesta un caso de uso; no contiene reglas | @Injectable() en application/, controla la transacción |
| Evento de dominio | Algo relevante que ha ocurrido, en pasado | Clase inmutable + EventEmitter2 o publicación tras el commit |
| Especificación | Regla de selección o validación reutilizable y componible | Objeto que produce un FilterQuery<T> de MikroORM |
| Fábrica | Encapsula la creación compleja de un agregado válido | Mé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.
20.7.3 Value object, fábrica y especificación en TypeScript
@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.
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;
}
}
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
@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 });
}
}
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
- Lenguaje ubicuo: un único vocabulario compartido por negocio y código. Si el equipo comercial dice «presupuesto», la
clase se llama
PresupuestooQuote, pero nuncaOrderDraftDataV2. Cuando el código y las reuniones usan palabras distintas, cada traducción es una oportunidad de malentendido. - Contexto delimitado (bounded context): la frontera dentro de la cual una palabra significa exactamente una
cosa. «Cliente» en Ventas (con historial de compras y descuentos) y «Cliente» en Soporte (con tickets y SLA) son dos modelos
distintos que casualmente comparten nombre. Intentar unificarlos en una clase
Customercon cuarenta campos es el origen del big ball of mud. En Nest, un contexto delimitado se corresponde con un módulo de primer nivel. - Mapa de contextos: cómo se relacionan. Los patrones útiles son shared kernel (código común acordado, úsalo poco), customer/supplier (uno se adapta al otro por acuerdo), conformist (te tragas el modelo ajeno tal cual) y sobre todo la capa anticorrupción: un traductor en tu frontera que convierte el modelo ajeno al tuyo para que su desorden no contamine tu dominio. La capa anticorrupción es, técnicamente, un adaptador (sección 20.9).
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.
@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.
// 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.
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.
@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}`);
}
}
}
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] }
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...».
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.
// 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".
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.
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.
// 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() };
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.
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.
// 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] }
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
- Factory Method / Abstract Factory. En Nest,
useFactoryes literalmente el patrón: una función que decide qué instancia crear según la configuración. Abstract Factory aparece cuando devuelve una familia coherente de objetos (por ejemplo, el par repositorio + unidad de trabajo del mismo motor). - Builder. El
QueryBuilderde MikroORM es un builder de manual: métodos encadenados que acumulan estado y un método terminal (getResult()) que construye el objeto final. Úsalo también para objetos de test complejos (object mother y test data builder). - Singleton. No lo implementes a mano. En Nest los providers son singleton por defecto dentro de su ámbito; en
Angular,
providedIn: 'root'hace lo mismo. Un singleton manual (variable de módulo congetInstance()) es un antipatrón con DI: no se puede sustituir en los tests, oculta la dependencia, crea estado global mutable y en Node persiste entre peticiones, con el riesgo de filtrar datos de un usuario a otro.
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 {}
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ón | Para qué | Dónde lo tienes ya |
|---|---|---|
| Adapter | Hacer que una interfaz ajena encaje con la que tú necesitas | Cada implementación de un puerto; la capa anticorrupción con una API de terceros |
| Decorator | Añadir comportamiento sin tocar el objeto original | Interceptores de Nest (caché, logging, transformación) y el decorator provider con useFactory que envuelve otro servicio |
| Facade | Una puerta simple a un subsistema complejo | Un servicio de Angular que agrupa varias llamadas HTTP y estado de señales para una pantalla |
| Proxy | Un sustituto que controla el acceso al objeto real | Las referencias perezosas de MikroORM (Ref<T>, Collection): parecen la entidad pero solo la cargan al acceder |
| Composite | Tratar igual a un objeto y a un grupo de objetos | El árbol de componentes y de inyectores de Angular; tareas con subtareas |
@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:
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ón | Problema que resuelve | Dónde aparece ya en tu stack |
|---|---|---|
| Factory Method | Elegir la implementación en tiempo de arranque | useFactory de Nest, APP_INITIALIZER en Angular |
| Abstract Factory | Crear familias coherentes de objetos | Factoría que devuelve driver + repositorio + unidad de trabajo |
| Builder | Construir un objeto complejo paso a paso | QueryBuilder de MikroORM, FormBuilder de Angular |
| Singleton | Una única instancia compartida | Providers de Nest, providedIn: 'root' (no lo hagas a mano) |
| Adapter | Encajar una interfaz ajena en la propia | Adaptadores de puertos, HttpClient envolviendo fetch |
| Decorator | Añadir responsabilidades sin herencia | Interceptores de Nest, HttpInterceptor de Angular |
| Facade | Simplificar un subsistema | Servicios fachada de Angular, @nestjs/config |
| Proxy | Controlar el acceso a un objeto caro | Ref<T> y Collection perezosas de MikroORM |
| Composite | Uniformar hoja y compuesto | Árbol de componentes y de inyectores de Angular |
| Strategy | Intercambiar algoritmos | Estrategias de Passport, validadores, canales de notificación |
| Observer | Notificar a N interesados | RxJS, EventEmitter2, hooks de MikroORM |
| Chain of Responsibility | Procesar en etapas con corte anticipado | Middleware, guards, interceptores y pipes de Nest |
| Template Method | Esqueleto fijo con pasos variables | Clases base de procesadores de cola e importadores |
| Command | Reificar una intención | @nestjs/cqrs, trabajos de BullMQ |
| State | Comportamiento dependiente del estado | Máquinas de estado de dominio, Router de Angular |
| Mediator | Desacoplar emisores de receptores | CommandBus y EventBus |
20.10 Patrones de arquitectura de datos
| Patrón | En qué consiste | En este stack |
|---|---|---|
| Data Mapper | Un mapeador traduce entre objetos y filas; el objeto no sabe que se persiste | Es el modelo de MikroORM (y de Doctrine e Hibernate) |
| Active Record | La 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 |
| Repository | Colección de agregados con lenguaje de dominio | Puerto + adaptador (sección 20.5) |
| Unit of Work | Acumula cambios y los escribe en una sola transacción | El EntityManager lo implementa: flush() calcula el diff y ordena los INSERT/UPDATE/DELETE |
| DAO | Objeto de acceso a datos orientado a la tabla, no al agregado | Útil para consultas de lectura e informes; no confundir con un repositorio |
| Specification | Criterios componibles y reutilizables | Objetos que producen FilterQuery<T> (sección 20.7) |
| CQRS | Separar el modelo de escritura del de lectura | Agregados para escribir; proyecciones planas con QueryBuilder para leer |
| Event Sourcing | El estado es la suma de los eventos, que son la fuente de verdad | Rara vez justificado; ver el aviso más abajo |
| Outbox | Publicar mensajes con la misma transacción que escribe los datos | Tabla outbox + trabajador que la vacía |
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.
@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.
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ón | Síntoma | Ejemplo típico | Remedio |
|---|---|---|---|
| Modelo de dominio anémico | Entidades con solo getters y setters; toda la lógica en servicios | class Task { title; status; } + TasksService de 800 líneas | Mover al agregado las reglas que dependen de su estado (ver debate abajo) |
| God object / servicio dios | Una clase que lo sabe y lo hace todo | AppService, CoreService, SharedService | Dividir por actor y por caso de uso; aplicar SRP |
| Controlador gordo | Consultas, reglas y efectos dentro del @Controller | El ejemplo incorrecto de la sección 20.4 | Extraer caso de uso; el controlador solo traduce protocolo |
| Big ball of mud | No hay fronteras: todo importa a todo, ciclos por doquier | Cualquier proyecto de tres años sin regla de dependencias en CI | Trazar módulos, prohibir ciclos con dependency-cruiser, extraer poco a poco |
| Singleton global mutable | Estado compartido fuera del inyector | export const cache = new Map() a nivel de módulo | Provider con ámbito controlado; si es por petición, AsyncLocalStorage |
| Primitive obsession | Todo son string y number | transfer(from: string, to: string, amount: number) | Value objects y tipos marcados (branded types) |
| Feature envy | Un método usa más datos de otro objeto que del suyo | if (task.status === 'done' && task.dueDate < now) fuera de Task | Mover el método al objeto dueño de los datos |
| Shotgun surgery | Un cambio pequeño obliga a tocar muchos archivos | Añadir un estado de tarea implica editar ocho switch | Centralizar la decisión: polimorfismo o tabla de transiciones |
| Sobre-abstracción prematura | Interfaces y capas «por si acaso», con una sola implementación | IEmailServiceFactoryProvider | YAGNI: la abstracción se extrae cuando aparece el segundo caso |
| Arquitectura de currículum | Se elige la tecnología por lo que luce, no por el problema | Kafka, microservicios y event sourcing para 200 usuarios internos | ADR con criterios explícitos (20.15) y revisión por pares |
// 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)
@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
- Los nombres revelan intención.
const d = new Date()no dice nada;const completedAtlo dice todo. Si necesitas un comentario para explicar el nombre, el nombre es malo. La longitud debe ser proporcional al ámbito: unaien un bucle de tres líneas está bien; una propiedad de clase, no. - Nada de abreviaturas ni de notación húngara.
usrRepo,strNameoarrTasksañaden ruido: el tipo ya lo dice TypeScript. LaIdeIUserServicees herencia de C# y aquí sobra, entre otras cosas porque una interfaz y una clase son intercambiables estructuralmente. - Booleanos en forma de afirmación (
isArchived,hasPermission,canBeCompleted) y sin negaciones en el nombre:if (!isNotValid)es un acertijo. Funciones con verbo y clases con sustantivo:calculateOverdueFee(), nodata(). Un concepto, una palabra: si esfetch, que sea siemprefetch, y noget,retrieveyload. - Un solo nivel de abstracción por función. El defecto más frecuente es mezclar el «qué» con el «cómo»: tres líneas de orquestación seguidas de veinte de manipulación de cadenas.
- Pocos parámetros. Con más de tres, agrúpalos en un objeto con nombre
(
execute(cmd: CompleteTaskCommand)): se lee mejor, elimina los errores por orden de argumentos y añadir un campo no rompe a los llamantes. Y nada de banderas booleanas:save(task, true)es ilegible en el punto de llamada y significa que la función hace dos cosas. - CQS (separación comando-consulta). Una función o cambia el estado y no devuelve nada, o devuelve algo
y no cambia nada. Un
getUser()que además crea el usuario si no existe es una bomba de relojería. Y usa cláusulas de guarda antes que anidamiento: salir pronto de los casos raros deja el camino feliz en el margen.
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 contadorsobrecount++. - 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
- Excepciones frente a códigos de error. En TypeScript las excepciones son idiomáticas y no ensucian la firma; su
problema es que son invisibles para el compilador. Los tipos de resultado (
Result<T, E>) obligan a tratar el error, pero contagian su envoltorio a todo. Recomendación equilibrada: excepciones para lo excepcional (fallo de red, error de programación) yResulten los pocos flujos donde el error es parte normal del caso de uso. - Errores de dominio, no de HTTP, en el dominio (
TaskArchived, noBadRequestException): unExceptionFiltertraduce en la frontera. No te tragues los errores: uncatch {}vacío convierte un fallo reproducible en un misterio de tres días. Y conserva la causa:throw new PaymentFailed('...', { cause: err })mantiene la traza original. - Ley de Demeter («no hables con extraños»). Un método solo debería llamar a métodos de: sí mismo, sus parámetros, los
objetos que crea y sus propios campos.
task.project.owner.address.cityacopla a cuatro clases y se rompe con cualquier cambio. Excepción sensata: las interfaces fluidas (QueryBuilder, RxJS) devuelven siempre el mismo objeto y no violan nada. - Principio de mínima sorpresa. Un método llamado
findUserno debe crear usuarios; un getter no debe lanzar peticiones HTTP. Cada sorpresa es una futura sesión de depuración. YAGNI: no implementes hoy lo que crees que necesitarás mañana. KISS: entre dos soluciones que funcionan, gana la que se entiende sin explicación. - DRY, con matices. El principio original de Hunt y Thomas habla de conocimiento, no de texto. Deduplicar dos
fragmentos que casualmente se parecen crea acoplamiento accidental: el día que uno cambia aparece un parámetro booleano
en la función común, luego otro, y acabas con un
ifpor llamante. Sandi Metz lo resumió mejor que nadie: «la duplicación es mucho más barata que la abstracción equivocada». - Formato y estructura. Delega el estilo en Prettier y ESLint con
--max-warnings 0en CI. Un archivo, un concepto, enkebab-casecon sufijo de rol (complete-task.use-case.ts). Dentro de la clase: campos estáticos, campos de instancia, constructor, métodos públicos, métodos privados; y la step-down rule, lo que llama antes que lo llamado, para que el archivo se lea de arriba abajo.
@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;
}
}
/** 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ón | Cuándo | Ejemplo en este stack |
|---|---|---|
| Extraer método | Un bloque necesita un comentario para entenderse | Sacar el cálculo de vencimiento a isOverdue(now) |
| Extraer clase | Un grupo de campos y métodos tiene vida propia | Sacar el envío de correos del servicio de tareas |
| Introducir objeto parámetro | Más de tres argumentos, o argumentos que viajan juntos | execute(cmd: CompleteTaskCommand) |
| Reemplazar condicional por polimorfismo | Un switch sobre un tipo | Canales de notificación como estrategias (20.8.2) |
| Reemplazar primitivo por objeto | Un string o number con reglas propias | Money, Email, TaskId |
| Mover método | El método usa más datos de otra clase que de la suya | Llevar la regla del servicio a la entidad |
| Condicional anidado por guardas | Escaleras de if | Salir pronto en los casos de error |
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 testear | Lo que revela | Arreglo de diseño |
|---|---|---|
| Necesito la base de datos para probar una regla | La regla vive en la capa equivocada | Mover al dominio; puerto para el acceso a datos |
| El test falla los martes o después de medianoche | Dependencia oculta del reloj o de la zona horaria | Inyectar ClockPort |
| Necesito seis mocks para instanciar la clase | Demasiadas 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 comportamiento | Repositorio en memoria y asertar sobre el estado resultante |
| Tengo que espiar un método privado | Ese método quiere ser una clase aparte | Extraer clase y testearla por su interfaz pública |
| Renombro un método y se rompen 40 tests | Los tests están acoplados a los detalles | Testear 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.
# 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 microservicios | Qué implica en la práctica |
|---|---|
| Operación | N pipelines, N despliegues, N configuraciones de secretos, N cuadros de mando, orquestador |
| Latencia | Lo que era una llamada de función pasa a ser una petición de red que puede fallar, tardar o duplicarse |
| Consistencia | Se acabaron las transacciones ACID entre módulos: sagas, compensaciones e idempotencia obligatoria |
| Depuración | Un fallo requiere trazas distribuidas y correlación de identificadores entre servicios |
| Contratos | Cada cambio de API necesita versionado y compatibilidad hacia atrás durante el despliegue |
| Datos | Nada de JOIN entre servicios: duplicación de datos y consistencia eventual |
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
| Error | Causa | Solución |
|---|---|---|
Capas que se saltan: el controlador consulta el EntityManager | Prisa, o falta de un caso de uso donde poner la lógica | Prohibir 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 dependencias | Puerto declarado por el dominio, adaptador en infraestructura |
| El DTO acaba convertido en la entidad | Object.assign(entity, dto) por comodidad | Constructor 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ónico | YAGNI: clase concreta y extracción de la interfaz cuando aparezca el segundo caso |
| Repositorio genérico que filtra detalles del ORM | IRepository<T> copiado de un tutorial | Un repositorio por agregado, con métodos con nombre de negocio |
| Interfaces de un solo método usadas en todas partes | ISP mal entendido | Agrupar por capacidad del consumidor; a veces un tipo de función basta |
| Dependencias circulares entre módulos | Dos contextos que se llaman mutuamente | Extraer lo común, invertir con un evento, o admitir que son un solo contexto |
| Entidades devueltas tal cual por la API | Falta de DTO de salida | DTO 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/, noservices/. - 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?
Si MikroORM ya implementa Data Mapper y Unit of Work, ¿para qué un repositorio?
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?
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.
¿Cuál es la diferencia entre inyección de dependencias e inversión de dependencias?
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?
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?
¿Cómo evito que el «shared» se convierta en un vertedero?
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?
Pregunta de entrevista: ¿qué patrones de diseño usa Angular?
@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?
¿Cuántas capas debería tener mi proyecto?
¿Debería usar @nestjs/cqrs desde el principio?
¿Qué hago si heredo un «big ball of mud» en producción?
20.20 Ejercicios
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.
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.
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
- Robert C. Martin, Clean Code (2008) — el catálogo de referencia sobre nombres, funciones y olores de código. Léelo con criterio: sus ejemplos en Java envejecen, sus principios no.
- Robert C. Martin, Clean Architecture (2017) — la regla de dependencia explicada con paciencia y una defensa clara de por qué el framework es un detalle.
- Martin Fowler, Patterns of Enterprise Application Architecture (2002) — de aquí salen Data Mapper, Unit of Work, Repository, Active Record e Identity Map, que son literalmente el diseño interno de MikroORM.
- Eric Evans, Domain-Driven Design (2003) — el libro azul, el original. Denso; empieza por las partes II y III.
- Vaughn Vernon, Implementing Domain-Driven Design (2013) — el libro rojo, mucho más práctico, con las reglas de diseño de agregados de la sección 20.7.
- Martin Fowler, Refactoring (2.ª ed., 2018, con ejemplos en JavaScript) — el catálogo de refactorizaciones paso a paso; encaja perfectamente con este stack.
- Michael Feathers, Working Effectively with Legacy Code (2004) — costuras y tests de caracterización: el manual para el código que ya está en producción.
- martinfowler.com · Arquitectura — artículos de referencia sobre monolito modular, microservicios, CQRS y deuda técnica.
- Alistair Cockburn · Hexagonal Architecture — el artículo original de puertos y adaptadores, en palabras de su autor.
- adr.github.io — plantillas y herramientas para Architecture Decision Records.
- Refactoring Guru (en español) — catálogo visual de los patrones GoF con ejemplos en TypeScript.
- NestJS · Custom providers — documentación oficial de tokens,
useFactoryyuseClass: la base técnica de los puertos y adaptadores de este capítulo.