Appearance
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:
Acoplamiento transaccional: el dispatch del evento
FacturacionLoteProcesadaEventocurría dentro de la ventanabeginTransaction / commitenBatchComprobanteRegistrationService. El listener encolaba un job viaJobDispatcher, que escribe enDB_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.Acceso a configuración SMTP:
MailerServicenecesita credenciales SMTP por tenant. El acceso directo via DBAL requeriría conocer elsearch_pathactivo, lo cual duplica lógica ya encapsulada en el modeloConfig.Semántica de estado del job: cuando SMTP no está configurado, el job podía completar con
enviados=0, omitidos=Ny un estadoCOMPLETED. 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
RegistrationServicey hacer que el listener tolere transacciones abiertas. Rechazado: el listener escribe enDB_INIviaJobDispatcher, 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_configvia DBAL consearch_pathhardcodeado. Rechazado: duplica la lógica de resolución de tenant ya encapsulada enConfig; elsearch_pathcorrecto 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 comoomitidosy retornarCOMPLETED. Rechazado: el operador verifica el estado del job, no los sub-campos del resultado. Un jobCOMPLETEDcon 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;FAILEDcomunica 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_loteconsume 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.phpcon 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
- ✅
MailerServicees multi-tenant por diseño (heredasearch_pathdel contexto de ejecución) - ✅ SMTP no configurado es visible operativamente (job
FAILED, noCOMPLETEDcon 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)
- ❌
SmtpNotConfiguredExceptionrequiere re-encolar manualmente; no hay retry automático para errores de configuración