VSA. Más de una feature

por Fran Iglesias

Con una feature implementada en el artículo anterior, vamos a introducir una nueva. Es ahora cuando tendremos que enfrentarnos de verdad al desafío que supone Vertical Slice Architecture.

Al fin y al cabo, crear una única feature es poco más que implementar un script para realizar una tarea concreta. En el artículo anterior terminábamos organizando un poco el código, en parte, porque tenemos claro que se trata de una aplicación y habrá que añadir nuevas prestaciones. Por tanto, necesitamos hacer una cierta inversión en organización y legibilidad del código, para que en el futuro sea lo más barato posible introducir funcionalidad o modificar la existente.

Introducir una segunda feature, que en este artículo va a ser la capacidad de recuperar una propuesta, debería plantearnos los problemas sobre los que giran las dudas más frecuentes sobre Vertical Slice Architecture. Por ejemplo:

  • La duplicación de los objetos que representan los conceptos de dominio.
  • La posibilidad de reutilizar código.
  • La identificación de abstracciones y dónde colocarlas.

En esta iteración voy a intentar escribir la nueva feature de la manera más aislada posible, reutilizando código solo si no me queda más remedio. Seguidamente, veremos qué problemas nos plantea esto para gestionar el código. Para terminar, usaremos refactoring para evolucionar el proyecto si es posible.

Segunda feature: ver una propuesta

Retomemos el test Gherkin original:

Feature: Sending proposals to C4P
  As a potential speaker
  I want 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",
            "author": "Fran Iglesias",
            "email": "fran.iglesias@ezxample.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

El último paso aparece comentado porque, como mencioné en el artículo anterior, no tenía claro qué hacer. La idea es asegurar que la propuesta recién creada se puede recuperar, cosa que los pasos anteriores no demuestran. Tal como se describe, la propuesta es reconocida, pero no nos consta que se pueda recuperar. Gracias a que el endpoint devuelve una URI para verla, creo que puede ser buena idea implementar esa característica, para lo cual voy a introducir un nuevo paso del escenario y quitar el comentario.

Feature: Sending proposals to C4P
    As a potential speaker
    I want 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",
            "author": "Fran Iglesias",
            "email": "fran.iglesias@ezxample.com",
            "type": "talk",
            "sponsored": true,
            "location": "Vigo, Galicia"
        }
        """
        When He sends the proposal
        Then The proposal is acknowledged
        Then Fran can see the proposal with the "waiting" status

Aprovecho para introducir un indicador de que la propuesta ha sido procesada como para asignarle el estado inicial de “waiting”. Esta sería la implementación del paso. Se obtiene la URI de la cabecera Location de la respuesta recibida tras el POST, se solicita mediante GET y se decodifica el resultado. De este resultado tomamos el campo ‘status’. Podría añadirse una comparación con $payload, que tenemos guardado, para asegurar que los datos se han recogido bien… al menos los más significativos.

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

    // Code removed for clarity
    
    /**
     * @Then /^Fran can see the proposal with the "([^"]*)" status$/
     */
    public function franCanSeeTheProposalWithTheStatus($status)
    {
        $loc = $this->response->getHeader('Location')[0];

        $client = new Client();
        $response = $client->request(
            'GET',
            $loc,
            [
                'headers' => [
                    'Content-Type' => 'application/json'
                ]
            ]
        );
        assertEquals(200, $response->getStatusCode());
        $body = json_decode($response->getBody()->getContents(), true);
        assertEquals($status, $body['status']);

        $proposal = json_decode($this->payload);
        assertEquals($proposal['title'], $body['title']);
    }
}

En todo caso, debería ser suficiente para dirigir el desarrollo de la feature. Si ejecutamos el test, vemos que falla porque no encuentra la ruta, y es lo que esperamos:

GuzzleHttp\Exception\ClientException: Client error: `GET https://localhost/api/proposals/01HXM8V0Y9T8SF1QK7Y3VF017J` resulted in a `404 Not Found` response:
<!-- No route found for &quot;GET https://localhost/api/proposals/01HXM8V0Y9T8SF1QK7Y3VF017J&quot; (404 Not Found) -->

Como ya te puedes imaginar, buena parte del trabajo aquí es crear un controlador y coser todo con la ayuda del framework, definiendo las rutas necesarias. Y como estamos haciendo un ejemplo de VSA, pues tendremos que crear el paquete para esta feature.

Y se nos presenta la primera duda. Tenemos un paquete ForSendProposals donde pusimos SendProposal, con la idea de que fuese el contenedor de todas las features relacionadas con la intención de enviar propuestas. Leer la propuesta, ¿Forma parte de este grupo? El caso es que podría haber otras intenciones para leer la propuesta, como podría ser cuando una persona de la organización la quiere leer para valorar su inclusión en el evento. ¿Es exactamente lo mismo?

Por otro lado, se supone que VSA quiere ser un enfoque pragmático. ¿Cuál es el problema inmediato que queremos resolver? Pues básicamente lo que estamos planteando es que la persona que ha enviado la propuesta tenga una seguridad de que se ha registrado correctamente y que ha entrado en el proceso de selección.

Así que vamos a seguir por ahí. Por el momento, nuestra necesidad de implementar esta prestación tiene que ver con dar feedback a la persona que envía una propuesta y, por tanto, se la mostramos. Así pues, la incorporamos al mismo paquete, dentro de un sub-paquete llamado ReadProposal.

<?php

declare (strict_types=1);

namespace App\ForSendProposals\ReadProposal;


final class ReadProposalController
{
    public function __invoke()
    {
        throw new \RuntimeException('Implement ReadProposalController.__invoke() method.');
    }
}

Una vez que añadimos la ruta, al ejecutar el test nos devuelve este error, indicando que el controlador se ejecuta.

GuzzleHttp\Exception\ServerException: Server error: `GET https://localhost/api/proposals/01HXM9VFGYEXKHC6XRJYN576KB` resulted in a `500 Internal Server Error` response:
<!-- Implement ReadProposalController.__invoke() method. (500 Internal Server Error) -->

Esta es la definición de la ruta, por cierto:

api_read_proposal:
    path: /api/proposals/{id}
    controller: App\ForSendProposals\ReadProposal\ReadProposalController
    methods: GET

Y diría que con toda esta información podemos ver la estructura que estamos construyendo. Podemos anticipar, que vamos a necesitar lo siguiente para empezar:

  • ReadProposal, la query que llevará la información del ID de la propuesta que queremos recuperar.
  • ReadProposalHandler, que se encargará de coordinar su recuperación de la base de datos.
  • ReadProposalResponse, que se encargará de llevar la respuesta.

En este momento, como en el caso anterior, no voy a introducir cuestiones de validación. Ya nos meteremos con ellas en otro momento. Tengo dudas sobre qué forma debería tener ReadProposalResponse. En el capítulo anterior lo planteé como un objeto que podría llevar información tanto si la request tenía éxito como si no. Y no estoy contento del enfoque tomado.

Pero como estamos haciendo VSA, nada me impide probar otra cosa para esta feature. El problema en el que estoy pensando es que la request falle porque no se encuentra la propuesta. Puede haber dos razones para ello:

  • La propuesta con ese ID no existe (o bien existió pero ha sido eliminada), que daría un error 404.
  • No se puede hablar con la base de datos por alguna razón, que daría un error 500.
  • Una tercera razón es que la request sea incorrecta porque el ID proporcionado no es un ULID, que daría error 400.

En este caso, prefiero un tipo de gestión basado en excepciones, que llegarían al controlador y este decide qué mostrar. La consecuencia es que el objeto ReadProposalResponse podría ser un DTO con los datos de Proposal. Soy consciente de que las excepciones tienen muy mala fama, pero es que me parece que este es el caso de uso ideal.

Toda esta explicación es necesaria para armar el test unitario del controlador que me va a servir para hacer el diseño básico. Y, de momento, solo el happy path. En esta primera versión es un poco farragoso, pero con ella podremos construir todos los elementos básicos de la feature.

final class ReadProposalControllerTest extends TestCase
{
    /** @test */
    public function should_retrieve_proposal_by_id(): void
    {
        $id = '01HXMBMMXAG7S1ZFZH98HS3CHP';
        $receivedAt = new \DateTimeImmutable();

        $request = Request::create(
            '/api/proposals/'.$id,
            'GET',
            [],
            [],
            [],
            ['CONTENT-TYPE' => 'json/application'],
        );

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

        $query = new ReadProposal($id);

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

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

        $controller = new ReadProposalController($handler);
        $response = ($controller)($id, $request);

        $body = json_encode(
            [
                'id' => $id,
                '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',
                'status' => 'waiting',
                'receivedAt' => $receivedAt,
            ]
        );

        assertEquals(200, $response->getStatusCode());
        assertEquals($body, $response->getContent());
    }
}

El controlador resultante es muy sencillo, pero todo funciona como es debido. Más adelante habrá que añadir soporte para la gestión de los errores, pero el hecho de que el controlador sea muy delgado me parece una ventaja.

final class ReadProposalController
{
    private ReadProposalHandler $handler;

    public function __construct(ReadProposalHandler $handler)
    {
        $this->handler = $handler;
    }
    
    public function __invoke(string $id, Request $request): Response
    {
        $query = new ReadProposal($id);
        $response = ($this->handler)($query);

        return new JsonResponse($response, Response::HTTP_OK);
    }
}

Si todo está bien cosido al lanzar el test Gherkin, el escenario fallará porque el Handler está pendiente de implementar.

GuzzleHttp\Exception\ServerException: Server error: `GET https://localhost/api/proposals/01HXMEANR00709VM9YM02A0W8D` resulted in a `500 Internal Server Error` response:
<!-- Implement ReadProposalHandler.__invoke() method. (500 Internal Server Error) -->

En mi caso, había olvidado etiquetar el servicio como controlador, pero una vez subsanado en la configuración todo funcionó como era de esperar.

Manejando el Handler

Una petición GET debería ser muy sencilla. El Handler tan solo debería recuperar los datos y montar la respuesta. Como comentamos más arriba, hay un par de posibilidades de que las cosas salgan mal: no se encuentra la propuesta o algo falla al conectar a la base de datos. No me voy a preocupar de eso ahora.

Por otro lado, que VSA promueva la independencia entre features no prohíbe que aprovechemos lo aprendido al construir una, por lo que voy a abstraer los detalles de implementación mediante una interfaz sencilla:

interface RetrieveProposal 
{
    public function __invoke(string $id): Proposal
}

Aun así, vamos a encontrar algunos de los puntos de fricción habituales sobre VSA. El problema, en este caso, es la definición de un objeto Proposal, ya que lo hemos definido para que pueda actuar como entidad el ORM y eso nos condiciona.

Si dependes de un ORM, influirá en tu diseño

En la otra feature, el objeto Proposal es un objeto fuertemente acoplado al ORM Doctrine. Este acoplamiento es relativamente ligero, pero nos ha obligado a usar anotaciones y definir las propiedades como privadas, con métodos de acceso públicos. Es un DTO un poco sobredimensionado.

Si queremos usarlo para aprovechar el ORM ya nos plantea algunas cuestiones. Está definido en otra feature, así que habría que compartirlo. Estoy casi seguro de que hay algún tipo de hackeo para conseguir definir dos entidades del ORM que apunten a la misma tabla. Pero no soy partidario de este tipo de trucos: no son código limpio, pueden introducir confusión semántica y dependen mucho de que el ORM mantenga el soporte. Hay una forma mucho más fácil: SendProposal y ReadProposal deberían compartir esa entidad, y su repositorio asociado en el ORM.

Y permíteme resaltar aquí que estoy hablando de entidad y repositorio del ORM. No tiene nada que ver con los building blocks de DDD del mismo nombre y que no hemos introducido en este proyecto.

En fin. Todas las decisiones que tomemos aquí estarán condicionados por la decisión que hagamos sobre la tecnología de persistencia, señal de que no estamos lo bastante aisladas del detalle de la implementación.

CQRS, o algo así, al rescate

A estas alturas no estoy muy seguro si sería correcto hablar de CQRS, pero quizá sirva para explicar la siguiente opción que, básicamente, consiste en entender que la escritura y la lectura son cosas distintas y se hacen de formas diferentes, sobre infraestructura diferente.

Esto es. En lugar de usar el ORM para recuperar Proposal, la idea sería aplicar el concepto de ReadModel, leyendo los datos deseados de la base de datos y reconstruyendo Proposal a partir de ahí. El ORM ofrece ventajas para la escritura, pero muchas veces resulta inconveniente para la lectura porque se trae más datos de los necesarios o genera. No en este caso, en el que vamos a recuperar posiblemente todos los datos de Proposal.

Para este ReadModel podemos hacer peticiones a la base de datos mediante SQL. Como vimos en el artículo anterior, podemos usar Doctrine DBAL para que nos ayude. Aunque estamos usando la misma tabla física en el mismo servidor de bases de datos, es un poco como si considerásemos esta tabla como una proyección. De hecho, si fuese necesario, sería bastante fácil introducir esta proyección, o tener un sistema de maestro-réplica, etc.

En cualquier caso, esta solución nos permite separar por completo el desarrollo de esta feature de las otras, ya que no necesitamos nada definido en otra parte.

Tampoco es que haga falta un rescate. Pero este es uno de los puntos confusos que plantea VSA y quizá uno de sus puntos débiles.

Decidiendo la implementación del Handler

En todo caso, vamos a empezar con un test TDD clásico y veremos donde nos lleva eso con cada una de las posibles implementaciones.

Versión con ORM

Empiezo con este test, que no es muy emocionante, pero que nos desvela alguno de los problemas.

final class ReadProposalHandlerTest extends TestCase
{
    /** @test */
    public function should_retrieve_proposal_with_id(): void
    {
        $proposalId = '01HXE2R5JBCRKAA3K0BZ1TCXT2';
        $now = new \DateTimeImmutable();

        $proposal = new Proposal();
        $proposal->setId($proposalId);
        $proposal->setTitle('Proposal Title');
        $proposal->setDescription('A description or abstract of the proposal');
        $proposal->setAuthor('Fran Iglesias');
        $proposal->setEmail('fran.iglesias@example.com');
        $proposal->setType('talk');
        $proposal->setSponsored(true);
        $proposal->setLocation('Vigo, Galicia');
        $proposal->setStatus('waiting');
        $proposal->setReceivedAt($now);

        $retrieveProposal = $this->createMock(RetrieveProposal::class);
        $retrieveProposal->method('__invoke')->willReturn($proposal);

        $handler = new ReadProposalHandler($retrieveProposal);
        $command = new ReadProposal($proposalId);

        $response = ($handler)($command);

        assertEquals($proposalId, $response->id);
        assertEquals('Proposal Title', $response->title);
        assertEquals('A description or abstract of the proposal', $response->description);
        assertEquals('Fran Iglesias', $response->author);
        assertEquals('fran.iglesias@example.com', $response->email);
        assertEquals('talk', $response->type);
        assertEquals(true, $response->sponsored);
        assertEquals('Vigo, Galicia', $response->location);
        assertEquals('waiting', $response->status);
        assertEquals($now, $response->receivedAt);
    }
}

El primer problema es que estamos usando Proposal de la otra feature:

use App\ForSendProposals\ReadProposal\RetrieveProposal;
use App\ForSendProposals\SendProposal\Proposal;

Algo que no se ve claro en la implementación, por cierto:

<?php

declare (strict_types=1);

namespace App\ForSendProposals\ReadProposal;


class ReadProposalHandler
{
    private RetrieveProposal $retrieveProposal;

    public function __construct(RetrieveProposal $retrieveProposal)
    {
        $this->retrieveProposal = $retrieveProposal;
    }


    public function __invoke(ReadProposal $readProposal): ReadProposalResponse
    {
        $proposal = ($this->retrieveProposal)($readProposal->id);

        return new ReadProposalResponse(
            $proposal->getId(),
            $proposal->getTitle(),
            $proposal->getDescription(),
            $proposal->getAuthor(),
            $proposal->getEmail(),
            $proposal->getType(),
            $proposal->isSponsored(),
            $proposal->getLocation(),
            $proposal->getStatus(),
            $proposal->getReceivedAt(),
        );
    }
}

Sino en la interfaz de RetrieveProposal y, como es lógico, en el test donde simulamos que se entrega un objeto Proposal:

<?php

declare (strict_types=1);

namespace App\ForSendProposals\ReadProposal;


use App\ForSendProposals\SendProposal\Proposal;

interface RetrieveProposal
{
    public function __invoke(string $id): Proposal;
}

Tenemos que movernos con cuidado ahora. En PHP no hay problema en importar clases desde cualquier namespace, pero hacerlo desde un nivel “hermano” no es fácil de entender ni de mantener. Lo apropiado sería mover Proposal, y ProposalRepository, al nivel de ForSendProposal, que representa el módulo o paquete contenedor de los casos de uso relacionados con el envío de propuestas.

Esto nos dejaría una estructura como la que sigue:

src/ForSendProposals
├── Proposal.php
├── ProposalRepository.php
├── ReadProposal
│   ├── DoctrineRetrieveProposal.php
│   ├── ReadProposal.php
│   ├── ReadProposalController.php
│   ├── ReadProposalHandler.php
│   ├── ReadProposalResponse.php
│   └── RetrieveProposal.php
└── SendProposal
    ├── Clock
    │   ├── Clock.php
    │   └── SystemClock.php
    ├── IdentityProvider
    │   ├── IdentityProvider.php
    │   └── UlidIdentityProvider.php
    ├── ProposalBuilder.php
    ├── SendProposal.php
    ├── SendProposalController.php
    ├── SendProposalHandler.php
    ├── SendProposalResponse.php
    └── StoreProposal
        ├── DoctrineStoreProposal.php
        ├── ProposalNotStored.php
        └── StoreProposal.php

¿Qué te parece? Mi primera impresión es que se pierde una parte de la clara separación entre features individuales que se supone que es uno de los puntos a favor de la VSA. Y el problema es que cuantas más cosas encontremos que nos interese compartir más se difumina la separación.

Esto se ve favorecido porque la pareja Proposal y ProposalRepository no son elementos del mismo tipo que ReadProposal y SendProposal. Para empezar no son paquetes, algo que podríamos solucionar fácilmente, creando un paquete para estas cuestiones.

Pero es que incluso así, el nuevo paquete no tiene la misma categoría que las features o casos de uso. Sería un paquete ORM, o Persistence… ¿Quieres llamarlo Infrastructure? ¿O tal vez usar otros nombres como Shared, Common o cualquier otro que haga mención a elementos compartidos? ¿Deberíamos mover las features a un nuevo paquete Use Cases, Features,… Application?

VSA no nos proporciona reglas claras al respecto, salvo la de evitar el acoplamiento entre features, aumentando el acoplamiento dentro de una. Esto me lleva a pensar en la opción alternativa: no usar el ORM, sino leer directamente los datos de Base de Datos y evitar usar el objeto Proposal definido para el ORM. En su lugar, pongamos un Read Model, o sea, un DTO a la medida de la vista (o View Model) que queremos mostrar.

Por supuesto, podríamos argumental que ReadProposalResponse es más o menos un ViewModel.

Version sin ORM

El test con el que vamos a iniciar el proceso de desarrollo de la feature basándonos en esta aproximación es similar al que teníamos, con la salvedad de que vamos a utilizar otro objeto para traernos los atos de la base de datos.

final class ReadProposalHandlerTest extends TestCase
{
    /** @test */
    public function should_retrieve_proposal_with_id(): void
    {
        $proposalId = '01HXE2R5JBCRKAA3K0BZ1TCXT2';
        $now = new \DateTimeImmutable();

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

        $retrieveProposal = $this->createMock(RetrieveProposal::class);
        $retrieveProposal->method('__invoke')->willReturn($proposal);

        $handler = new ReadProposalHandler($retrieveProposal);
        $command = new ReadProposal($proposalId);

        $response = ($handler)($command);

        assertEquals($proposalId, $response->id);
        assertEquals('Proposal Title', $response->title);
        assertEquals('A description or abstract of the proposal',
            $response->description);
        assertEquals('Fran Iglesias', $response->author);
        assertEquals('fran.iglesias@example.com', $response->email);
        assertEquals('talk', $response->type);
        assertEquals(true, $response->sponsored);
        assertEquals('Vigo, Galicia', $response->location);
        assertEquals('waiting', $response->status);
        assertEquals($now, $response->receivedAt);
    }
}

Como no tenemos ninguna atadura con el ORM podemos usar un DTO más sencillo que, por cierto, coincide con ReadProposalResponse, pero obedece a otra función. Esto puede parecer duplicar código, pero son conceptos distintos.

final readonly class Proposal
{

    public function __construct(
        public string $id,
        public string $title,
        public string $description,
        public string $author,
        public string $email,
        public string $type,
        public true $sponsored,
        public string $location,
        public string $status,
        public DateTimeImmutable $receivedAt
    )
    {
    }
}

Si introducimos una implementación inicial de RetrieveProposal como esta:

final class DBALRetrieveProposal implements RetrieveProposal
{

    public function __invoke(string $id): Proposal
    {
        throw new \RuntimeException('Implement DBALRetrieveProposal.__invoke() method.');
    }
}

La estructura del paquete nos queda así, con ambas features/use cases separadas limpiamente.

src/ForSendProposals
├── ReadProposal
│   ├── DBALRetrieveProposal.php
│   ├── Proposal.php
│   ├── ReadProposal.php
│   ├── ReadProposalController.php
│   ├── ReadProposalHandler.php
│   ├── ReadProposalResponse.php
│   └── RetrieveProposal.php
└── SendProposal
    ├── Clock
    │   ├── Clock.php
    │   └── SystemClock.php
    ├── IdentityProvider
    │   ├── IdentityProvider.php
    │   └── UlidIdentityProvider.php
    ├── Proposal.php
    ├── ProposalBuilder.php
    ├── SendProposal.php
    ├── SendProposalController.php
    ├── SendProposalHandler.php
    ├── SendProposalResponse.php
    └── StoreProposal
        ├── DoctrineStoreProposal.php
        ├── ProposalNotStored.php
        ├── ProposalRepository.php
        └── StoreProposal.php

Tan solo nos quedaría implementar el servicio DBALRetrieveProposal, que quedaría más o menos así:

<?php

declare (strict_types=1);

namespace App\ForSendProposals\ReadProposal;


use DateTimeImmutable;
use Doctrine\DBAL\Connection;

final class DBALRetrieveProposal implements RetrieveProposal
{
    private Connection $connection;

    public function __construct(Connection $connection)
    {
        $this->connection = $connection;
    }


    public function __invoke(string $id): Proposal
    {
        $builder = $this->connection->createQueryBuilder();

        $query = $builder->select(
            'id',
            'title',
            'description',
            'author',
            'email',
            'sponsored',
            'type',
            'location',
            'status',
            'received_at'
        )
            ->from('proposal')
            ->where('id = ?')
            ->setParameter(0, $id);

        $result = $query->executeQuery();

        $readProposal = $result->fetchAssociative();

        return new Proposal(
            $readProposal['id'],
            $readProposal['title'],
            $readProposal['description'],
            $readProposal['author'],
            $readProposal['email'],
            $readProposal['type'],
            $readProposal['sponsored'],
            $readProposal['location'],
            $readProposal['status'],
            new DateTimeImmutable($readProposal['received_at'])
        );
    }
}

Con esto para el test Gherkin y podemos dar la feature por implementada. Bueno, el happy path. De los sad paths y demás nos encargaremos en otro momento y en otro lugar para no desviar la atención del meollo de este artículo.

Consideraciones tras la segunda feature

A medida que avanzo en esta aplicación estoy observando algunas consecuencias de intentar aplicar este enfoque de Vertical Slice Architecture. Como de momento solo me he ocupado de los happy paths de las features y no hay gestión de potenciales errores hay asunto que se quedan en el tintero y que intentaré abordar en un próximo artículo en el que no avanzaré en prestaciones nuevas, sino en iterar sobre las anteriores.

Además de eso, hay algunas cosas aquí y allá que me gustaría cambiar.

En el lado positivo, observo algunas ventajas al organizar el código en torno a cada caso de uso o feature. Una de esas ventajas es que al evitar reutilizar código, no aparece la necesidad de introducir la complejidad asociada a tratar de gestionar con una sola unidad diferentes situaciones. Me explico. Ahora mismo estoy trabajando en un código en el que se ha empleado mucho esfuerzo en crear ciertos servicios comunes para tareas relativamente sencillas, pero que obligan a introducir complejidad accidental y reducen la expresividad del código. Si bien es cierto que alivian ciertas tareas repetitivas, también es cierto que hacen mucho más complejo gestionar el código y entender lo que está ocurriendo.

Mantener las features aisladas no debería ser complicado y en este artículo hemos podido implementar la segunda prestación sin necesidad de tocar lo que había. Eso sí, cuando hemos estado considerando usar el ORM para recuperar los datos de las propuestas han empezado a aparecer problemas. Hemos dicho que VSA admite usar generalizaciones y compartir componentes, para mantener la consistencia de los datos y las reglas de negocio, pero no da muchas respuestas a la hora de disponer esos elementos compartidos. De momento, lo hemos esquivado, pero creo que en algún momento vamos a tener que abordar eso.

Un problema, que ya vimos en el artículo anterior, es que si queremos evitar el acoplamiento a tecnologías concretas, tenemos que introducir abstracciones en forma de interfaces para invertir las dependencias. Esto puede requerir más clases y más archivos. A partir de un cierto punto, necesitamos dar una estructura a los paquetes de cada feature, pero… ¿cuál?

¿Domain, Application, Infrastructure…? Una primera tentación es considerar que Vertical Slice Architecture define cada Slice como un corte a través de las capas clásicas. Por ese motivo tendría sentido reproducirlas a pequeña escala. Sería pensar en las capas más como un componente lógico.

El mayor inconveniente de esto es que puede que no tenga mucho sentido. Con frecuencia nos vamos a encontrar con features que no tienen ninguna necesidad de pasar por la capa de dominio. Es el caso, por ejemplo, de ReadProposal: para recuperar los datos y mostrarlos en una vista (o endpoint de API), no necesitamos nada de dominio porque, de hecho, este tipo de features no implican comportamientos de negocio. Solo tenemos el caso de uso y los adaptadores necesarios.

Por otro lado, SendProposal sí podría llegar a requerir de una capa de dominio. Aunque todavía no hemos hecho nada acerca de la validación, es posible que al menos la parte de validaciones de reglas de negocio pueda encapsularse en objetos de dominio.

¿La A es de arquitectura?

Pues permíteme dudarlo. Creo que VSA no propone un sistema de reglas o principios para organizar una aplicación más allá de abordarla por features y minimizar el acoplamiento entre slices.

Hay una definición de arquitectura de software que menciona la idea de que consiste en la toma de decisiones que son muy difíciles de cambiar en el futuro. Por esa razón, hay diversas propuestas que inciden en tener mecanismos que permitan el cambio. Por ejemplo, las dependencias configurables de la Arquitectura Hexagonal o la ley de Dependencia de la arquitectura limpia. Sospecho que el cambio en VSA es caro si no tomas medidas preventivas, como aplicar inversión de dependencias.

Por otro lado, tengo dudas acerca de la conveniencia de mantener juntos elementos como controladores, sistemas de persistencia y casos de uso. Un caso de uso podría ser requerido por distintos actores a través de distintos mecanismos. La Arquitectura Hexagonal, por ejemplo, pone los controladores en el exterior porque son adaptadores, al igual que los sistemas técnicos de persistencia. Dentro de la aplicación, sin embargo, nada nos impide realizar un abordaje vertical del desarrollo.

VSA me parece más una forma ágil de afrontar los proyectos de desarrollo de software que una arquitectura. Y, tomada de esta forma, me parece un enfoque útil, que puede ayudar a los equipos a posponer decisiones de diseño que, de otro modo, se toman prematuramente y luego cuesta cambiar.

En cualquier caso, el análisis no acaba aquí. Nos vemos en próximos artículos.

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.3.0.

May 13, 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

hexagonal

tips

ddd

books

bbdd

software-design

soft-skills

pulpoCon

oop

javascript

api

sql

ethics

agile

typescript

swift

java