Skip to content

ADR-008: Patrón de Job para Notificaciones Email Transaccionales

Fecha: 2026-03-13 Estado: Aceptado Deciders: Architecture Team, Backend Team

Contexto y Problema

El módulo Membresia necesita enviar comprobantes en PDF por email a los socios al finalizar la facturación por lotes. Esto introduce tres problemas arquitectónicos que no estaban cubiertos por el background-jobs core existente:

  1. Acoplamiento transaccional: el dispatch del evento FacturacionLoteProcesadaEvent ocurría dentro de la ventana beginTransaction / commit en BatchComprobanteRegistrationService. El listener encolaba un job via JobDispatcher, que escribe en DB_INI. Escribir en una base de datos externa dentro de una transacción de tenant crea acoplamiento cross-database y riesgo de leer datos no commiteados desde el worker.

  2. Acceso a configuración SMTP: MailerService necesita credenciales SMTP por tenant. El acceso directo via DBAL requeriría conocer el search_path activo, lo cual duplica lógica ya encapsulada en el modelo Config.

  3. Semántica de estado del job: cuando SMTP no está configurado, el job podía completar con enviados=0, omitidos=N y un estado COMPLETED. Esto oculta un problema operativo que requiere acción del operador.

Decisiones

1. Dispatch del evento relocado al Orchestrator, post-commit

EventDispatcher::dispatch() se mueve de BatchComprobanteRegistrationService al BatchInvoicingOrchestrator, ejecutándose después de $this->connections->commit().

Alternativas rechazadas:

  • Mantener el dispatch en RegistrationService y hacer que el listener tolere transacciones abiertas. Rechazado: el listener escribe en DB_INI via JobDispatcher, creando acoplamiento cross-database dentro de una transacción de tenant. El worker podría leer datos no commiteados.
  • Implementar un mecanismo de deferred-dispatch que flush post-commit en EventDispatcher. Rechazado: sobre-ingeniería para el caso de uso actual; agrega complejidad al core de infraestructura.

Consecuencia: BatchComprobanteRegistrationService pierde la dependencia de EventDispatcher. El orchestrator la gana. El evento lleva datos enriquecidos (enviarEmail, gruposExitosos, schema, db, prueba) para evitar re-queries en el listener.

Regla derivada: ningún listener que encole jobs via JobDispatcher debe registrarse en un EventDispatcher que pueda ser invocado dentro de una transacción de tenant.


2. MailerService inyecta el modelo Config para leer claves SMTP de data_config

MailerService recibe el modelo Config via constructor y llama Config::getByPrefix('mail.smtp') para obtener las credenciales SMTP del tenant activo. No usa DBAL directamente.

Alternativas rechazadas:

  • Acceso directo a data_config via DBAL con search_path hardcodeado. Rechazado: duplica la lógica de resolución de tenant ya encapsulada en Config; el search_path correcto ya está configurado por el bootstrap CLI antes de que el handler ejecute.
  • Leer credenciales desde variables de entorno. Rechazado: las credenciales SMTP son por-tenant (cada sucursal puede tener su propio servidor de correo); una variable de entorno global no puede expresar esta multiplicidad.

Consecuencia: el worker CLI ya configura search_path vía ConnectionManager::setSchemaContext($schema) antes de ejecutar el handler (patrón establecido en ADR-006). MailerService se beneficia de este contexto sin necesidad de configuración adicional.


3. El job falla (FAILED) cuando SMTP no está configurado y hay grupos a procesar

Cuando MailerService::sendWithAttachment() lanza SmtpNotConfiguredException, el handler re-lanza la excepción en lugar de capturarla y retornar enviados=0, omitidos=N. JobExecutor marca el job como FAILED.

Alternativas rechazadas:

  • Capturar SmtpNotConfiguredException, registrar todos los grupos como omitidos y retornar COMPLETED. Rechazado: el operador verifica el estado del job, no los sub-campos del resultado. Un job COMPLETED con 0 emails enviados es indistinguible de un run sin socios a notificar, ocultando un problema de configuración que requiere acción.
  • Introducir un nuevo estado COMPLETED_WITH_WARNINGS. Rechazado: sobre-ingeniería del modelo de estados para un único caso de borde; FAILED comunica la urgencia correcta.

Consecuencia: FAILED es el estado terminal correcto para SMTP no configurado. No se aplican reintentos automáticos (la excepción no es transitoria). El operador debe configurar SMTP y re-encolar manualmente. El caso borde de ordcon_ids vacío (nada a enviar) sigue completando con COMPLETED sin error.


4. Handler placement: Modules/Membresia/Application/Handlers/

EmailNotificationLoteJobHandler se ubica en Modules/Membresia/Application/Handlers/, junto a otros handlers del módulo Membresia.

Alternativas rechazadas:

  • Shared/Handlers/. Rechazado: los handlers son adaptadores entre la infraestructura de jobs y servicios de dominio específicos. email_notification_lote consume payloads con forma de dominio Membresia y no tiene utilidad fuera de ese módulo.
  • Implementarlo como Application Service (sin implementar JobHandlerInterface). Rechazado: el sistema de jobs requiere la interfaz; agregar una capa de indirección adicional no aporta valor.

Consecuencia: sigue el Wrapper Pattern establecido (ADR-004). El handler es un adaptador delgado entre la infraestructura y los servicios de dominio (InformesHttpClient, MailerService, SystemJwtService).


5. Self-dispatch reemplaza el cron-scheduler de reintentos

JobExecutor auto-despacha el reintento via nohup bash -c 'sleep {delay} && php background-worker.php {jobId} {schema} {db}' cuando el job falla con una excepción transitoria. El retry-scheduler.php planificado originalmente fue eliminado.

bin/worker.php mantiene un barrido de seguridad cada 5 minutos para capturar jobs que hayan escapado al self-dispatch (por ejemplo, si el proceso nohup fue terminado por el OS).

Alternativas rechazadas:

  • Cron-based retry scheduler (retry-scheduler.php con polling cada N minutos). Rechazado: introduce latencia hasta el próximo tick del cron, requiere configuración de cron en el servidor, y genera un ciclo de polling que la mayoría de las veces no encuentra nada.
  • Delayed messages via RabbitMQ/Redis. Rechazado: introduce dependencias externas no presentes en el proyecto; sobre-ingeniería para el volumen actual.

Consecuencia: reintentos ocurren con delay configurable sin dependencia de schedulers externos. El barrido del bin/worker.php actúa como defensa en profundidad, no como mecanismo primario.

Consecuencias Generales

Positivas

  • ✅ Cero acoplamiento cross-database dentro de transacciones de tenant
  • MailerService es multi-tenant por diseño (hereda search_path del contexto de ejecución)
  • ✅ SMTP no configurado es visible operativamente (job FAILED, no COMPLETED con 0 emails)
  • ✅ Reintentos sin cron ni dependencias externas
  • ✅ Handler cohesionado con su dominio (Membresia)

Negativas

  • ❌ El orchestrator asume más responsabilidades (event dispatch + construcción del payload enriquecido)
  • SmtpNotConfiguredException requiere re-encolar manualmente; no hay retry automático para errores de configuración

Referencias