Salvador Llaberia · Software Architect ← Todos los artículos

El Command Bus (mediator entre comando y handler)

Portada del artículo El Command Bus (mediator entre comando y handler)

En el artículo anterior empaquetamos los parámetros de un caso de uso en un objeto Command y dejamos la entrada a la aplicación con una forma única. Pero nos quedó una pieza suelta, y es justo la que toca hoy.

El comando dice qué hay que hacer. Alguien tiene que hacerlo. Y hoy, ese alguien lo conoce perfectamente quien envía la orden. Cada controlador, cada consumidor de cola, cada comando de consola, guarda una referencia directa al servicio que ejecuta el caso de uso y lo invoca por su nombre.

final class UpdateUserController
{
    public function __construct(
        private readonly UpdateUser $updateUser,
    ) {
    }

    public function __invoke(Request $request): Response
    {
        $command = UpdateUserCommand::fromRequest($request);

        ($this->updateUser)($command);

        return new Response(status: 202);
    }
}

No está mal. El controlador está fino, el comando ya nace validado y el caso de uso hace su trabajo. Pero mira la dependencia que hay en el constructor. Este controlador sabe exactamente qué servicio ejecuta su orden, y saberlo no le aporta nada. Él no necesita conocer a UpdateUser, lo único que necesita es que alguien, el que sea, coja el comando y lo procese. El nombre de la clase, cómo se instancia, qué dependencias arrastra. Todo eso es ruido desde su punto de vista.

Mientras cada emisor conozca a su ejecutor por su nombre, cualquier cambio en el handler salpica a todos los que lo llaman. Renombra la clase, o llévala a otro namespace, y el controlador, el comando de consola y el consumidor de cola se quedan importando una clase que ya no existe. Añade una dependencia a su constructor y tendrás que actualizar todos los sitios que lo instancian.

Un bus, un mediador

La solución se llama Command Bus y no es otra cosa que el patrón Mediator de toda la vida. Los objetos dejan de hablarse directamente y se comunican a través de un intermediario. El emisor no llama al handler. Entrega el comando al bus. El bus no sabe ejecutar nada. Solo sabe encontrar al que sí sabe y pasarle el comando. Nadie conoce a nadie más de lo estrictamente necesario.

La pieza central es una interfaz de una línea.

interface CommandBus
{
    public function dispatch(Command $command): void;
}

El método se llama dispatch a propósito. El bus no ejecuta la orden, la despacha. Ejecutarla es cosa del handler, que es quien sabe lo que hay que hacer.

Y Command es una interfaz marcadora, vacía, que solo sirve para decir "soy un comando". Todos los comandos de la aplicación la implementan y con eso el bus puede tratarlos a todos por igual.

interface Command
{
}

Fíjate en el tipo de retorno. void. Un comando se entrega, no se consulta. El bus no devuelve nada porque un comando no produce una respuesta que leer, produce un efecto. Cambia el estado del sistema. Si algo sale mal, se lanza una excepción y el flujo se corta. Si todo va bien, la orden está ejecutada y no hay nada que preguntar.

Ahora mira cómo queda el controlador.

final class UpdateUserController
{
    public function __construct(
        private readonly CommandBus $bus,
    ) {
    }

    public function __invoke(Request $request): Response
    {
        $command = UpdateUserCommand::fromRequest($request);

        $this->bus->dispatch($command);

        return new Response(status: 202);
    }
}

El controlador ya no sabe qué servicio existe. Solo conoce el bus. Cualquier controlador, cualquier consumidor de cola, cualquier comando de consola, depende de la misma interfaz. El nombre del caso de uso desaparece del código de quien manda.

La implementación más simple

Un bus no es una pieza misteriosa. La versión mínima es un mapa y un par de líneas.

final class SimpleCommandBus implements CommandBus
{
    public function __construct(
        private readonly array $handlers,
    ) {
    }

    public function dispatch(Command $command): void
    {
        $handler = $this->handlers[$command::class]
            ?? throw HandlerNotFound::for($command::class);

        $handler($command);
    }
}

El mapa relaciona cada clase de comando con su handler, y dispatch busca por la clase del comando. Si no hay nadie registrado, se lanza una excepción en el mismo sitio donde se entregó el comando, en lugar de un null que alguien tendría que comprobar. La construcción del bus es puramente declarativa:

$bus = new SimpleCommandBus([
    UpdateUserCommand::class => new UpdateUser($users),
    CreateOrderCommand::class => new CreateOrder($orders),
    CancelSubscriptionCommand::class => new CancelSubscription($subscriptions),
]);

Y el handler es exactamente el caso de uso que ya teníamos. No cambia nada de él.

final class UpdateUser
{
    public function __construct(
        private readonly UserRepository $users,
    ) {
    }

    public function __invoke(UpdateUserCommand $command): void
    {
        $user = $this->users->ofIdOrFail($command->userId);

        $user->update($command->name, $command->email);

        $this->users->save($user);
    }
}

Un comando, un handler

La relación entre comando y handler es de uno a uno. Cada comando tiene exactamente un handler, y cada handler atiende exactamente un comando. No hay dos servicios que ejecuten el mismo UpdateUserCommand, ni un handler que se encargue de varios comandos.

No es una limitación arbitraria, es lo que distingue a un comando de otras cosas que viajan por el sistema. Un comando es una orden concreta: "actualiza este usuario". Si dos handlers pudieran atender la misma orden, el sistema no sabría cuál de los dos efectos es el válido, y una orden que puede producir dos efectos distintos deja de ser una orden. Cuando necesites que una misma cosa desencadene varias reacciones, eso no es un comando. Es un evento, y para los eventos hay otro bus con reglas distintas del que hablaremos más adelante.

Esa correspondencia uno a uno es también la que permite que el mapa funcione con un simple $command::class. Dado un comando, siempre hay exactamente un destino, y el bus no tiene que tomar ninguna decisión, solo mirar en el mapa.

El handler es el caso de uso

Detrás de toda esta terminología hay una idea que, en arquitectura hexagonal y en DDD, tiene un nombre más establecido. Los handlers son los casos de uso. El UpdateUser de arriba no es un componente técnico del bus, es la representación de una operación que negocio reconoce. Actualizar un usuario.

Y por eso viven donde viven. En hexagonal, los casos de uso son la capa de aplicación, esa capa fina que está entre el dominio y los adaptadores. El dominio, en el centro, contiene las reglas. Los adaptadores, en el borde, hablan con el mundo (HTTP, consola, base de datos, colas), y en medio, la capa de aplicación coordina: recibe el comando, busca la entidad por un puerto, le da la orden, persiste por otro puerto. El handler es exactamente esa coordinación, ni más ni menos.

Esto lo desplegaremos con calma cuando escriba sobre DDD y arquitectura hexagonal. De momento quédate con la idea de que, cuando montes tu primer bus, lo que registras en el mapa son casos de uso, es decir, servicios de aplicación.

El bus, hogar del middleware

En el artículo anterior vimos que un comando estandariza la entrada y permite envolver la ejecución con middleware: logging, transacciones, validación, reintentos. Lo que no dijimos es dónde vive ese middleware, y la respuesta es aquí. El bus es el único punto por el que pasan todas las órdenes, así que es el sitio natural para colgar la infraestructura transversal.

En lugar de meter el logging dentro de cada handler, envuelves el bus una vez y listo.

final class LoggingCommandBus implements CommandBus
{
    public function __construct(
        private readonly CommandBus $inner,
        private readonly LoggerInterface $logger,
    ) {
    }

    public function dispatch(Command $command): void
    {
        $this->logger->info('Handling ' . $command::class);

        $this->inner->dispatch($command);
    }
}

Es el patrón Decorador, y encaja de maravilla porque todos los comandos pasan por la misma puerta. Añadir una transacción, medir tiempos o registrar métricas es envolver el bus una vez más, sin tocar ni un handler.

La mitad de CQRS

Aquí es donde el comando conecta con algo más grande. Fíjate en una propiedad que ha ido apareciendo sola. Un comando cambia el estado y no devuelve nada. El bus de comandos está pensado para escribir.

Pero una aplicación no solo escribe. También lee: listas, informes, consultas. Y leer tiene una naturaleza opuesta. No altera nada y devuelve datos. Si le pasas una consulta a un bus de comandos que devuelve void, estás forzando una pieza cuadrada en un agujero redondo. La lectura merece su propia puerta.

interface CommandBus
{
    public function dispatch(Command $command): void;   // altera, no devuelve
}

interface QueryBus
{
    public function ask(Query $query): mixed;          // devuelve, no altera
}

Esa separación entre escribir y leer, entre comandos y consultas, es la esencia de CQRS, Command Query Responsibility Segregation. Y ya ves que no es una arquitectura que se impone desde arriba. Es el resultado natural de haberse dado cuenta de que un comando no devuelve datos y una consulta no los altera. El Command Bus se ocupa de la escritura. En el próximo artículo montaremos el Query Bus, que se ocupa de la lectura, y veremos por qué separar las dos deja de ser una opción estética para convertirse en una necesidad práctica.

Hazlo tú mismo

Llegados aquí conviene quitarle dramatismo al asunto. Montar un Command Bus es un ejercicio de una tarde. La interfaz es una línea, la implementación simple es un mapa y un par de asignaciones, y el handler es el caso de uso que ya escribías antes. No hay magia, no hay framework, no hay nada que instalar.

Lo único que tiene un poco de miga es el locator, la parte que decide qué handler se ejecuta. En la versión de arriba lo resolvimos a mano con un array, y para la mayoría de aplicaciones eso es suficiente y es lo más claro. El día que tengas decenas de casos de uso, mantener ese array a mano se vuelve pesado, y es cuando apetece que alguien lo haga por ti.

Hay dos maneras de automatizarlo. La primera es resolver el handler por convención de nombre. UpdateUserCommand se convierte en UpdateUserHandler con un simple str_replace, y lo que se construye se busca en el contenedor de dependencias.

final class ContainerCommandBus implements CommandBus
{
    public function __construct(
        private readonly ContainerInterface $container,
    ) {
    }

    public function dispatch(Command $command): void
    {
        $handlerClass = str_replace('Command', 'Handler', $command::class);

        ($this->container->get($handlerClass))($command);
    }
}

La segunda es mantener un mapa explícito de comando a clase de handler, y dejar que el contenedor se encargue de instanciarla con sus dependencias. Perdemos la convención pero ganamos la libertad de que un handler se llame como quieras y viva donde quieras.

El locator es lo único que tiene un poco de trabajo, y aun así es un trabajo acotado. Todo lo demás es un mapa y una llamada. No necesitas una librería para empezar, y de hecho escribir tu primer bus a mano es la mejor forma de entender qué hace una librería de verdad por dentro.

Buses ya hechos

Cuando quieras algo con más funcionalidades, hay opciones maduras y probadas. En PHP, Tactician es la referencia clásica de Command Bus. Pequeña, con un pipeline de middleware de serie y exactamente con la forma que hemos construido aquí. Symfony Messenger va más allá y añade colas, reintentos y transporte asíncrono de serie, ideal cuando el comando debe salir del proceso y ejecutarse en segundo plano. Y SimpleBus, de Matthias Noback, es otra alternativa ligera con la misma filosofía de comandos, consultas y eventos por buses separados.

Que existan estas librerías no invalida lo anterior, lo refuerza. Todas son, en el fondo, un mapa, un locator y un pipeline de middleware. Haber montado el tuyo a mano te deja en el sitio exacto desde el que estas herramientas se entienden como lo que son y no como cajas negras.

El Command Bus es el paso lógico que sigue al patrón Command. Ayer empaquetamos la orden, hoy la entregamos a un mediador que no sabe quién la ejecuta, mañana, con el Query Bus, separaremos las dos mitades de la aplicación y CQRS dejará de ser un acrónimo para convertirse en la forma en la que ya estás trabajando.