Una aplicación usando Vertical Slice Architecture

por Fran Iglesias

Para entender mejor la propuesta de Vertical Slice Architecture vamos a intentar usarla en el desarrollo de una aplicación.

Para ponernos en contexto, el tema será una aplicación para gestionar el contenido de una conferencia. Básicamente, lo que sería el Call for Papers y la revisión por parte del comité organizador para aceptar o rechazar las propuestas. Esto para empezar.

De momento voy a hacer el backend, por lo que vamos a exponer una API REST. A grandes rasgos, creo que no hay diferencia en que la aplicación publique una interfaz de usuario web.

Todavía no tengo muy claras las features que va a necesitar la aplicación, pero creo que precisamente, una de las ventajas potenciales de un enfoque Vertical Slice sería precisamente no requerir de un diseño completo. En el fondo, es una aproximación ágil.

Antes de empezar he preparado un proyecto PHP/Symfony y hecho algunas pruebas de concepto. No está del todo a mi gusto, pero parece que es suficiente para ponerse a trabajar.

Poder enviar propuestas a la conferencia

La primera iteración consiste en poder enviar propuestas en respuesta al Call for Papers de la conferencia. Tener propuestas en una base de datos siempre es mejor que no tenerlas en ningún sitio. Incluso mejor que tenerlas en un formulario de Google, que suele ser lo habitual cuando se empieza.

A mí me gusta bastante definirlas las features con Gherkin, ya que puedo tener tests de aceptación con Behat o Cucumber.

Feature: Sending proposals to C4P
    As a potential speaker
    Fran wants to send a new proposal to a C4P
    So it can be reviewed

    Scenario: First time proposal
        Given Fran has a proposal with the following content:
        """
        {
            "title": "Proposal Title",
            "description": "A description or abstract of the proposal",
            "autor": "Fran Iglesias",
            "email": "fran.iglesias@example.com",
            "type": "talk",
            "sponsored": true,
            "location": "Vigo, Galicia"
        }
        """
        When He sends the proposal
        Then The proposal is acknowledged
        Then The proposal appears in the list of sent proposals

No voy a entrar en los detalles de los tests, pero a grandes rasgos puedo decir que serán de este estilo: una request HTTP a los endpoints verificando la respuesta y un posible efecto. Aquí tenemos un ejemplo sin terminar.

<?php

declare (strict_types=1);

namespace App\Tests\Behat;

use Behat\Behat\Context\Context;
use Behat\Gherkin\Node\PyStringNode;
use GuzzleHttp\Client;
use Psr\Http\Message\ResponseInterface;
use function PHPUnit\Framework\assertEquals;

class ProposalsFeatureContext implements Context
{
    private ResponseInterface $response;
    private string $payload;

    /**
     * @Given /^Fran has a proposal with the following content:$/
     */
    public function franHasAProposalWithTheFollowingContent(PyStringNode $payload): void
    {
        $this->payload = $payload->getRaw();
    }

    /**
     * @When /^He sends the proposal$/
     */
    public function heSendsTheProposal(): void
    {
        $client = new Client(
            [
                'base_uri' => 'https://localhost',
            ]
        );
        $this->response = $client->request(
            'POST',
            '/api/proposals',
            [
                'body' => $this->payload,
                'headers' => [
                    'Content-Type' => 'application/json'
                ]
            ]
        );
    }

    /**
     * @Then /^The proposal is acknowledged$/
     */
    public function theProposalIsAcknowledged(): void
    {
        assertEquals(202, $this->response->getStatusCode());
    }

    /**
     * @Then /^The proposal appears in the list of sent proposals$/
     */
    public function theProposalAppearsInTheListOfSentProposals()
    {
        throw new \Behat\Behat\Tester\Exception\PendingException();
    }
}

Tienes un slice

¿Cómo voy a organizar el código? En principio, lo que nos pide Vertical Slice Architecture es tener juntos todos los componentes necesarios para implementar la prestación.

SendProposalController
SendProposal
SendProposalHandler
SendProposalResponse

Pero como vimos en el artículo anterior, lo más probable es que la parte de enviar propuestas esté formada por varios casos de uso. Por ejemplo, como mínimo, sería bueno ofrecer prestaciones como:

  • Ver el estado de aprobación de la propuesta.
  • Ver la lista de propuestas que haya enviado.
  • Poder editar una propuesta enviada.
  • Poder retirar una propuesta.

En mi cabeza, las propuestas en tanto no se revisan y se aprueban son de su autora, que podrá gestionarlas a su gusto, por lo que me parece que tiene sentido agrupar estas prestaciones en lo que podríamos llamar un módulo. Típicamente, a este módulo se le podría llamar Proposals. Sin embargo, me parece mal nombre y prefiero algo más similar a cómo se denominan los puertos en Arquitectura Hexagonal, así que le llamaría ForSendProposals.

Así que mi objetivo es llegar a tener algo así:

ForSendProposals
    Send
        SendProposalController
        SendProposal
        SendProposalHandler
        SendProposalResponse
    List
        ListProposalController
        ListProposal
        ListProposalHandler    
        ListProposalResponse
    Edit
        EditProposalController
        EditProposal
        EditProposalHandler    
        EditProposalResponse
    Remove
        RemoveProposalController
        RemoveProposal
        RemoveProposalHandler    
        RemoveProposalResponse          

Bueno, esto es un poco CRUD. Se podría argumentar que los nombres son un poco redundantes y que podríamos hacer algo así:

ForSendProposals
    Send
        Controller
        Command
        Handler
        Response
    ...   

Pero también creo que por mucho que tengamos namespaces y todo eso se introduce mucho riesgo de mezclar código de distintas features. Aunque tengo bastante querencia por los nombres cortos, esta solución no me convence mucho.

Aprovecho también para comentar que es posible que haya alguna divergencia entre el código que aparezca en los artículos y el código que finalmente esté en el repositorio.

Vamos a centrarnos.

Todo junto

Con el test Gherkin en marcha, podemos empezar a escribir código de producción. Como ya vimos en el artículo anterior, las features o casos de uso se representan en código mediante un patrón Command/Handler, el cual es invocado desde el Controller, que obtiene una Response.

ForSendProposals
    Send
        SendProposalController
        SendProposal
        SendProposalHandler
        SendProposalResponse

Controller es uno de los patrones GRASP y, como sabemos, se encarga de traducir una acción en el mundo exterior en una acción en la aplicación. En nuestro caso, enviar una request POST a un endpoint con un contenido.

En este ejemplo SendProposal es la parte Command del Caso de Uso y llevará los datos recibidos por el Controller al SendProposalHandler, cuya función será guardarlos en el tipo de persistencia que hayamos decidido, así como devolver una respuesta SendProposalResponse que permita al Controller saber si todo ha ido bien o hemos tenido errores.

A partir de aquí podemos imaginar muchas posibles implementaciones. Quizá te estés preguntando ¿Dónde está la entidad Proposal? Y el ProposalRepository, ¿para cuándo? ¿Cómo te aseguras de que los datos son válidos?

Vayamos por partes.

Entidades, ¿Sí o No?

A la primera de las preguntas voy a responder: ¿para qué necesito en este punto una entidad? De momento, no tengo muchas reglas de negocio, salvo quizá que una propuesta debe contener todos los datos requeridos y que algunos de estos datos tienen un formato particular (email) o valores predeterminados.

Por otro lado, tampoco tengo de momento temas transaccionales. Todos los datos que vengan son datos que se han de guardar y posiblemente solo voy a tener una tabla en la base de datos. Hemos visto que apenas es un CRUD. Al menos de momento. Por supuesto, las cosas se pueden complicar en el futuro.

¿Necesito un repositorio? Mucha gente piensa que el Repositorio es una entrada a la base de datos, pero nada más lejos de la realidad. En DDD un Repositorio es una colección de entidades o agregados en memoria, que suele tener que apoyarse en alguna persistencia permanente como una base de datos.

Obviamente, necesito poder guardar las propuestas en algún lugar, pero ni siquiera necesito que sea una base de datos. Sin embargo, pensando un poco en el futuro, tiene sentido que sea una base de datos SQL porque puedo imaginar varios casos de uso en los que encajará bien:

  • Necesitaré buscar por alguno de los campos.
  • Necesitaré seleccionar propuestas por diversos criterios, como pueden ser propuestas pendientes de revisión.
  • Me interesará poder cruzar datos con otras tablas que crearé en el futuro, por ejemplo, para crear la agenda de una conferencia concreta.

Y, ¿qué hay de la validación de datos? Como he comentado antes los datos tienen que cumplir algunos requisitos. Por ejemplo, al enviar una propuesta, todos los campos deberían estar cubiertos, y algunos de los campos deberían forzar ciertos formatos o ser de cierto tipo.

Que todos los campos necesarios estén cubiertos podría forzarse en el controlador, a fin de poder rechazar el envío y solicitar que se corrijan los cambios.

Esta validación, que suelo llamar validación estructural o sintáctica, prefiero realizarla antes de instanciar el Comando. Se refiere a cuestiones que podríamos considerar sintácticas porque se refieren sobre todo a la forma de los datos. Algunas de ellas podrían forzarse, por ejemplo, en el frontend y ni siquiera tendrían que llegar a la aplicación, aunque deberían protegerse igualmente. No hay que confiar ciegamente en el input del exterior.

Por otro lado, las validaciones que corresponden con reglas de negocio e invariantes tendrían que ocurrir en el handler. Serían validaciones semánticas, pues tienen que ver con el significado del caso de uso y los conceptos manejados. Por ejemplo, supongamos que solo admitimos tres propuestas por Speaker. En ese caso, se trata de una regla de negocio que, además, solo se podría forzar dentro de la aplicación.

Outside-in y VSA

Las metodologías outside-in como BDD o TDD outside-in encajan muy bien en la aproximación de VSA. En ambas metodologías, se busca minimizar la cantidad de diseño up-front. Sandro Mancuso habla de Just enough design, algo que cuadra bastante bien con la idea de Jimmy Bogard de que las arquitecturas de capas imponen un exceso de diseño y abstracciones.

En cualquier caso, se trata de proceder desde el exterior hacia el interior de la aplicación. La vida de una request a un endpoint empieza para nosotras en el controlador, allí deberíamos recibir unos datos en forma de objeto JSON, o tal vez de formulario, o como parámetros de una petición GET. ¿Qué tiene que hacer el controlador? En general, tiene que convertir esa payload en un DTO, que habitualmente será un comando, y pasárselo directa o indirectamente a un objeto capaz de manejarlo y esperar su respuesta. Finalmente, tiene que dar forma a esa respuesta para poder devolverla al emisor de la request.

Por lo general, para empezar suelo partir de este esqueleto.

<?php

declare (strict_types=1);

namespace App\ForSendProposals\SendProposal;


use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

final class SendProposalController
{
    public function __invoke(Request $request): Response
    {
        throw new \RuntimeException('Implement __invoke() method.');
    }
}

Al lanzar la feature BDD obtengo este error:

Server error: `POST https://localhost/api/proposals` resulted in a `500 Internal Server Error` response:
<!-- Implement __invoke() method. (500 Internal Server Error) -->

En metodología Outside-in, cuando el test me pide implementar algo, debo pasar al ciclo de TDD clásica para desarrollar mi controlador. En Outside-in vamos a diseñar un componente, en este caso el controlador, con el test de aceptación en rojo. Y dado que no sabemos como vamos a implementar los componentes más internos, usaremos dobles mientras descubrimos su interfaz.

No quiero alargar el artículo yendo paso por paso e incluyendo cada posible test. Lo interesante para nuestro objetivo aquí es que voy a diseñar los elementos que necesite en el controlador, buscando la solución más sencilla que pueda.

ForSendProposals
    Send
        SendProposalController
        SendProposal
        SendProposalHandler
        SendProposalResponse

Por supuesto, el controlador debería poder enviar un comando a un handler para que se encargue de ejecutar el registro de la propuesta. Y, por otro lado, respondernos con al menos un código de estado y, quizá, algún mensaje.

Como no vamos a crear nuestro handler todavía, vamos a crear un doble de test que describa su interfaz. He aquí un borrador que es muy sencillo, pero que no es muy fácil de seguir. Luego le haré un refactor, pero vamos a intentar explicarlo primero, ya que al tener todo el código junto sería más fácil ver cómo se relaciona todo:

final class SendProposalControllerTest extends TestCase
{
    /** @test */
    public function should_accept_well_formed_proposal(): void
    {
        $payload = [
            'title' => 'Proposal Title',
            'description' => 'A description or abstract of the proposal',
            'author' => 'Fran Iglesias',
            'email' => 'fran.iglesias@example.com',
            'type' => 'talk',
            'sponsored' => true,
            'location' => 'Vigo, Galicia',
        ];
        $request = Request::create(
            '/api/proposals',
            'POST',
            [],
            [],
            [],
            ['CONTENT-TYPE' => 'json/application'],
            json_encode($payload)
        );
        $handler = $this->createMock(SendProposalHandler::class);
        $command = new SendProposal(
            'Proposal Title',
            'A description or abstract of the proposal',
            'Fran Iglesias',
            'fran.iglesias@example.com',
            'talk',
            true,
            'Vigo, Galicia',
        );

        $expected = new SendProposalResponse(true, 'proposal-id', 'Proposal Title');
        $handler
            ->method('__invoke')
            ->with($command)
            ->willReturn($expected);

        $controller = new SendProposalController($handler);
        $response = ($controller)($request);
        assertEquals(202, $response->getStatusCode());
        $content = json_decode($response->getContent(), true);
        assertStringContainsString('Your proposal titled "Proposal Title" was registered.', $content['message']);
        assertEquals('https://localhost/api/proposals/proposal-id', $response->headers->get("Location"));
    }
}

$payload es la información que se envía en la Request al controlador y contiene los datos de la propuesta que estamos usando en este test. Esta parte debería ser bastante clara.

La siguiente línea nos sirve para crear un doble (mock) de SendProposalHandler. Esta clase ni siquiera está definida, pero ya llegaremos a ella:

$handler = $this->createMock(SendProposalHandler::class);

La variable $command define un objeto SendProposal, que será la parte de datos del par CommandHandler. Como se puede ver fácilmente, va a recoger los datos que hemos pasado como payload, por lo que para que el test pase, debemos incluir el código necesario para extraerlos de la request.

$command = new SendProposal(
    'Proposal Title',
    'A description or abstract of the proposal',
    'Fran Iglesias',
    'fran.iglesias@example.com',
    'talk',
    true,
    'Vigo, Galicia',
);

En la siguiente línea definimos un objeto SendProposalResponse que será lo que devuelva el handler. Como es un comando, en realidad tampoco tendríamos que hacer que devuelva nada, pero podemos ser un poco laxas en este punto y darle al controlador alguna información útil para mostrar al consumidor del endpoint. En este caso un ID y el título de la propuesta. El ID nos permitiría construir una cabecera de respuesta “Location” que, en principio, está contemplada en el estándar REST cuando se ha creado un recurso.

$expected = new SendProposalResponse(true, 'proposal-id', Proposal Title');

Y la siguiente línea define la interfaz de SendProposalHandler, que tendrá un método __invoke, el cual recibirá el objeto SendProposal y devolverá un objeto SendProposalResponse.

$handler
    ->method('__invoke')
    ->with($command)
    ->willReturn($expected);

Las aserciones que tenemos en este test básicamente nos ayudan a asegurar que creamos todos los objetos necesarios con los datos adecuados, aunque no desarrollemos todavía el comportamiento.

assertEquals(202, $response->getStatusCode());
$content = json_decode($response->getContent(), true);
assertStringContainsString('Your proposal titled "Proposal Title" was registered.', $content['message']);
assertEquals('https://localhost/api/proposals/proposal-id', $response->headers->get("Location"));

Este código hace pasar el test unitario:

final class SendProposalController
{
    private SendProposalHandler $handler;

    public function __construct(SendProposalHandler $handler)
    {
        $this->handler = $handler;
    }

    public function __invoke(Request $request): Response
    {
        $payload = json_decode($request->getContent(), true);
        $command = new SendProposal(
            $payload['title'],
            $payload['description'],
            $payload['author'],
            $payload['email'],
            $payload['type'],
            $payload['sponsored'],
            $payload['location'],
        );
        $response = ($this->handler)($command);
        $message = sprintf('Your proposal titled "%s" was registered.', $response->title);
        $jsonResponse = new JsonResponse(['message' => $message], Response::HTTP_ACCEPTED);
        $jsonResponse->headers->set("Location", "https://localhost/api/proposals/" . $response->id);
        return $jsonResponse;
    }
}

Junto con los siguientes DTO:

final readonly class SendProposal
{
    public function __construct(
        public string $title,
        public string $description,
        public string $name,
        public string $email,
        public string $type,
        public bool   $sponsored,
        public string $location
    )
    {
    }
}

final readonly class SendProposalResponse
{
    public function __construct(
        public bool   $success,
        public string $id,
        public string $title)
    {
    }
}

Y el código inicial del handler que como no se invoca directamente en el test, pues usamos un doble, no hace falta que tenga implementación.

class SendProposalHandler
{
    public function __invoke(SendProposal $command): SendProposalResponse
    {
        throw new \RuntimeException('Implement __invoke() method.');
    }
}

Puedo sentir a algunas personas que están leyendo esto y removiéndose incómodas en sus asientos, así que vamos a explicar cada decisión poco a poco. De todos modos, recuerda que esta es solo la primera iteración de una feature de la que quedan muchos detalles por definir.

Uso de DTOs

¡Y con propiedades públicas!

Los DTO nos sirven para mover datos entre capas de la manera más barata posible. En PHP 8 podemos definir propiedades de solo lectura y esto nos permite que estos objetos sean creados inmutables. Al no tener que forzar la inmutabilidad, no necesitamos poner propiedades privadas y getters. Eso que nos ahorramos de teclear.

Pero… ¿He dicho capas? ¿No estamos haciendo un ejercicio en el que pretendemos usar una arquitectura no basada en capas?

Como vimos en el artículo anterior, las features en la VSA son equiparables a los casos de uso y, de algún modo, atravesarían todas las capas de una arquitectura. Así que el controlador sigue estando en una capa lógica (UI, Infrastructure, Delivery… como quieras llamarla), mientras que el Handler está dentro de la aplicación (capa Application, UseCase…).

Por esa razón SendProposal y SendProposalResponse son definidos como DTOs, objetos inmutables de transporte de datos. No es previsible que tengan comportamiento. En otros lenguajes podrías usar otros tipos como Structs, Records, etc.

Otro detalle: SendProposalHandler se acopla a estos objetos concretos. No intentamos introducir una interfaz y no deberíamos necesitarlo.

La implementación del controlador

La implementación del controlador es muy simplista y no tiene nada de diseño. Ahora es donde entra nuestra capacidad de detectar smells y refactorizar, una vez que sabemos que funciona.

Podemos observar las siguientes partes que separo aquí mediante comentarios:

final class SendProposalController
{
    private SendProposalHandler $handler;

    public function __construct(SendProposalHandler $handler)
    {
        $this->handler = $handler;
    }

    public function __invoke(Request $request): Response
    {
        // Build command from request
        $payload = json_decode($request->getContent(), true);
        $command = new SendProposal(
            $payload['title'],
            $payload['description'],
            $payload['author'],
            $payload['email'],
            $payload['type'],
            $payload['sponsored'],
            $payload['location'],
        );
        
        // Invoke handler with the command
        $response = ($this->handler)($command);
        
        // Build HTTP Response from handler response
        $message = sprintf('Your proposal titled "%s" was registered.', $response->title);
        $jsonResponse = new JsonResponse(['message' => $message], Response::HTTP_ACCEPTED);
        $jsonResponse->headers->set("Location", "https://localhost/api/proposals/" . $response->id);
        return $jsonResponse;
    }
}

El smell más evidente aquí es long method: tenemos muchas líneas de código y vemos que hay distintos niveles de abstracción. Podemos refactorizar para expresarlo mejor:

final class SendProposalController
{
    private SendProposalHandler $handler;

    public function __construct(SendProposalHandler $handler)
    {
        $this->handler = $handler;
    }

    public function __invoke(Request $request): Response
    {
        $command = $this->buildCommand($request);
        $response = ($this->handler)($command);

        return $this->buildResponse($response);
    }

    private function buildCommand(Request $request): SendProposal
    {
        $payload = json_decode($request->getContent(), true);
        
        return new SendProposal(
            $payload['title'],
            $payload['description'],
            $payload['author'],
            $payload['email'],
            $payload['type'],
            $payload['sponsored'],
            $payload['location'],
        );
    }

    private function buildResponse(SendProposalResponse $response): JsonResponse
    {
        $message = sprintf('Your proposal titled "%s" was registered.',
            $response->title);
        $jsonResponse = new JsonResponse(['message' => $message],
            Response::HTTP_ACCEPTED);
        $jsonResponse->headers->set("Location",
            "https://localhost/api/proposals/" . $response->id);

        return $jsonResponse;
    }
}

Esto deja las cosas un poco mejor y, por el momento, no necesitaríamos nada más. Podríamos introducir la validación estructural de la payload, así como validar si la request también cumple requisitos como gestionar la cabecera content-type y otros. Como no quiero introducir tanto nivel de detalle en el artículo, lo dejaré aquí. Básicamente, lo que haré será crear los tests que verifiquen cada uno de estos detalles.

Donde ver el código

Puedes ver el repositorio aquí. Este es el commit que recoge los cambios incluídos en este artículo. Versión 0.1.0.

Y de momento me voy a parar aquí. En la próxima sesión profundizaré un poco más en el código para diseñar el Handler y ver qué problemas nos presenta.

May 5, 2024

Etiquetas: design-patterns   php  

Temas

good-practices

refactoring

php

testing

tdd

design-patterns

python

blogtober19

design-principles

tb-list

misc

bdd

legacy

golang

dungeon

ruby

tools

tips

hexagonal

ddd

bbdd

soft-skills

books

oop

javascript

api

sql

ethics

typescript

swift

java

agile