Desacoplando con eventos de aplicación

En este artículo voy a exponer una manera sencilla de desacoplar funcionalidades de nuestra aplicación cumpliendo con los comportamientos necesarios. Para ello vamos a usar eventos de aplicación.

Supongamos que estamos implementando una aplicación que se encarga de la gestión de paquetes que se reciben en una oficina, para que cuando  entre un paquete se realicen las siguientes acciones:

  1. Se guarde en base de datos la información del paquete recibido.
  2. Se notifique al destinatario del paquete (por ejemplo vía email).
  3. Se comunique a un tercer sistema que el paquete ha llegado (por ejemplo vía Rest API).

A continuación expongo las opciones de implementación

Implementación acoplada

En una primera aproximación (digamos clásica) se puede implementar un caso de uso para crear paquete (cuando llega es nuevo, por tanto se crea). Un ejemplo en Java podría quedar así:

@Service
public class CreatePackageInputPort 
			implements CreatePackageUseCase {

  private PackageRepository repository; 

  private PackageReceiverNotifier notifier;

  private ExternalSystem exSystem;

  public CreatePackageInputPort(PackageRepository repository, 
                                PackageReceiverNotifier notifier,
                                ExternalSystem exSystem) {

        this.repository = repository;
        this.notifier = notifier;
        this.exSystem = exSystem;
    }

  @Override  
  public Package create(Package package){

    //Se guarda en base de datos
    Package createdPackage = repository.create(package); 

    //Se notifica
    notifier.notify(createdPackage);

    //Se comunica a un tercer sistema
    exSystem.notify(createdPackage);
  }
}

¿Por qué digo que es acoplada? 

El acoplamiento viene de que estamos incorporando a nuestro caso de uso las dependencias del notificador y del sistema externo. Las operaciones de notificar al destinatario y al sistema externo no deberían de provocar la necesidad de esa dependencia, puesto que no son responsabilidad del caso de uso de crear paquete.

Problemática con esta implementación 

No es que esté mal el ejemplo anterior, pero sí que introduce alguna problemática que pueden complicar el mantenimiento del código. Al más puro estilo socrático, planteo las siguientes cuestiones:

- ¿Qué pasa si necesito realizar otra acción al "crear el paquete"? por ejemplo, ahora se requiere que se envíe un SMS. ¿acoplamos otra dependencia?

- ¿Qué pasa si también se requiere notificar al destinatario y comunicar con el sistema externo cuando se modifica un paquete? ¿incluimos esas dependencias (y el código) al caso de uso de "modificar paquete"?

- ¿Es responsabilidad del caso de uso "crear paquete" notificar al destinatario y al sistema externo? 

-¿Qué hacemos si nos piden que no se envíe la comunicación al sistema externo?

 

Implementación basada en eventos 

Vamos a intentar resolver la situación anterior aplicando un desarrollo dirigido por eventos.


En este caso prefiero llamarlos eventos de aplicación (o de integración) ya que se establecen a nivel de caso de uso, y no los entiendo como puros eventos de dominio.

Continuando con el ejemplo anterior me voy a apoyar en la funcionalidad de publicación y consumo de eventos que ofrece Spring. Este es un mecanismo rápido y sencillo para desacoplar nuestro código.

Veamos como quedaría la implementación del caso de uso de crear paquete:


@Service
public class CreatePackageInputPort 
			implements CreatePackageUseCase, PackageEventPublisher {

  private PackageRepository repository; 

  private ApplicationEventPublisher applicationEventPublisher;

  public CreatePackageInputPort(PackageRepository repository,
              	ApplicationEventPublisher applicationEventPublisher) {
                             
        this.repository = repository;
        this.applicationEventPublisher = applicationEventPublisher;
    }

  @Override  
  public Package create(Package package){

    //Se guarda en base de datos
    Package createdPackage = repository.create(package); 

    //Publicamos el evento
    publish(createdPackage);
   
  }

  @Override  
  public void publish(Package package){
    applicationEventPublisher.publishEvent(new OnCreatePackageEvent(package);  
  }
}

Se puede observar que transformamos nuestro caso de uso en un Publicador de eventos:

  • Se implementa PackageEventPublisher para garantizar que es un publicador de eventos de paquete. Esta interfaz es propia y obligará a implementar el método publish.
  • Se inyecta ApplicationEventPublisher de Spring, lo que nos facilitará la publicación de eventos en el contexto.
  • Se han eliminado las dependencias de PackageReceiverNotifier y ExternalSystem.
  • El caso de uso hace única y exclusivamente lo que tiene que hacer, crear el paquete. Luego notifica el evento de creación.

Una vez está definido el publicador, necesitamos algún suscriptor (o consumidor del evento que se publique). Para ello tenemos varias alternativas, pero podría ser tan simple como tener un componente que escuche este tipo de eventos:


@Component
public class OnCreatePackageEventHandler {

  private PackageReceiverNotifier notifier;

  private ExternalSystem exSystem;

  public CreatePackageInputPort(PackageReceiverNotifier notifier,
                                ExternalSystem exSystem) {
        
      this.notifier = notifier;
      this.exSystem = exSystem;
    }

  @EventListener
  public void handle(OnCreatePackageEvent event) {
      
    //Se notifica
    notifier.notify(package);

    //Se comunica a un tercer sistema
    exSystem.notify(package);
  }
}

Se anota un método público con @EventListener, aceptando como parámetro el tipo de evento OnCreatePackageEvent. Esto hará que este componente consuma los eventos de creación de paquete, y realice las notificaciones. Ya lo sé, es susceptible de mejoras. Se podrían separar responsabilidades y tener tantos consumidores como sean necesarios, en vez de ejecutar ambos en el mismo componente. Por ejemplo:


@Component
public class PackageReceiverNotifierEventConsumer 
			implements OnCreatePackageEventConsumer{

  private PackageReceiverNotifier notifier;

  public PackageReceiverNotifierEventConsumer(PackageReceiverNotifier notifier) {        
      this.notifier = notifier;
    }

  
  @Override
  @EventListener  
  public void handle(OnCreatePackageEvent event) {
      
    //Se notifica
    notifier.notify(event.package());
  }
}
Las ventajas de esta segunda aproximación son varias, por ejemplo: se podría habilitar/deshabilitar consumidores según necesidades, además si se necesita incorporar otro consumidor, no se tiene que tocar el código existente.
 

Por defecto el consumidor del evento se invoca de manera síncrona, es decir, sobre el mismo hilo. Si necesitamos que la ejecución sea asíncrona será suficiente con anotar el método con @Async (por defecto Spring usará un SimpleAsyncTaskExecutor para ejecutar esos métodos) y habilitar el soporte asíncrono en Spring.

¿Me falta algo?

¡Ah! por supuesto el mensaje a transmitir entre publicador y consumidores, el contenido del evento. En los fragmentos de código anteriores lo he llamado OnCreatePackageEvent, y puede ser tan simple como lo siguiente:


public record OnCreatePackageEvent(Package pakage) {}

El record del evento encapsula la información necesaria para que los consumidores puedan ejecutar su tarea.


Conclusión

El uso de eventos de aplicación puede ayudarnos a desacoplar código y funcionalidad, manteniendo las responsabilidades en su sitio. Además se consigue una mayor cohesión de cada implementación.

Spring proporciona mecanismos para conseguir este objetivo, pero existen otras herramientas y técnicas que permiten alcanzarlos. Lo importante es entender las ventajas que un desarrollo dirigido por eventos puede proporcionar a nuestras aplicaciones.

Comentarios