Saltar a contenido

Dispatch Multicanal

GexCom implementa despacho de notificaciones via el Strategy Pattern: cada canal tiene su propio dispatcher que implementa INotificacionDispatcher.

Arquitectura de Dispatch

graph TB CN[CrearNotificacionUseCase] -->|publica| EB subgraph Events["EventBus (in-process Observer)"] EB[EventBus] -->|NotificacionCreadaEvent| H[DispatchOnNotificacionCreada] end H --> UC[DispatchNotificacionUseCase] UC --> REG[DispatcherRegistry\ninfrastructure/notifications/] REG --> |canal=EMAIL| ED[EmailDispatcher\nSMTP / smtplib] REG --> |canal=WHATSAPP| WD[WhatsAppDispatcher\nMeta API / urllib] REG --> |canal desconocido| ERR[Failure: dispatcher no encontrado] UC --> UOW[UoW commit] UOW --> |exito| UPD1[update ENVIADA + audit] UOW --> |fallo| UPD2[update FALLIDA + audit] subgraph Worker["DispatchWorker (Background — retry)"] Q[asyncio.Queue\nDispatchJob items] L[Loop asincrono\nmax_retries=3] Q --> L L --> |reintento| Q end ED -.->|alternativo| Q WD -.->|alternativo| Q

INotificacionDispatcher Protocol

class INotificacionDispatcher(Protocol):
    @property
    def canal(self) -> CanalNotificacion: ...
    def dispatch(self, notificacion: Notificacion) -> Result[str, str]: ...

Ambos dispatchers implementan este Protocol (duck typing, sin herencia).

DispatchJob (frozen dataclass)

@dataclass(frozen=True)
class DispatchJob:
    notificacion: Notificacion
    max_retries: int = 3
    attempts: int = 0

    def with_attempt(self) -> DispatchJob:
        return DispatchJob(
            notificacion=self.notificacion,
            max_retries=self.max_retries,
            attempts=self.attempts + 1,
        )

Inmutable — cada reintento crea una nueva instancia con with_attempt().

Canales Soportados

Canal Dispatcher Implementacion Estado
EMAIL EmailDispatcher smtplib + TLS Funcional
WHATSAPP WhatsAppDispatcher Meta Graph API Funcional
PERSONAL — RegistrarGestionManualUseCase Funcional
TELEFONO — RegistrarGestionManualUseCase Funcional
CORREO_POSTAL — RegistrarGestionManualUseCase Funcional

Politica de Reintentos

  1. DispatchWorker consume jobs de asyncio.Queue
  2. Llama a dispatcher.send(notificacion)
  3. Si falla y attempts < max_retries: re-encola con job.with_attempt()
  4. Si falla y attempts >= max_retries: actualiza estado a FALLIDA + audit
  5. Si exito: actualiza estado a ENVIADA + registra fecha_envio + audit

Domain Events

El EventBus in-process conecta CrearNotificacionUseCase con el despacho automatico sin acoplar capas:

# CrearNotificacionUseCase publica tras commit exitoso
event_bus.publish(NotificacionCreadaEvent(
    event_id=uuid.uuid4(),
    occurred_at=datetime.now(UTC),
    notificacion_id=saved.id,
    canal=saved.canal,
    created_by=saved.created_by,
))

# ServiceContainer suscribe el handler (si hay dispatcher configurado)
event_bus.subscribe(NotificacionCreadaEvent, DispatchOnNotificacionCreada(dispatch_uc))

Estado P04

EventBus activo desde P04-D1. El wiring es condicional: solo se suscribe DispatchOnNotificacionCreada cuando dispatcher_registry esta configurado en el ServiceContainer.

Nota de dominio

El handler llama dispatch_notificacion.execute() que requiere estado EN_PROCESO. Las notificaciones se crean en PENDIENTE. La auto-transicion PENDIENTE→EN_PROCESO es una decision de producto pendiente.