01 · Hexagonal · DDD · CQRS · EDA
1. El desafío de la complejidad en sistemas modernos
La experiencia muestra que, en aplicaciones monolíticas de gran tamaño, hasta un 80% del tiempo del equipo se invierte en leer código y rastrear dependencias antes de poder realizar un cambio seguro. Cada nueva funcionalidad incrementa la probabilidad de efectos colaterales, de modo que la evolución del producto se ralentiza y el refactor pasa de ser una decisión técnica a convertirse en una cuestión política:
«¿Quién se atreve a tocar ese módulo crítico sin romper el build justo antes del despliegue?»
Para visualizar el impacto, comparemos un monolito acoplado con un conjunto de microservicios estructurados en capas de dominio:
Mientras el monolito concentra responsabilidades y dependencias en un mismo espacio de memoria, el enfoque hexagonal distribuye la lógica en componentes aislados y explícitamente conectados.
2. Un enfoque arquitectónico estratégico
A lo largo del curso profundizaremos en cuatro pilares complementarios. A continuación se presenta una síntesis conceptual y sus compromisos.
2.1 Arquitectura Hexagonal (Ports & Adapters)
El propósito fundamental es proteger el núcleo de dominio de detalles tecnológicos que inevitablemente cambian.
Dónde encaja: capa de dominio y casos de uso internos.
Resultado esperado: adaptar o sustituir tecnología (BD, mensajería) sin reescribir reglas de negocio.
Dominio libre de frameworks
Mayor código de infraestructura inicial
Tests unitarios sin I/O
Definir contratos de puerto claros
Cambio de proveedor de base de datos en un sprint
Contener dependencias externas mediante inversión de control
Ejemplo mínimo (TypeScript)
// domain/order.ts
/** Puerto de persistencia - la interfaz la define el dominio */
export interface OrderRepositoryPort {
save(order: Order): Promise<void>;
byId(id: string): Promise<Order | null>;
}
/** Entidad de dominio; no conoce frameworks ni librerías externas */
export class Order {
constructor(
public readonly id: string,
private readonly items: OrderItem[]
) {}
total(): number {
return this.items.reduce((acc, it) => acc + it.price * it.qty, 0);
}
}
/** Value Object protegido con invariantes */
export class OrderItem {
constructor(
readonly sku: string,
readonly qty: number,
readonly price: number
) {
if (!Number.isInteger(qty) || qty <= 0)
throw new Error("Cantidad inválida");
}
}
// infrastructure/postgres/order-repository.postgres.ts
import { OrderRepositoryPort, Order } from "../../domain/order";
import { prisma } from "../prisma"; // infraestructura técnica
export class PostgresOrderRepository implements OrderRepositoryPort {
async save(order: Order): Promise<void> {
await prisma.order.upsert({
where: { id: order.id },
update: { total: order.total() },
create: { id: order.id, total: order.total() },
});
}
async byId(id: string): Promise<Order | null> {
const row = await prisma.order.findUnique({ where: { id } });
return row ? new Order(row.id, /* hydrate items… */ []) : null;
}
}
Observación: el core no sabe que existe Prisma ni PostgreSQL.
2.2 Domain-Driven Design (DDD)
DDD parte de una premisa sencilla: el software se mantiene alineado con la realidad del negocio cuando modela el lenguaje y las reglas que los expertos usan a diario. Aplicarlo exige dos niveles:
Estratégico
Domar la complejidad global dividiendo el dominio en zonas coherentes.
Subdominios, Bounded Contexts, Context Map
Táctico
Expresar las reglas dentro de cada contexto.
Entities, Value Objects, Aggregates, Domain Events, Domain Services
2.2.1 Ejemplo estratégico — Context Map
Supongamos un comercio electrónico:
Ordering y Payments son dominio Core: diferencian al negocio.
Inventory y Shipping proveen soporte, pero no ventaja competitiva.
Decisión: invertimos más esfuerzo DDD en los contextos core y aplicamos soluciones genéricas (o SaaS) en los de soporte.
2.2.2 Ejemplo táctico — Entidad, Value Object y Aggregate
// domain/vo/money.ts
export class Money {
// Constructor privado: fuerza el uso de static Factory para validar invariantes
private constructor(private readonly cents: number) {}
/**
* Static Factory: crea un Money a partir de un número decimal
* - Redondea a centavos
* - Lanza si el valor es negativo
*/
static of(amount: number) {
if (amount < 0) throw new Error("Negative money");
return new Money(Math.round(amount * 100));
}
/**
* Suma otro Money devolviendo una nueva instancia (inmutabilidad)
*/
plus(other: Money) {
return new Money(this.cents + other.cents);
}
/**
* Convierte los centavos al valor decimal original
*/
toDecimal() {
return this.cents / 100;
}
}
const money = Money.of(100.5);
const other = Money.of(200.5);
const sum = money.plus(other);
console.log(sum.toDecimal()); // 301 - https://www.typescriptlang.org/play
// domain/entity/order-item.ts
import { Money } from "../vo/money";
export class OrderItem {
/**
* SKU, cantidad y precio se definen al construir.
* Validamos que la cantidad sea un entero positivo.
*/
constructor(
readonly sku: string,
readonly qty: number,
readonly price: Money
) {
if (!Number.isInteger(qty) || qty <= 0) {
throw new Error("Invalid qty");
}
}
/**
* Calcula el subtotal de este item: qty × price.
* Ejemplo: qty=3, price=100 → subtotal = 300
*/
subtotal(): Money {
// price.plus(Money.of(0)) es solo para encajar el ejemplo de plus()
return this.price.plus(Money.of((this.qty - 1) * this.price.toDecimal()));
}
}
const item = new OrderItem("SKU-123", 3, Money.of(100.5));
console.log(item.subtotal().toDecimal()); // 301.5 - https://www.typescriptlang.org/play
// domain/aggregate/order.ts
import { DomainEvent, AggregateRoot } from "../events";
import { OrderItem } from "../entity/order-item";
import { Money } from "../vo/money";
// El AggregateRoot provee manejo de eventos y un ID único
export class Order extends AggregateRoot {
private items: OrderItem[] = []; // Lista de OrderItem
private status: "PENDING" | "PAID" = "PENDING"; // Estado inicial
/**
* Añade un item al pedido. No valida stock aquí.
*/
addItem(item: OrderItem) {
this.items.push(item);
}
/**
* Marca la orden como pagada:
* - Verifica invariante (solo PENDING → PAID)
* - Cambia estado
* - Genera evento de dominio OrderPaid
*/
pay() {
if (this.status !== "PENDING") {
throw new Error("Order cannot be paid twice");
}
this.status = "PAID";
// Emite evento para que otros contextos reaccionen (EDA / CQRS)
this.addEvent(new OrderPaid({ orderId: this.id, total: this.total() }));
}
/**
* Suma el subtotal de cada OrderItem usando Money
*/
total(): Money {
return this.items.reduce((acc, it) => acc.plus(it.subtotal()), Money.of(0));
}
}
/**
* Evento de dominio que notifica a otros contextos que la orden fue pagada
*/
export class OrderPaid implements DomainEvent<"OrderPaid"> {
readonly type = "OrderPaid" as const;
readonly occurredAt = new Date();
constructor(readonly payload: { orderId: string; total: Money }) {}
}
Puntos clave
Invariantes encapsuladas: no existe forma de dejar el agregado en estado ilegítimo sin lanzar excepción.
Eventos de dominio: el agregado comunica un cambio significativo (
OrderPaid
) sin acoplarse a infraestructura.Value Object
Money
preserva exactitud y reglas monetarias (inmutabilidad, no valores negativos).
2.2.3 Domain Service — regla que cruza aggregates
// domain/service/payment-service.ts
import { CreditPort } from "../ports/credit.port";
import { OrderRepositoryPort } from "../ports/order-repo.port";
import { OrderPaid } from "../aggregate/order";
export class PaymentService {
constructor(
private readonly creditPort: CreditPort, // Consulta crédito
private readonly orderRepo: OrderRepositoryPort // Persistencia de órdenes
) {}
/**
* Proceso de pago completo:
* 1. Recupera la orden
* 2. Verifica crédito disponible
* 3. Invoca pay() → genera evento
* 4. Persiste cambios y eventos (Outbox pattern)
*/
async payOrder(orderId: string): Promise<void> {
// 1. Carga la orden o falla
const order = await this.orderRepo.byId(orderId);
if (!order) throw new Error("Order not found");
// 2. Comprueba crédito externo
const allowed = await this.creditPort.hasEnough(orderId, order.total());
if (!allowed) throw new Error("Insufficient credit");
// 3. Cambia estado + emite evento
order.pay();
// 4. Guarda la orden y sus eventos en transacción atómica
await this.orderRepo.save(order);
}
}
La operación involucra el agregado Order y un sistema externo (Credit Service).
Encapsularla en un Domain Service mantiene a la entidad Order pura e independiente de APIs externas.
2.2.4 Beneficios tangibles de aplicar DDD correctamente
Cobertura tests (target)
20 - 30 %
80 % (foco en dominio)
Tiempo medio de cambio (MTTR)
Alto – revisar capas front/backend/DB
Bajo – reglas localizadas en Aggregates
Comunicación negocio-equipo
Glosario disperso
Lenguaje Ubicuo
Resultado final: el costo de agregar reglas nuevas se aproxima al costo de escribir las reglas, no al costo de redescubrir el sistema.
2.2.5 Ejercicio — Domain Model
Modelar el/los dominio(s) de https://www.eachlabs.ai/ - basado en su descripción.
2.3 CQRS (Command Query Responsibility Segregation)
Idea principal: separar la modificación de estado (Commands) de la consultación de datos (Queries), permitiendo que cada lado evolucione, escale y se optimice de manera independiente.
2.3.1 Modelos independientes
Write Model: contiene las reglas de negocio, las invariantes y las transacciones. Suele usar el modelo de dominio (Aggregates, Entities, Value Objects) junto a un ORM o repositorios transaccionales.
Read Model: diseñado para consultas rápidas y específicas, a menudo desnormalizado en tablas o índices optimizados (por ejemplo, vistas materializadas, bases de datos NoSQL).
2.3.2 Flujo típico
2.3.3 Ejemplo de implementación mínima (TypeScript)
// application/commands/handlers/create-order.handler.ts
import { OrderRepositoryPort } from "../../domain/ports/order-repo.port";
import { CreateOrder } from "../commands/create-order";
export class CreateOrderHandler {
constructor(private repo: OrderRepositoryPort) {}
async execute(cmd: CreateOrder): Promise<string> {
const order = Order.Factory.new(cmd.items);
await this.repo.save(order);
// opcional: publicar eventos de dominio
return order.id;
}
}
// application/queries/handlers/get-order.handler.ts
import { ReadOrderRepository } from "../read-models/order.read-repo";
import { GetOrder } from "../queries/get-order";
export class GetOrderHandler {
constructor(private readRepo: ReadOrderRepository) {}
async execute(qry: GetOrder): Promise<OrderDTO> {
return this.readRepo.findById(qry.id);
}
}
2.3.4 Consideraciones
Consistencia eventual: tras un comando, puede haber un pequeño retardo hasta que el Read Model refleje el cambio. Hay que diseñar UX y Sagas acordes.
Idempotencia y resiliencia: al procesar eventos para actualizar el Read Model, los handlers deben ser idempotentes y capaces de reintentos seguros.
Event Sourcing (opcional): si los eventos de dominio se convierten en la única fuente de verdad, el Write Model se reconstruye reaplicando la secuencia de eventos. Esto facilita auditoría y versionado, pero añade complejidad operacional.
2.3.5 ¿Cuándo usarlo?
Alta carga de lectura y escritura que requieren escalado independiente.
Modelos de datos muy divergentes entre operaciones de lectura (dashboards, reportes) y escritura (flujos de negocio complejos).
Equipo multidisciplinar, donde un grupo optimiza el Write Model y otro el Read Model sin interferirse.
2.4 Event-Driven Architecture (EDA)
Idea principal: articular el sistema en torno a la emisión y consumo de eventos de dominio a través de un broker asíncrono (RabbitMQ, Kafka, AWS SNS/SQS…), consiguiendo desacoplamiento, flexibilidad y resiliencia.
2.4.1 Patrón de Publicación–Suscripción
Productor: el Aggregate o el Application Service publica un evento (por ejemplo, OrderPaid, InventoryReserved).
Consumidor: uno o varios microservicios o Read Updaters se suscriben al tópico correspondiente y reaccionan (actualizan proyecciones, envían notificaciones, disparan nuevas acciones).
2.4.2 Esquema de evento
interface DomainEvent {
type: string; // p.ej. 'OrderPaid'
occurredAt: string; // timestamp ISO
payload: Record<string, any>;
version: number; // para compatibilidad de esquema
}
2.4.3 Ejemplo mínimo de publicación y suscripción (Node.js + RabbitMQ)
// infrastructure/eda/event-bus.ts
import amqp from "amqplib";
export class RabbitEventBus {
private conn = await amqp.connect(process.env.AMQP_URL);
private channel = await this.conn.createChannel();
async publish(event: DomainEvent) {
const exchange = "domain_events";
await this.channel.assertExchange(exchange, "topic", { durable: true });
this.channel.publish(
exchange,
event.type,
Buffer.from(JSON.stringify(event)),
{ persistent: true }
);
}
async subscribe(
eventType: string,
handler: (event: DomainEvent) => Promise<void>
) {
const exchange = "domain_events";
const q = await this.channel.assertQueue("", { exclusive: true });
await this.channel.bindQueue(q.queue, exchange, eventType);
this.channel.consume(q.queue, (msg) => {
if (!msg) return;
const event = JSON.parse(msg.content.toString()) as DomainEvent;
handler(event)
.then(() => this.channel.ack(msg))
.catch(() => this.channel.nack(msg, false, false)); // DLQ
});
}
}
2.4.4 Patrones de fiabilidad
Outbox
Garantizar que el evento se guarda y publica en la misma transacción.
Idempotencia
Permitir reintentos sin efectos colaterales al procesar eventos duplicados.
Dead-Letter Queue
Aislar los mensajes que fallan repetidamente para revisión manual.
Circuit Breaker
Evitar que un consumidor defectuoso bloquee la cadena de procesamiento.
2.4.5 Orquestación vs. Coreografía
Coreografía: cada servicio reacciona a eventos de otros sin coordinador central. Máximo desacoplo, pero puede ser difícil razonar el flujo.
Orquestación: un orquestador (por ejemplo, un Saga Manager) envía comandos o eventos en un flujo controlado. Más control, pero introduce un punto de coordinación que debe gestionarse.
2.4.6 Anti-patrón
Eventos como RPC encubierto: esperar respuesta sincrónica (pub/sub sincrónico) rompe el desacoplamiento y reintroduce latencias, time-outs y complejidad.
3. ¿Por qué funciona bien en Node.js?
Modelo de ejecución asíncrono y no bloqueante
Manejo eficiente de brokers y bases de datos concurrentes
Ecosistema npm extenso
Adapters disponibles para RabbitMQ, gRPC, OpenTelemetry, etc.
TypeScript maduro
Expresión clara de Entities, Value Objects y puertos con tipos estáticos
Imágenes Docker ligeras
Facilita microservicios con arranque rápido y bajo consumo
Herramientas de testing (Vitest, ts-mockito)
Permite pruebas rápidas en memoria del núcleo de dominio
Ejemplo de instrumentación OTel mínima:
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-otlp-http";
new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: "http://otel-collector:4318/v1/traces",
}),
}).start();
4. Beneficios clave
Mantenibilidad: cambio localizado sin efectos cascada.
Escalabilidad: lectura y escritura crecen de forma independiente.
Time-to-Market: incorporación de nuevos features con menor riesgo.
Observabilidad: capas y eventos claramente delineados facilitan el trazado.
Última actualización