7 min read
Tu servicio de facturación consume eventos OrderCreated de una cola. Todo marcha bien hasta que llega un mensaje con un formato que nadie esperaba: un campo faltante, un JSON mal formado, un dato que rompe una validación.
El consumidor lanza una excepción, el broker lo vuelve a entregar, falla de nuevo, y así indefinidamente.
Ese único mensaje, al que se le suele llamar mensaje envenenado (poison message), consume CPU, llena los logs, y dependiendo de tu configuración puede bloquear todos los mensajes que vienen detrás.
La solución estándar es la Dead Letter Queue (DLQ — Cola de Mensajes Muertos): una cola aparte donde se envían los mensajes que fallaron demasiadas veces, para que el resto del flujo siga funcionando y alguien pueda revisarlos con calma.
En posts anteriores hablamos de Retry, Saga y Outbox; este es el patrón que cierra el círculo cuando el reintento ya no tiene sentido.
¿Qué es un Dead Letter Queue?
Es una cola secundaria asociada a la cola principal. Cuando un mensaje falla después de un número máximo de intentos, en lugar de descartarlo o seguir reintentándolo, se mueve a la DLQ junto con información del fallo: qué error lo causó, cuántos intentos tuvo y el mensaje original sin modificar.
El DLQ no es un basurero. Es una herramienta de diagnóstico: cada mensaje ahí es un problema que alguien tiene que entender y resolver, ya sea corrigiendo el dato, arreglando un bug del consumidor o descartando el mensaje de forma consciente.
Esto se apoya en una distinción que ya vimos en el post de Retry: los errores transitorios (un timeout, un servicio caído un momento) se resuelven reintentando; los errores permanentes (un payload inválido) nunca se van a resolver reintentando, así que después de unos pocos intentos van al DLQ.
El diagrama

Guía paso a paso
Paso 1 — Definir el máximo de intentos
Un número bajo (3 a 5) con espera creciente entre intentos suele ser suficiente para absorber los fallos transitorios.
Paso 2 — Crear la cola de mensajes muertos
Una cola aparte, con un nombre claro (order-events.DLQ), que sea fácil de encontrar y de monitorear.
Paso 3 — Mover el mensaje cuando se agoten los intentos
Enviá a la DLQ el mensaje original sin modificar, y agregá metadatos: el último error, el número de intentos y la fecha del fallo.
Paso 4 — Alertar sobre la DLQ
Una DLQ que nadie mira es un lugar donde los problemas se olvidan en silencio. Configurá una alerta cuando la cantidad de mensajes supere cero, o un umbral razonable para tu caso.
Paso 5 — Definir cómo se reprocesan
Una vez corregido el problema, los mensajes se reinyectan a la cola principal. Esto requiere que tu consumidor sea idempotente, porque un mensaje podría procesarse dos veces.
El código
Simulamos un consumidor de eventos de pedidos con cuatro mensajes: uno válido, uno con un fallo transitorio, uno envenenado y uno válido al final. Lo que importa observar es que el mensaje envenenado no bloquea al cuarto.
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
// --- El mensaje con su estado de procesamiento ---
@Getter
@Setter
@AllArgsConstructor
class Message {
private final String id;
private String payload;
private int attempts;
private String lastError;
}
// --- La lógica de negocio que procesa cada evento ---
class OrderEventProcessor {
public void process(Message message) {
String payload = message.getPayload();
// Error permanente: reintentar nunca lo va a arreglar
if (!payload.startsWith("{")) {
throw new IllegalArgumentException("Payload no es JSON válido");
}
// Error transitorio: se resuelve solo después de un par de intentos
if (payload.contains("TRANSIENT") && message.getAttempts() < 3) {
throw new RuntimeException("Timeout temporal al consultar inventario");
}
System.out.println(" ✔ Procesado " + message.getId() + " → " + payload);
}
}
// --- El consumidor: reintenta y, si no hay caso, envía a la DLQ ---
@AllArgsConstructor
class OrderConsumer {
private static final int MAX_ATTEMPTS = 3;
private final OrderEventProcessor processor;
private final BlockingQueue<Message> deadLetterQueue;
public void consume(Message message) throws InterruptedException {
while (message.getAttempts() < MAX_ATTEMPTS) {
message.setAttempts(message.getAttempts() + 1);
try {
processor.process(message);
return; // éxito
} catch (Exception e) {
message.setLastError(e.getMessage());
System.out.println(" ✖ " + message.getId() + " falló (intento "
+ message.getAttempts() + "/" + MAX_ATTEMPTS + "): " + e.getMessage());
Thread.sleep(100L * message.getAttempts()); // espera creciente
}
}
deadLetterQueue.add(message);
System.out.println(" ☠ " + message.getId() + " enviado a la DLQ tras " + MAX_ATTEMPTS + " intentos");
}
}
// --- Main ---
public class DeadLetterQueueDemo {
public static void main(String[] args) throws Exception {
BlockingQueue<Message> mainQueue = new LinkedBlockingQueue<>();
BlockingQueue<Message> deadLetterQueue = new LinkedBlockingQueue<>();
OrderConsumer consumer = new OrderConsumer(new OrderEventProcessor(), deadLetterQueue);
mainQueue.add(new Message("MSG-1", "{\"orderId\":\"ORD-1\"}", 0, null));
mainQueue.add(new Message("MSG-2", "{\"orderId\":\"ORD-2\",\"note\":\"TRANSIENT\"}", 0, null));
mainQueue.add(new Message("MSG-3", "orderId=ORD-3;;;", 0, null)); // mensaje envenenado
mainQueue.add(new Message("MSG-4", "{\"orderId\":\"ORD-4\"}", 0, null));
System.out.println("=== Consumidor procesando la cola principal ===");
Message message;
while ((message = mainQueue.poll()) != null) {
System.out.println("\n→ Mensaje " + message.getId());
consumer.consume(message);
}
System.out.println("\n=== Monitoreo: mensajes en la DLQ = " + deadLetterQueue.size() + " ===");
for (Message dead : deadLetterQueue) {
System.out.println(" ALERTA → " + dead.getId() + " | último error: " + dead.getLastError());
}
System.out.println("\n=== Una persona corrige el payload y reprocesa ===");
Message recovered = deadLetterQueue.poll();
recovered.setPayload("{\"orderId\":\"ORD-3\"}");
recovered.setAttempts(0);
consumer.consume(recovered);
}
}
Output esperado:
=== Consumidor procesando la cola principal ===
→ Mensaje MSG-1
✔ Procesado MSG-1 → {"orderId":"ORD-1"}
→ Mensaje MSG-2
✖ MSG-2 falló (intento 1/3): Timeout temporal al consultar inventario
✖ MSG-2 falló (intento 2/3): Timeout temporal al consultar inventario
✔ Procesado MSG-2 → {"orderId":"ORD-2","note":"TRANSIENT"}
→ Mensaje MSG-3
✖ MSG-3 falló (intento 1/3): Payload no es JSON válido
✖ MSG-3 falló (intento 2/3): Payload no es JSON válido
✖ MSG-3 falló (intento 3/3): Payload no es JSON válido
☠ MSG-3 enviado a la DLQ tras 3 intentos
→ Mensaje MSG-4
✔ Procesado MSG-4 → {"orderId":"ORD-4"}
=== Monitoreo: mensajes en la DLQ = 1 ===
ALERTA → MSG-3 | último error: Payload no es JSON válido
=== Una persona corrige el payload y reprocesa ===
✔ Procesado MSG-3 → {"orderId":"ORD-3"}
MSG-2 se recuperó solo gracias al reintento.
MSG-3 agotó sus intentos y terminó en la DLQ, pero MSG-4 se procesó con normalidad.
Sin la DLQ, MSG-3 habría seguido atascado, y dependiendo del broker, MSG-4 habría esperado detrás de él.
Para tener en cuenta en producción
- Cada broker lo resuelve distinto: RabbitMQ tiene el concepto nativo de
dead-letter-exchange; Amazon SQS (Simple Queue Service) permite configurar unredrivePolicycon un máximo de recepciones; en Kafka no existe una DLQ nativa, y se implementa con un topic aparte (frameworks como Spring Kafka ofrecenDeadLetterPublishingRecovererpara hacerlo). - Guardá el contexto del fallo, no solo el mensaje: el error, el stack trace, el número de intentos y la hora. Cuando alguien abra la DLQ a las 7am, esa información es la diferencia entre resolverlo en cinco minutos o en una hora.
- La DLQ necesita un dueño: definí quién la revisa, con qué frecuencia y qué hace con cada mensaje (corregir y reprocesar, o descartar con una razón documentada). Una DLQ sin dueño es un cementerio.
- Clasificá el error antes de decidir: un error permanente, como un payload inválido, puede ir directo a la DLQ sin gastar los reintentos. Reservá los reintentos con espera creciente para los errores transitorios, como vimos en el post de Retry.
- El consumidor debe ser idempotente: al reprocesar mensajes (o ante reentregas del broker) el mismo evento puede llegar más de una vez. Las Idempotency Keys de un post anterior son la herramienta para eso.
Conclusión
Una Dead Letter Queue no arregla los mensajes malos, pero impide que un solo mensaje malo detenga a todos los demás, y deja una evidencia ordenada de qué falló.
En sistemas que procesan eventos de forma asíncrona, es una de las decisiones de diseño más baratas y con mejor retorno que podés tomar.
Recordá suscribirte aquí para recibir los próximos posts directamente en tu correo.
Referencias:
Enterprise Integration Patterns — Gregor Hohpe, Bobby Woolf (patrón Dead Letter Channel)
Microservices Patterns — Chris Richardson