Descomponiendo los casos de uso

¿No os ha pasado nunca que un caso de uso va creciendo y creciendo con acciones derivadas de la principal? Que si guarda un log, que si manda un e-mail, que si añade un registro en la base de datos, que si es la visita que hace mil le sumes saldo…

Al final, un caso de uso sencillo, acaba teniendo un montón de acciones y resulta engorroso de manejar y, cada vez que nos piden añadir una nueva acción, tenemos que editar una clase, añadir dependencias, cambiar los tests, etc. Un follón para añadir una pequeña funcionalidad.

Pues hoy vamos a ver un ejemplo de este tipo, que aunque todavía no es preocupante, ya nos chirría porque la clase hace mucho más de lo que su nombre explica. Y para mejorarlo, nos serviremos de los eventos de dominio y un bus de eventos.

Eventos de dominio y buses de eventos

Pero un momento, ¿qué es eso de los eventos de dominio?

Pues los eventos de dominio no son más que eventos que se van lanzando desde el dominio (la parte más interna de nuestra aplicación) cuando ocurren cosas relevantes, como por ejemplo crear un usuario, emitir una factura, o cualquier otra acción que sea relevante en nuestra aplicación.

Estos eventos se envían a un bus de eventos, que es una cola de eventos, donde se guardan para ser procesados.

De este modo, podemos avisar a otras partes del sistema que ha ocurrido algo importante para nosotros, y si cualquier otra parte de nuestra aplicación está interesada en ese evento, podrá suscribirse para hacer una acción cada vez que este evento ocurra.

Bueno, vale, a ver, ponme un ejemplo.

Concrétamente vamos a ver el típico caso de uso de crear un usuario, en el cual, además de crear el usuario propiamente, debemos guardar un log de la creación, y enviarle un e-mail de bienvenida al usuario creado.

No voy a poner aquí las implementaciones de las dependencias y demás, pero en el repositorio del ejemplo podréis ver todo el código funcionando.

Del mismo modo tampoco he usado ningún framework porque estos conceptos son válidos para cualquier framework o ausencia de él, y los ejemplos los pongo en PHP, pero es perfectamente aplicable con cualquier lenguaje.

Tendríamos algo así:

<?php

declare(strict_types=1);

namespace App\Application\CreateUser;

use App\Domain\Shared\Logger;
use App\Domain\Shared\Mailer;
use App\Domain\User\User;
use App\Domain\User\UserRepository;

class CreateUserHandler
{
    private UserRepository $userRepository;
    private Mailer $mailer;
    private Logger $logger;

    public function __construct(UserRepository $userRepository, Mailer $mailer, Logger $logger)
    {
        $this->userRepository = $userRepository;
        $this->mailer = $mailer;
        $this->logger = $logger;
    }

    public function __invoke(CreateUserCommand $command): void
    {
        $user = new User($command->getId(), $command->getEmail());
        $this->userRepository->save($user);

        $this->mailer->send(
            $user->getEmail(),
            'Bienvenido',
            'Bienvenido a nuestro sistema.'
        );

        $this->logger->save('User created with id ' . $user->getId());
    }
}

Vemos que este caso de uso empieza a tener más dependencias de las lógicas para la acción que debe hacer, además intuimos que pueden seguir añadiédose acciones, y no queremos tener que editar esta clase cuando eso ocurra.

Nuestro objetivo es dejar en el caso de uso solo la acción principal, en este caso la creación del usuario, lanzar un evento que indique que se ha creado un usuario y tener dos suscriptores para este, que cada uno de ellos se encargue de enviar el e-mail de bienvenida y guardar el log.

Así que vamos a crear un evento, que es una clase que guarda los datos necesarios, en este caso el id del usuario.

<?php

declare(strict_types=1);

namespace App\Domain\User;

use App\Domain\Event\Event;

class UserCreated implements Event
{
    private UserCreatedPayload $payload;

    public function __construct(UserCreatedPayload $payload)
    {
        $this->payload = $payload;
    }

    public function getName(): string
    {
        return 'user.created';
    }

    public function getPayload(): UserCreatedPayload
    {
        return $this->payload;
    }
}

Ahora vamos a ver cómo lanzamos este evento. La idea de los eventos de dominio, como su propio nombre indican, es que se lancen en el dominio de nuestra aplicación, en el corazón, así que vamos a preparar nuestra clase User, que es el dominio, para que vaya acumulando los eventos que se lancen durante el caso de uso, y al final del caso de uso, recogeremos estos eventos y los pasaremos al bus de eventos.

<?php

declare(strict_types=1);

namespace App\Domain\User;

class User
{
    private array $events; // array donde iremos acumulando los eventos
    private string $id;
    private string $email;

    public function __construct(string $id, string $email)
    {
        $this->id = $id;
        $this->email = $email;
        
        // guardamos el evento de usuario creado
        $this->events[] = new UserCreated(
            new UserCreatedPayload($id)
        );
    }

    public function getId(): string
    {
        return $this->id;
    }

    public function getEmail(): string
    {
        return $this->email;
    }

    // Método para coger los eventos acumulados
    public function getEvents(): array
    {
        return $this->events;
    }
}

Ahora vamos a implementar un bus de eventos. Normalmente se hace con alguna librería o componente del framework, pero nosotros vamos a implementar un bus de eventos cutre para el ejemplo, y que cada uno escoja la implementación adecuada según el entorno en el que trabaje.

Este bus de eventos simplemente va a definir un array con los subscribers que tiene para cada evento, un método para enviar los eventos y poco más.

Cuando llegue un evento, si tenemos algún subscriber para este, se ejecutará. Además recibirá las dependencias que estos suscriptores tengan por simplicidad, pese a que esto no sería escalable, por lo que si quieres utilizar eventos de dominio, será mejor que uses una implementación en condiciones, esta es puramente educativa.

<?php

declare(strict_types=1);

namespace App\Infrastructure\Event;

use App\Domain\Event\Event;
use App\Domain\Event\EventBus;
use App\Domain\Shared\Logger;
use App\Domain\Shared\Mailer;
use App\Domain\User\UserRepository;
use App\Infrastructure\Shared\FakeLogger;
use App\Infrastructure\Shared\FakeMailer;
use App\Infrastructure\User\InMemoryUserRepository;
use App\Infrastructure\User\OnUserCreatedSaveLog;
use App\Infrastructure\User\OnUserCreatedSendEmail;

class InMemoryEventBus implements EventBus
{
    private array $subscribers;

    public function __construct(UserRepository $userRepository, Mailer $mailer, Logger $logger)
    {
        $this->subscribers = [
            'user.created' => [
                new OnUserCreatedSendEmail($userRepository, $mailer),
                new OnUserCreatedSaveLog($logger),
            ],
        ];
    }

    public function dispatchAll(array $events): void
    {
        /** @var Event $event */
        foreach ($events as $event) {
            foreach ($this->subscribers[$event->getName()] as $subscriber) {
                ($subscriber)($event);
            }
        }
    }
}

Fíjate que hemos puesto dos suscriptores para el evento user.created, así que veamos cómo están implementados estos suscriptores:

Un suscriptor se encargará de enviar el e-mail de bienvenida:

<?php

declare(strict_types=1);

namespace App\Infrastructure\User;

use App\Domain\Shared\Mailer;
use App\Domain\User\UserCreated;
use App\Domain\User\UserRepository;

class OnUserCreatedSendEmail
{
    private UserRepository $userRepository;
    private Mailer $mailer;

    public function __construct(UserRepository $userRepository, Mailer $mailer)
    {
        $this->userRepository = $userRepository;
        $this->mailer = $mailer;
    }

    public function __invoke(UserCreated $event): void
    {
        $user = $this->userRepository->find($event->getPayload()->getId());

        $this->mailer->send(
            $user->getEmail(),
            'Bienvenido',
            'Bienvenido a nuestro sistema.'
        );
    }
}

Y otro suscriptor será el encargado de guardar un log:

<?php

declare(strict_types=1);

namespace App\Infrastructure\User;

use App\Domain\Shared\Logger;
use App\Domain\User\UserCreated;

class OnUserCreatedSaveLog
{
    private Logger $logger;

    public function __construct(Logger $logger)
    {
        $this->logger = $logger;
    }

    public function __invoke(UserCreated $event): void
    {
        $this->logger->save('User created with id ' . $event->getPayload()->getId());
    }
}

Estos suscriptores utilizarán los datos del payload del evento para hacer lo que deban hacer de forma independiente al caso de uso principal, pero veamos el caso de uso ahora, a ver cómo queda:

<?php

declare(strict_types=1);

namespace App\Application\CreateUserWithEvents;

use App\Domain\Event\EventBus;
use App\Domain\User\User;
use App\Domain\User\UserRepository;

class CreateUserWithEventsHandler
{
    private UserRepository $userRepository;
    private EventBus $eventBus;

    public function __construct(UserRepository $userRepository, EventBus $eventBus)
    {
        $this->userRepository = $userRepository;
        $this->eventBus = $eventBus;
    }

    public function __invoke(CreateUserWithEventsCommand $command): void
    {
        $user = new User($command->getId(), $command->getEmail());
        $this->userRepository->save($user);

        $this->eventBus->dispatchAll($user->getEvents());
    }
}

Ha quedado reducido a crear el usuario, persistirlo y enviar los eventos generados al bus de eventos. Además, como consecuencia, se han reducido las dependencias del caso de uso, y hemos ganado mucho en extensibilidad, pues ahora tenemos un sistema en el que cualquier parte de la aplicación puede reaccionar a la creación de un usuario sin afectar al resto, sin depender de nada más, simplemente se hace un nuevo suscriptor y listo.

¿Cómo implemento esto en mi proyecto

Como he comentado antes, la implementación de este bus de eventos es muy mala, requiere que se instancien las dependencias y los suscribers, es siempre síncrono, treméndamente poco mantenible, etc.

Solo he decidido implementarlo por mí mismo y sin preocupaciones para centrarnos en la parte de separar el caso de uso, ya que en cada caso y dependiendo del framework usado o la ausencia de este, las opciones serían distintas para el bus de eventos, pero el resto sería exactamente igual.

Si tu proyecto está en PHP y usas Symfony (versión 4 o posterior) te recomiendo usar el componente Symfony Messenger para implementar el bus de eventos, próximamente haré un post explicando en detalle cómo hacer esto, pues es un caso que conozco.

Si tu proyecto está en PHP, pero tienes una versión muy antigua de Symfony, o usas cualquier otro framework, puedes emplear Tactician.

Para el resto de lenguajes me consta que existen librerías adecuadas para esto, pero como no tengo experiencia en ello, no puedo deciros mucho.