Arquitectura basada en eventos: patrones y trampas
Los sistemas por eventos desacoplan productores y consumidores con mensajería asíncrona, pero consistencia, orden e idempotencia añaden complejidad.

En una arquitectura solicitud-respuesta, el servicio A llama al servicio B y espera una respuesta. Esto crea un acoplamiento fuerte: A necesita conocer la dirección de B, el contrato de su API y su disponibilidad. La arquitectura dirigida por eventos invierte esto: A publica un evento ("order created") en un broker, y cualquier número de consumidores reacciona de forma independiente. El productor no sabe ni le importa quién está escuchando.
Eventos vs. Comandos
Los eventos describen algo que ocurrió. Los comandos solicitan que ocurra algo. La distinción importa a la hora de diseñar tu sistema.
// Events: past tense, factual, immutable
interface OrderCreatedEvent {
type: "order.created";
timestamp: string;
data: {
orderId: string;
userId: string;
items: Array<{ productId: string; quantity: number; price: number }>;
totalAmount: number;
};
}
// Commands: imperative, directed at a specific handler
interface SendEmailCommand {
type: "send.email";
data: {
to: string;
template: string;
variables: Record<string, string>;
};
}
// ❌ Mixing events and commands
// "OrderCreated" event that also tells the email service what to do
interface BadEvent {
type: "order.created";
data: {
orderId: string;
emailTo: string; // Why does the order event know about emails?
emailTemplate: "receipt"; // This couples the producer to the consumer
};
}
// ✅ Clean separation — event carries facts, consumers decide what to do
// Order service publishes: OrderCreatedEvent
// Email service listens, looks up user email, sends receipt
// Inventory service listens, decrements stock
// Analytics service listens, records conversionImplementación básica de un bus de eventos
Un bus de eventos simple en proceso demuestra el patrón antes de introducir un broker de mensajes.
type EventHandler<T = unknown> = (event: T) => Promise<void>;
class EventBus {
private handlers = new Map<string, EventHandler[]>();
on<T>(eventType: string, handler: EventHandler<T>): void {
const existing = this.handlers.get(eventType) ?? [];
existing.push(handler as EventHandler);
this.handlers.set(eventType, existing);
}
async emit<T extends { type: string }>(event: T): Promise<void> {
const handlers = this.handlers.get(event.type) ?? [];
// Execute all handlers concurrently
const results = await Promise.allSettled(
handlers.map(handler => handler(event))
);
// Log failures without blocking the producer
results.forEach((result, index) => {
if (result.status === "rejected") {
console.error(`Handler ${index} for ${event.type} failed:`, result.reason);
}
});
}
}
// Usage
const bus = new EventBus();
bus.on<OrderCreatedEvent>("order.created", async (event) => {
await sendReceiptEmail(event.data.userId, event.data.orderId);
});
bus.on<OrderCreatedEvent>("order.created", async (event) => {
await decrementInventory(event.data.items);
});
bus.on<OrderCreatedEvent>("order.created", async (event) => {
await recordAnalytics("conversion", event.data);
});Manejo de la consistencia eventual
En sistemas basados en eventos, los datos son eventualmente consistentes: el servicio de correo procesa el evento segundos después de que se crea el pedido. Esto exige un diseño cuidadoso de la UI y la API.
// ❌ Assuming immediate consistency
app.post("/orders", async (req, res) => {
const order = await createOrder(req.body);
await eventBus.emit({ type: "order.created", data: order });
// Problem: client immediately queries order status
// but the inventory service hasn't processed the event yet
res.json({ orderId: order.id, status: "confirmed" });
});
// ✅ Acknowledging async processing
app.post("/orders", async (req, res) => {
const order = await createOrder(req.body);
await eventBus.emit({ type: "order.created", data: order });
// Return 202 Accepted — processing is asynchronous
res.status(202).json({
orderId: order.id,
status: "processing",
statusUrl: `/orders/${order.id}/status`, // Client can poll for updates
});
});
// Status endpoint reflects the actual processed state
app.get("/orders/:id/status", async (req, res) => {
const order = await getOrder(req.params.id);
res.json({
orderId: order.id,
status: order.status, // "processing" | "confirmed" | "failed"
inventoryReserved: order.inventoryReserved,
paymentCaptured: order.paymentCaptured,
updatedAt: order.updatedAt,
});
});Manejadores de eventos idempotentes
Los eventos pueden entregarse más de una vez (reintentos del broker, problemas de red). Los manejadores deben ser idempotentes: procesar el mismo evento dos veces debe tener el mismo efecto que procesarlo una vez.
// ❌ Non-idempotent handler — double-charges the customer
async function handlePaymentEvent(event: OrderCreatedEvent) {
await chargeCustomer(event.data.userId, event.data.totalAmount);
// If this event is delivered twice, the customer is charged twice
}
// ✅ Idempotent handler — uses event ID for deduplication
async function handlePaymentEvent(event: OrderCreatedEvent & { id: string }) {
// Check if we've already processed this event
const processed = await db.query(
"SELECT 1 FROM processed_events WHERE event_id = $1",
[event.id]
);
if (processed.rows.length > 0) {
console.log(`Event ${event.id} already processed, skipping`);
return;
}
// Process within a transaction
await db.transaction(async (tx) => {
await tx.query(
"INSERT INTO processed_events (event_id, processed_at) VALUES ($1, NOW())",
[event.id]
);
await tx.query(
"INSERT INTO payments (order_id, amount, status) VALUES ($1, $2, 'captured')",
[event.data.orderId, event.data.totalAmount]
);
});
}Colas de mensajes fallidos
Cuando un manejador de eventos falla repetidamente, el evento no debería bloquear la cola indefinidamente. Las colas de mensajes fallidos capturan los eventos con error para su investigación.
async function processWithRetry(
event: unknown,
handler: EventHandler,
maxRetries: number = 3
): Promise<void> {
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await handler(event);
return;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.warn(`Attempt ${attempt}/${maxRetries} failed:`, lastError.message);
if (attempt < maxRetries) {
// Exponential backoff
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
);
}
}
}
// All retries exhausted — send to dead letter queue
await deadLetterQueue.push({
originalEvent: event,
error: lastError?.message,
failedAt: new Date().toISOString(),
attempts: maxRetries,
});
}Conclusiones clave
- Los eventos describen hechos, los comandos solicitan acciones — mantén los eventos como datos puros sobre lo que ocurrió
- Los productores no conocen a los consumidores — este desacoplamiento permite escalar y desplegar de forma independiente
- Diseña para la consistencia eventual — devuelve 202 Accepted y expone endpoints de estado para operaciones asíncronas
- Cada manejador debe ser idempotente — deduplica por ID de evento porque la entrega al menos una vez es la norma
- Usa colas de mensajes fallidos — los eventos con error necesitan investigación, no bucles infinitos de reintento
- La arquitectura basada en eventos añade complejidad — no la adoptes para flujos de trabajo síncronos simples donde el modelo solicitud-respuesta funciona bien


