Arquitectura de Software

Domain-Driven Design: Domain Events, cómo representar hechos del negocio y desacoplar procesos

Aprende a registrar hechos del negocio desde un Aggregate Root y a procesarlos con handlers en TypeScript. Un caso de pedidos permite explorar pruebas, idempotencia, fallos después de guardar y cuándo incorporar un Transactional Outbox.

Cargando tus opciones de aprendizaje…

Un pedido confirmado puede interesar a varias partes del sistema

Un cliente confirma un pedido. La aplicación debe guardar ese cambio, actualizar una vista comercial y preparar una notificación. Al principio, todo termina dentro de Order.confirm(): una llamada al repositorio, otra al correo y una tercera al servicio de estadísticas.

El método empieza a conocer decisiones que no le corresponden. Además, aparece una pregunta incómoda: si el pedido se guardó pero falla la notificación, ¿el pedido está confirmado o no?

Un Domain Event, o evento de dominio, permite expresar el hecho que origina esas reacciones: OrderConfirmed. El pedido registra lo que ocurrió; otros componentes deciden cómo reaccionar. Eso separa responsabilidades, pero no resuelve por sí solo la entrega, los reintentos ni las transacciones.

Trabajaremos con TypeScript y Node.js, primero en memoria. El ejemplo es independiente de cualquier repositorio concreto y no añade servicios de mensajería. Recuperaremos únicamente lo necesario de Aggregates y Repositories para seguir el recorrido de una confirmación.

El evento cuenta algo que ocurrió, no algo que queremos intentar

Un evento de dominio conserva un acontecimiento relevante para el negocio. La idea central es que su significado sobreviva al detalle técnico que lo produjo: «pedido confirmado» sigue teniendo sentido aunque cambies la base de datos. La descripción clásica de Domain Event de Martin Fowler pone el foco en esa memoria de un hecho significativo y distingue cuándo ocurrió de cuándo fue observado.

Conviene separar cuatro conceptos:

  • Command: solicita una acción. ConfirmOrder puede rechazarse si el pedido no cumple una regla.
  • Domain Event: expresa un hecho aceptado por el modelo. OrderConfirmed no pide permiso para confirmar; registra que la transición ya ocurrió en ese modelo.
  • Integration Event: comunica hacia otro sistema un hecho que la aplicación decidió exponer después de confirmar su transacción. Es un contrato de intercambio, no una copia automática del objeto interno.
  • Evento técnico: describe infraestructura, como una conexión cerrada o una fila insertada. Puede ser útil, pero no necesariamente representa una decisión del negocio.

«Ya ocurrió» necesita una precisión: el agregado puede registrar el evento antes del commit. Si la persistencia se revierte, no debes anunciarlo fuera como un hecho confirmado. Más adelante separaremos el registro en memoria de su publicación duradera.

El nombre ayuda a conservar esa intención. OrderConfirmed, EnrollmentCancelled y ShipmentDispatched hablan en pasado. SendEmail, UpdateStatistics y NotifyWarehouse son instrucciones. Convertirlas en nombres de eventos no las transforma en hechos de negocio.

Cuándo merece existir un Domain Event

No necesitas emitir un evento por cada asignación. Pregúntate si una persona del negocio reconocería el hecho, si tiene un instante identificable y si alguna reacción necesita conocerlo sin quedar incrustada en el objeto que cambió.

Un cambio de estado a «confirmado» puede justificar OrderConfirmed. Una operación interna que recalcula una variable temporal normalmente no. Tampoco conviene crear OrderChanged para todo: obliga a los consumidores a reconstruir qué sucedió leyendo el estado actual.

En nuestro ejemplo, confirmación significa que el pedido dejó de ser borrador y tiene un importe positivo. No significa «pago cobrado» ni «stock reservado». Esos serían hechos distintos y no podemos prometerlos con este evento.

Si solo quieres aplicar un descuento dentro del mismo agregado antes de confirmar, una llamada explícita al comportamiento correspondiente puede ser suficiente. Los eventos aportan valor cuando hacen visible una colaboración real; usados para ocultar cada llamada, dificultan seguir el flujo.

Diseñar el mensaje: identidad, momento y datos del hecho

OrderConfirmed incluirá un identificador de evento, el pedido, el cliente, el importe, la moneda, el momento de ocurrencia y la versión del agregado. El identificador permitirá reconocer una reentrega; la versión ayudará a situar el cambio dentro del pedido.

El importe será el de la confirmación, no una referencia a un objeto mutable que mañana podría contener otro valor. Evitaremos incluir el agregado completo, conexiones, repositorios o secretos. Los consumidores recibirán un dato acotado, no permiso para modificar el pedido.

No existe una lista universal de campos. Un handler que solo necesita el identificador no justifica copiar toda la información del cliente. En una integración, elegirás además qué datos pueden salir, quién podrá leerlos y cómo evolucionará el contrato.

Usaremos Readonly y Object.freeze. readonly es una restricción del comprobador de tipos, no una congelación en ejecución, como aclara la documentación de TypeScript. Object.freeze es superficial: aquí basta porque el evento contiene valores primitivos. Con arrays u objetos anidados necesitarías diseñar copias y protección de esos valores, sin asumir una inmutabilidad profunda automática.

Caso completo: del Aggregate Root a los handlers

El siguiente ejemplo se entrega en tres partes de un mismo archivo domain-events.mts. Pégalas en orden, sin añadir separadores. Solo utiliza módulos incorporados de Node.js. Las secciones representan responsabilidades lógicas; en una aplicación real separarías dominio, aplicación, infraestructura y pruebas en sus archivos respectivos.

Es una demostración de entrega en memoria y sin garantía de recuperación tras una caída. No pretende implementar un outbox, un correo real, un servidor HTTP ni un ORM. Las pruebas incluyen ese límite deliberadamente. Los importes se expresan como enteros en centavos de USD; el modelo omite líneas y otras reglas de un pedido real.

domain-events.mts · 1/3

import assert from "node:assert/strict";
import test from "node:test";

type OrderConfirmed = Readonly<{
  type: "OrderConfirmed";
  eventId: string;
  occurredAt: string;
  orderId: string;
  customerId: string;
  totalCents: number;
  currency: "USD";
  aggregateVersion: number;
}>;

type OrderSnapshot = Readonly<{
  id: string;
  customerId: string;
  totalCents: number;
  status: "draft" | "confirmed";
  version: number;
}>;

type EventContext = Readonly<{
  eventId: string;
  occurredAt: string;
}>;

class Order {
  private state: OrderSnapshot;
  private events: OrderConfirmed[] = [];

  private constructor(snapshot: OrderSnapshot) {
    this.state = Object.freeze({ ...snapshot });
  }

  static draft(id: string, customerId: string, totalCents: number): Order {
    if (!id.trim() || !customerId.trim()) throw new Error("Missing identity");
    if (!Number.isSafeInteger(totalCents) || totalCents < 0) {
      throw new Error("Invalid total");
    }
    return new Order({ id, customerId, totalCents, status: "draft", version: 0 });
  }

  static restore(snapshot: OrderSnapshot): Order {
    // El repositorio de esta demo solo almacena snapshots creados por Order.
    return new Order(snapshot);
  }

  snapshot(): OrderSnapshot {
    return Object.freeze({ ...this.state });
  }

  pendingEvents(): readonly OrderConfirmed[] {
    return Object.freeze([...this.events]);
  }

  confirm(context: EventContext): void {
    if (this.state.status === "confirmed") return;
    if (this.state.totalCents === 0) throw new Error("Cannot confirm an empty total");
    if (!context.eventId.trim() || Number.isNaN(Date.parse(context.occurredAt))) {
      throw new Error("Invalid event context");
    }

    const version = this.state.version + 1;
    const event: OrderConfirmed = Object.freeze({
      type: "OrderConfirmed",
      eventId: context.eventId,
      occurredAt: context.occurredAt,
      orderId: this.state.id,
      customerId: this.state.customerId,
      totalCents: this.state.totalCents,
      currency: "USD",
      aggregateVersion: version,
    });

    this.state = Object.freeze({ ...this.state, status: "confirmed", version });
    this.events.push(event);
  }

  pullEvents(): readonly OrderConfirmed[] {
    const result = Object.freeze([...this.events]);
    this.events = [];
    return result;
  }
}

La raíz del agregado protege la transición y registra un evento únicamente si se produjo. Repetir confirm() sobre ese pedido ya confirmado no añade otra confirmación. Esto hace idempotente esta transición específica, no cualquier comando de la aplicación: operaciones como «añadir una unidad» necesitan otra estrategia para distinguir un reintento de una solicitud nueva.

El contexto del evento se inyecta. La aplicación decidirá cómo generar el identificador y obtener el reloj; el agregado no importa un bus, AWS ni un cliente de base de datos.

pendingEvents() permite consultar sin vaciar; pullEvents() entrega y limpia la colección. Usar este último antes de guardar y olvidar el resultado si el guardado falla sería un error. El registro diferido permite decidir después cuándo despachar, una separación que explica la guía de Microsoft sobre eventos de dominio.

domain-events.mts · 2/3

interface OrderRepository {
  get(id: string): Promise<Order>;
  save(order: Order, expectedVersion: number): Promise<void>;
}

class MemoryOrderRepository implements OrderRepository {
  private rows = new Map<string, OrderSnapshot>();
  failNextSave = false;

  constructor(initial: readonly OrderSnapshot[]) {
    for (const snapshot of initial) {
      this.rows.set(snapshot.id, Object.freeze({ ...snapshot }));
    }
  }

  async get(id: string): Promise<Order> {
    const snapshot = this.rows.get(id);
    if (!snapshot) throw new Error("Order not found");
    return Order.restore(snapshot);
  }

  async save(order: Order, expectedVersion: number): Promise<void> {
    if (this.failNextSave) {
      this.failNextSave = false;
      throw new Error("Save failed");
    }
    const next = order.snapshot();
    const previous = this.rows.get(next.id);
    if (!previous || previous.version !== expectedVersion) {
      throw new Error("Concurrent modification");
    }
    this.rows.set(next.id, next);
  }
}

interface OrderConfirmedHandler {
  handle(event: OrderConfirmed): Promise<void>;
}

class InMemoryDispatcher {
  private handlers: OrderConfirmedHandler[] = [];

  subscribe(handler: OrderConfirmedHandler): void {
    this.handlers.push(handler);
  }

  async dispatch(events: readonly OrderConfirmed[]): Promise<void> {
    for (const event of events) {
      for (const handler of this.handlers) await handler.handle(event);
    }
  }
}

class MemorySalesSummary implements OrderConfirmedHandler {
  private processed = new Set<string>();
  totalCents = 0;

  async handle(event: OrderConfirmed): Promise<void> {
    if (this.processed.has(event.eventId)) return;
    // No hay await entre el efecto y la marca en esta demo de un solo proceso.
    this.totalCents += event.totalCents;
    this.processed.add(event.eventId);
  }
}

class MemoryNotificationQueue implements OrderConfirmedHandler {
  readonly pending = new Map<string, Readonly<{ orderId: string; customerId: string }>>();

  async handle(event: OrderConfirmed): Promise<void> {
    if (this.pending.has(event.eventId)) return;
    this.pending.set(event.eventId, Object.freeze({
      orderId: event.orderId,
      customerId: event.customerId,
    }));
  }
}

class ConfirmOrder {
  private repository: OrderRepository;
  private dispatcher: InMemoryDispatcher;
  private nextEventId: () => string;
  private now: () => string;

  constructor(
    repository: OrderRepository,
    dispatcher: InMemoryDispatcher,
    nextEventId: () => string,
    now: () => string,
  ) {
    this.repository = repository;
    this.dispatcher = dispatcher;
    this.nextEventId = nextEventId;
    this.now = now;
  }

  async execute(orderId: string): Promise<void> {
    const order = await this.repository.get(orderId);
    const expectedVersion = order.snapshot().version;
    order.confirm({ eventId: this.nextEventId(), occurredAt: this.now() });
    if (order.pendingEvents().length === 0) return;

    await this.repository.save(order, expectedVersion);
    // Guardar primero evita anunciar una confirmación cuyo guardado falló.
    // NO elimina el intervalo de pérdida entre guardar y despachar.
    await this.dispatcher.dispatch(order.pullEvents());
  }
}

El flujo es explícito: cargar, ejecutar la regla, guardar con control de versión y despachar. El repositorio devuelve una instancia reconstruida, no una referencia al mismo objeto que guarda. Así, una modificación todavía no persistida no altera accidentalmente la «base» en memoria.

El dispatcher solo maneja OrderConfirmed. Esa limitación evita introducir un framework genérico para enseñar un evento. Los handlers se esperan secuencialmente; si uno falla, los posteriores no se ejecutan. Su orden es una decisión visible de esta implementación, no una garantía que debas asumir al cambiar de bus.

El resumen comercial y la cola de notificaciones son infraestructura de prueba. No son servicios de dominio: reaccionan al hecho y mantienen efectos auxiliares, sin decidir si el pedido podía confirmarse. Tampoco mandan un correo real.

Los sets y maps hacen que una repetición del mismo evento no duplique esos efectos dentro de esta instancia en memoria. Al reiniciar, se pierde todo. Un Set en RAM no proporciona idempotencia persistente ni coordinación entre varios procesos.

Pruebas: verificar también el caso incómodo

La tercera parte prueba tanto resultados correctos como fallos relevantes. No basta con comprobar que se invocó un handler: importa cuándo se guardó el pedido, qué pasó al fallar y qué hace un reintento.

domain-events.mts · 3/3

const instant = "2026-01-15T10:00:00.000Z";

function fixture() {
  const order = Order.draft("order-1", "customer-1", 2500);
  const repository = new MemoryOrderRepository([order.snapshot()]);
  const dispatcher = new InMemoryDispatcher();
  const sales = new MemorySalesSummary();
  const notifications = new MemoryNotificationQueue();
  const command = new ConfirmOrder(repository, dispatcher, () => "event-1", () => instant);
  return { repository, dispatcher, sales, notifications, command };
}

test("la regla registra un solo hecho inmutable", () => {
  const order = Order.draft("order-1", "customer-1", 2500);
  order.confirm({ eventId: "event-1", occurredAt: instant });
  order.confirm({ eventId: "event-2", occurredAt: instant });
  assert.equal(order.snapshot().version, 1);
  assert.equal(order.pendingEvents().length, 1);
  const event = order.pendingEvents()[0];
  assert.ok(event);
  assert.equal(event.totalCents, 2500);
  assert.equal(Object.isFrozen(event), true);
  assert.equal(order.pullEvents().length, 1);
  assert.equal(order.pendingEvents().length, 0);

  const empty = Order.draft("empty", "customer-1", 0);
  assert.throws(() => empty.confirm({ eventId: "event-3", occurredAt: instant }));
  assert.equal(empty.snapshot().status, "draft");
  assert.equal(empty.pendingEvents().length, 0);
});

test("el guardado precede a los handlers y una reentrega no duplica efectos", async () => {
  const f = fixture();
  const received: OrderConfirmed[] = [];
  f.dispatcher.subscribe({ async handle(event) {
    assert.equal((await f.repository.get(event.orderId)).snapshot().status, "confirmed");
    received.push(event);
  } });
  f.dispatcher.subscribe(f.sales);
  f.dispatcher.subscribe(f.notifications);
  await f.command.execute("order-1");
  await f.dispatcher.dispatch(received.slice(0, 1));
  await f.command.execute("order-1");
  assert.equal(f.sales.totalCents, 2500);
  assert.equal(f.notifications.pending.size, 1);
});

test("un guardado fallido no publica la confirmación", async () => {
  const f = fixture();
  f.dispatcher.subscribe(f.sales);
  f.repository.failNextSave = true;
  await assert.rejects(f.command.execute("order-1"), /Save failed/);
  assert.equal((await f.repository.get("order-1")).snapshot().status, "draft");
  assert.equal(f.sales.totalCents, 0);
});

test("fallar después de guardar no revierte el pedido ni repara la entrega", async () => {
  const f = fixture();
  f.dispatcher.subscribe(f.sales);
  f.dispatcher.subscribe({ async handle() { throw new Error("Notification unavailable"); } });
  f.dispatcher.subscribe(f.notifications);
  await assert.rejects(f.command.execute("order-1"), /Notification unavailable/);
  assert.equal((await f.repository.get("order-1")).snapshot().status, "confirmed");
  assert.equal(f.sales.totalCents, 2500);
  assert.equal(f.notifications.pending.size, 0);
  // El estado confirmado evita otro evento, pero el pendiente no está persistido.
  await f.command.execute("order-1");
  assert.equal(f.notifications.pending.size, 0);
});

test("dos instancias no pueden guardar sobre la misma versión", async () => {
  const f = fixture();
  const first = await f.repository.get("order-1");
  const second = await f.repository.get("order-1");
  first.confirm({ eventId: "first", occurredAt: instant });
  second.confirm({ eventId: "second", occurredAt: instant });
  await f.repository.save(first, 0);
  await assert.rejects(f.repository.save(second, 0), /Concurrent modification/);
});

Para ejecutar el archivo con Node.js 24 y su soporte de eliminación de tipos, usando únicamente la sintaxis compatible que aparece aquí:

node --test domain-events.mts

Eso ejecuta las pruebas, pero no realiza una comprobación estática de TypeScript. Si tu proyecto ya tiene TypeScript y los tipos de Node instalados, comprueba también:

tsc --noEmit --strict --target ES2022 --module NodeNext --moduleResolution NodeNext domain-events.mts

La prueba del fallo posterior al guardado es intencional: la operación devuelve un error, pero el pedido sigue confirmado. Repetir el comando no reconstruye el evento pendiente. Persistir el estado no equivale a persistir la intención de entrega. La demostración no debe utilizarse como notificación garantizada en producción.

Síncrono, asíncrono y transaccional son decisiones diferentes

En este dispatcher, cada handler devuelve una promesa y la aplicación la espera. Eso no introduce una cola durable ni un proceso separado. Es ejecución en el mismo proceso, secuencial y esperada por quien confirmó el pedido. Una función async no convierte una llamada en mensajería distribuida.

Tampoco basta con reemplazar await handler.handle(event) por una llamada sin await. Tendrías trabajo no esperado que podría fallar o desaparecer al terminar el proceso, sin una política explícita de recuperación.

Este fragmento es incorrecto si esperas entrega fiable; no es otra parte del archivo ejecutable:

await repository.save(order, expectedVersion);
void dispatcher.dispatch(order.pullEvents());
// Ignorar la promesa no crea una cola ni conserva los eventos ante una caída.

Esperar la promesa permite observar el fallo, como hacen nuestras pruebas. Para recuperarte de una caída necesitas además registrar el trabajo pendiente de forma duradera; cambiar solo el uso de await no lo consigue.

Hay dos momentos posibles que conviene distinguir:

  1. Antes del commit: ciertos handlers pueden participar en la misma transacción local si comparten realmente su contexto de persistencia. Si falla una escritura participante, puede revertirse todo. No metas un correo o una llamada HTTP suponiendo que el rollback los deshará.
  2. Después del commit: el pedido ya existe en su nuevo estado. Un fallo de una reacción no lo revierte. La reacción necesita su propia política de entrega y recuperación.

La guía de Microsoft sobre el despacho antes o después del commit subraya que esa elección cambia el límite transaccional. No hay atomicidad compartida solo porque dos operaciones ocurran dentro del mismo método.

La consistencia eventual describe que una vista o proceso derivado puede ponerse al día más tarde. No significa «todo terminará bien» por esperar: requiere mecanismos de entrega, reintento y recuperación. Si una regla debe cumplirse inmediatamente dentro del pedido, protégela al confirmar; no la delegues a un handler que quizá corra después.

Cuando una colaboración cruza contextos, un contrato HTTP o un mensaje puede servir de frontera. No importes directamente el agregado de otro contexto ni presentes dos llamadas HTTP como una transacción local compartida.

Del intervalo de pérdida al Transactional Outbox

La implementación anterior deja un intervalo entre save() y dispatch(). El proceso puede caer ahí. Publicar primero tampoco arregla el problema: otro componente podría actuar sobre una confirmación que después no se guarda.

El patrón Transactional Outbox registra la actualización del pedido y un mensaje pendiente en la misma transacción de la misma base de datos. Un publicador separado lee esos pendientes y los entrega. AWS explica este problema de doble escritura y sus alternativas en su guía de Transactional Outbox.

Este SQL es un esquema conceptual, no una migración ni una implementación completa:

BEGIN;

-- La aplicación debe comprobar que esta actualización afectó exactamente una fila.
UPDATE orders
SET status = 'confirmed', version = version + 1
WHERE id = :order_id AND status = 'draft' AND version = :expected_version;

-- El payload representa el contrato de integración elegido por la aplicación.
INSERT INTO outbox (event_id, event_type, payload, created_at)
VALUES (:event_id, 'orders.order-confirmed.v1', :payload, :created_at);

COMMIT;

Si la actualización no afecta una fila, la aplicación debe abortar y no insertar el mensaje. Si falla la inserción, revierte también la confirmación. Esa atomicidad cubre estado más pendiente local; no incluye mágicamente al broker.

El publicador puede enviar el mensaje y caer antes de marcarlo como enviado. Al recuperarse, lo volverá a entregar. Ese intervalo y la necesidad de consumidores idempotentes están descritos también en el patrón de Chris Richardson. Marcarlo como enviado antes de transmitirlo cambiaría el riesgo: podrías perderlo.

En producción faltan detalles importantes: reclamación de pendientes entre workers, caducidad de leases, límites de reintentos, limpieza, métricas y tratamiento de mensajes problemáticos. El evento mantiene su identidad al reintentarse; no generas un eventId nuevo en cada intento.

Idempotencia del consumidor

Supón que un consumidor actualiza un resumen comercial en su propia base. Puede registrar una clave única formada por consumer_name y event_id en la misma transacción que actualiza el resumen. Si la clave ya existe, esa operación ya se aplicó y no vuelve a sumar el importe.

Registrar «procesado» antes del efecto en transacciones separadas podría perder el efecto tras una caída. Hacer primero el efecto y registrar después, también por separado, podría duplicarlo. La protección debe coincidir con el recurso que estás modificando.

Un proveedor de correo o de pagos queda fuera de esa transacción local. Allí necesitas estudiar su soporte de claves de idempotencia y el significado concreto de «aceptado», «enviado» o «cobrado». Ni el outbox ni una cola garantizan por sí solos que un efecto externo se produzca exactamente una vez.

Domain Event interno e Integration Event público

El dominio podría seguir usando OrderConfirmed, mientras un adaptador de aplicación produce un contrato externo más pequeño:

{
  "id": "event-1",
  "type": "orders.order-confirmed.v1",
  "occurredAt": "2026-01-15T10:00:00.000Z",
  "data": {
    "orderId": "order-1",
    "totalCents": 2500,
    "currency": "USD"
  }
}

Este JSON es nuestro contrato ilustrativo, no el sobre específico de una API de AWS. Omitimos el cliente porque el consumidor supuesto no lo necesita. La versión del contrato indica cómo interpretar su estructura; no sustituye a la versión del agregado.

Conceptualmente, EventBridge puede ayudar a enrutar hechos hacia distintos destinos y SQS a mantener trabajo en colas para sus consumidores. Elegir uno, ambos o ninguno depende de las necesidades de entrega, aislamiento y operación; no es un requisito de DDD.

Las colas SQS estándar pueden volver a entregar un mensaje, por lo que AWS recomienda diseñar consumidores idempotentes ante reentregas. EventBridge dispone de políticas de reintento y colas de mensajes no entregados, descritas en su documentación de entrega a destinos. Configurar transporte no sustituye a gestionar errores de negocio, efectos parciales ni observabilidad.

Dos pedidos distintos pueden avanzar independientemente. Si tu consumidor requiere orden dentro de un mismo pedido, diseña esa necesidad explícitamente, identifica su versión y decide qué hará ante un evento atrasado. No supongas un orden global por llevar un timestamp.

Tres decisiones que cambian según el negocio

Educación: completar una clase no equivale a aprobar un curso

LessonCompleted puede actualizar el progreso visible o preparar una recomendación. Pero CourseCompleted solo debe registrarse cuando se cumplan las reglas de finalización del curso. Nombrar todos los eventos ProgressUpdated obligaría a cada consumidor a reinterpretar esas reglas.

Si entregar un certificado exige completar todas las clases, no lo emitas al recibir cualquier LessonCompleted. El proceso responsable debe verificar la condición correspondiente y evitar certificados duplicados. Aquí aprendes a elegir la granularidad del hecho y a no confundir una reacción con una nueva regla.

Reservas: una reacción tardía no protege una plaza única

En una reserva con capacidad limitada, la comprobación y el consumo de la plaza necesitan una estrategia consistente en el recurso que controla esa disponibilidad. Emitir ReservationRequested y esperar que otro handler detecte después la sobreventa no mantiene una regla que debía cumplirse inmediatamente.

Una vez aceptada la reserva, ReservationConfirmed sí puede alimentar una vista o una notificación con retraso tolerable. La enseñanza no es «todo debe ir por eventos», sino separar la decisión crítica de sus consecuencias diferibles.

Logística: cada hecho tiene su propio momento

OrderConfirmed puede iniciar la preparación de un envío, pero no significa ShipmentDispatched. Este último requiere que el proceso de logística confirme realmente la salida. Anunciar el despacho por anticipado solo para desacoplar módulos hace que el lenguaje del sistema deje de ser fiable.

Incluso dentro de un monolito puedes mantener estas colaboraciones con llamadas explícitas si son pocas y claras. Un dispatcher merece existir cuando simplifica la variación de reacciones y sus límites; si solo disimula un flujo fijo, revisa su utilidad.

Usar IA sin delegar las decisiones de consistencia

Una IA puede generar la estructura de mensajes y pruebas, pero también colocar un publicador dentro del agregado, crear un UUID nuevo al reintentar o envolver HTTP y SQL en una supuesta «transacción». Pídele decisiones observables y contrástalas con fallos concretos.

Para analizar el modelo:

En este sistema, confirmar un pedido cambia su estado y exige un importe positivo. Las estadísticas pueden retrasarse. Propón hechos de negocio y diferencia cada uno de comandos y eventos técnicos. No añadas un broker. Explica qué regla se protege inmediatamente y qué reacción puede fallar después. Una persona revisará que los nombres correspondan al lenguaje del negocio.

Para implementar el caso en memoria:

Genera TypeScript con un Aggregate Root que registre OrderConfirmed y un caso de uso que guarde antes de despachar. Inyecta reloj e identificador. Usa handlers pequeños y pruebas de guardado fallido, reentrega y error posterior al guardado. No lo presentes como entrega garantizada; señala el intervalo de pérdida y no introduzcas paquetes nuevos.

Para revisar fiabilidad:

Revisa este flujo de persistencia y publicación. Identifica los límites transaccionales reales y simula una caída antes del commit, después del commit y después del envío antes de registrar su éxito. Busca duplicados y pérdidas. Si propones outbox, explica qué garantiza y qué sigue necesitando idempotencia. No asumas que un correo se revierte con SQL. La revisión humana comprobará cada escenario y las pruebas ejecutadas.

Los prompts no sustituyen los tests. Examina especialmente si un ejemplo usa referencias mutables, si los handlers toman decisiones que pertenecían al agregado y si un fallo posterior se presenta erróneamente como rollback del pedido.

Ejercicios para aplicar lo aprendido

1. Distinguir intención de hecho

Un proceso recibe SendOrderConfirmationEmail y otro recibe OrderConfirmed. ¿Cuál sirve mejor como evento del dominio de pedidos? ¿Qué ocurre si cambia el canal de notificación?

Solución razonada: el primero ordena un efecto concreto; el segundo expresa la transición del pedido. Un handler puede decidir preparar un mensaje por correo o por otro canal sin cambiar el significado de la confirmación. Si el negocio necesita registrar que una notificación se entregó, ese es otro hecho, posterior y distinto.

2. Encontrar el fallo después de guardar

En la cuarta prueba, cambia el orden de los handlers: coloca primero el que falla y después el resumen comercial. Predice el estado del pedido, el resumen y la cola de notificaciones. ¿Repetir el comando los reparará?

Solución razonada: el pedido seguirá confirmado, el resumen quedará en cero y la cola vacía, porque el dispatcher detiene su recorrido al primer error. Repetir el comando no registrará un evento nuevo. El cambio de orden altera los efectos parciales, pero no elimina la ausencia de una entrega pendiente persistente. Añade aserciones para comprobar esa predicción.

3. Hacer idempotente un efecto persistido

Un consumidor recibe dos veces el mismo evento y suma 2500 centavos en cada recepción. Propón cómo impedir la segunda suma. Luego considera que actualizar el resumen funciona, pero guardar la marca de procesado falla.

Solución razonada: utiliza una clave única por consumidor y evento y guarda tanto la marca como la actualización del resumen en una sola transacción local. Si la segunda escritura falla, esa transacción debe revertir también la primera. En una reentrega, la existencia de la clave permite no repetir un efecto ya confirmado. Si el efecto es externo, esta solución local no basta: necesitas examinar el contrato e idempotencia de ese proveedor.

Qué revisar antes de incorporar eventos a tu modelo

Un buen evento dice con precisión qué ocurrió, conserva los datos necesarios y no obliga al agregado a conocer a sus consumidores. Una buena implementación deja visible cuándo se confirma el estado, quién reacciona y qué pasa cuando una reacción falla.

Empieza por esos acuerdos y por pruebas que los puedan refutar. Si basta una colaboración local y explícita, mantenla simple. Cuando la reacción deba sobrevivir a una caída, incorpora persistencia de pendientes, recuperación e idempotencia según el efecto real. El nombre OrderConfirmed aporta lenguaje; la fiabilidad depende del flujo completo que construyes alrededor.

Cargando tus opciones de aprendizaje…

Cargando conversación…

Sigue aprendiendo