ecosystem-wikicómo extender los templates sin romper sus convenciones
‹ todas las recipes

Un aviso que no se puede perder

template-api
último cambio 2026-09-25

Conectar con notification-api termina en requestWithRetry(): dos intentos y, si los dos fallan, se lo traga y lo registra. Esta página es sobre cuándo eso no basta.

Dónde está el agujero

El stack de notificaciones es fiable a partir de la cola. Una vez el trabajo entra en BullMQ hay tres intentos, backoff, y un log que dice en qué acabó — eso lo cubre la vida de un log.

El tramo que no cubre nadie es el de antes:

tu use case ──HTTP──▶ notification-api ──▶ cola ──▶ worker ──▶ proveedor
            └─ aquí ─┘                  └──── cubierto por reintentos ────┘

Si esa llamada HTTP falla —el servicio caído, un timeout, la red— y tú ya has commiteado el cambio de estado que la motivaba, el aviso no existe en ninguna parte. No hay trabajo que reintentar porque nunca llegó a encolarse.

stack-viaje-email.md lo dice desde el otro lado: encolado no es entregado. Esto es el escalón anterior: llamado no es encolado.

Cuándo importa y cuándo no

La mayoría de los avisos no necesitan esto, y por eso el patrón por defecto es el best-effort:

El aviso Si se pierde Qué usar
“Tienes una cita mañana” Se pierde uno, y hay más recordatorios detrás requestWithRetry()
Aviso de nuevo login Molesto, no roto requestWithRetry()
Enlace de activación de cuenta El usuario no puede entrar, y no hay reintento humano Outbox
Restablecer contraseña Lo mismo: sin el correo, el flujo queda muerto Outbox

El criterio no es la importancia del mensaje, es si existe otro camino para que el usuario acabe recibiéndolo. Un recordatorio perdido se repite solo; un enlace de activación perdido deja una cuenta que nadie puede usar.

El patrón outbox

La idea es no depender de que la llamada HTTP salga bien en ese instante. En lugar de llamar al servicio dentro del use case, escribes la intención en tu propia base de datos, en la misma transacción que el cambio de estado:

await this.prisma.$transaction(async (tx) => {
  const user = await tx.user.create({ data: { ... } });

  // Misma transacción: o se guardan los dos, o ninguno.
  await tx.notificationOutbox.create({
    data: {
      code: 'account-activation',
      recipient: user.email,
      variables: { activationUrl },
    },
  });
});

Y un proceso aparte lee las filas pendientes y las entrega, con reintentos, marcándolas cuando notification-api acepta el trabajo.

Lo que compra es atomicidad: ya no puede pasar que el usuario exista y el aviso no. O falla la transacción entera y no hay usuario, o hay usuario y hay fila pendiente. Que la entrega tarde un minuto más es aceptable; que no ocurra nunca, no.

Lo que esto no es

No es una segunda cola. La cola sigue estando en notification-api y el outbox no la sustituye: lo único que hace es garantizar que el trabajo llegue a entrar en ella. Son dos tramos distintos del mismo camino, cada uno con su propio mecanismo de reintento.

Tampoco está implementado hoy en el template. Es el patrón a aplicar cuando aparezca el primer aviso de la columna “Outbox” de la tabla de arriba; hasta entonces, requestWithRetry() es la respuesta correcta.