VSA. Diseño del handler

por Fran Iglesias

Después de abrir boca con el artículo anterior y tras arreglar algunos problemas que tenía con la configuración de servicios toca seguir profundizando en el desarrollo. Nos vamos al handler.

Consideraciones sobre SendProposalHandler

Si lanzamos la feature de Gherkin deberíamos tener un error como este:

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

Este Implement __invoke() se refiere al SendProposalHandler que, ahora mismo, no hace nada, pero que nos ha servido para introducir todos los objetos necesarios:

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

Como ya sabemos, ahora que el test nos pide implementar, nos movemos a un ciclo de TDD clásico en el que vamos a diseñar este Handler, Feature o Caso de Uso, según la terminología que prefiramos utilizar.

Lo cierto es que este objeto en VSA es la clave que sostiene todo. Es donde vamos a implementar la funcionalidad de la feature. Si seguimos al pie de la letra la propuesta de Jimmy Bogard, lo suyo sería usar un patrón Transaction Script.

Transaction script

A Transaction Script organizes all this logic primarily as a single procedure, making calls directly to the database or through a thin database wrapper. Each transaction will have its own Transaction Script, although common subtasks can be broken into subprocedures.

Martin Fowler, Patterns of Entreprise Application Architecture

¿Hacer llamadas directamente a la base de datos desde un caso de uso? ¿Es que nos hemos vuelto locas? Pues no. Transaction Script es un patrón muy básico y puede que simplemente lo estés usando ya, solo que no le das ese nombre.

Idealmente, el código de un caso de uso es muy sencillo. La lógica consiste en coordinar objetos para implementar el comportamiento deseado en la feature. Y ocurre que, muchas veces, ese comportamiento es muy simple. Tan simple como para que no necesites un gran modelo de dominio: simplemente obtener o guardar algunos datos en la persistencia.

El nombre viene, como debería ser fácil suponer, de que la operación debería ocurrir en una transacción, a fin de garantizar que la base de datos no queda en un estado inconsistente si ocurre algo durante el proceso.

En nuestro caso, no queremos más que poder guardar los datos que recibimos en el comando SendProposal, quizá asegurándonos de que estén bien construidos, añadiendo un par de propiedades más:

  • Asignarle Identidad a la propuesta, pues todos los datos que recibimos deberían poder cambiarse, al menos hasta que la propuesta sea aceptada y asignada al programa de la conferencia. ¿Estamos hablando de una Entidad? Puede ser.
  • Iniciar la propuesta con un estado que represente en qué punto del proceso se encuentra, que en este caso sería algo así como pendiente de revisión.

Inciso: esto me empieza a plantear la posibilidad de algunas reglas de negocio relacionadas con el estado. Por ejemplo, que una propuesta en proceso de revisión no debería poder cambiarse. Pero quizá es demasiado pronto para ponernos a pensar en esto.

Debería ser poco más que un Insert en la Base de Datos. Y aquí empiezan las dudas. Para algunas personas, usar directamente un ORM como Entity Framework en este contexto se considera como una práctica aceptable. En PHP se podría utilizar Doctrine y su Entity Manager. En otros lenguajes, existen ORM equivalentes. La pregunta que me hago es ¿es lo bastante fina la envoltura que proporciona el ORM para acceder a la base de datos? ¿Qué hay de la testabilidad de la solución? ¿Necesitamos un ORM tan pronto en el desarrollo? ¿Podríamos posponer estas decisiones?

Intentemos analizar los problemas:

  • Usar un ORM, o incluso una librería de más bajo nivel como Doctrine DBAL o incluso PDO, sigue siendo hablar con una tecnología del mundo real. Podríamos contraargumentar diciendo que esas librerías ya ponen la necesaria abstracción entre nuestro código y la tecnología.
  • Se trataría de usar librerías de terceras partes para interactuar con tecnologías concretas. Sigue habiendo una dependencia con un detalle de implementación. Transaction script me permite usar una capa fina de abstracción que me permita separar la idea de guardar los datos (abstracción representada por una interfaz) y la forma concreta de guardarlos (implementación usando el patrón adapter).

¿O es que estoy intentando diseñar por encima de mis posibilidades?

A ver. Reconozco que este es un punto en el que la propuesta me genera incomodidad. Entiendo y comparto bastante la idea de que las features se implementen de la forma más sencilla posible. Pero, por otro lado, me disgusta acoplarme tanto y tan pronto a un framework.

Prueba de concepto con PDO

¿Merece la pena? Depende un poco. Básicamente, con PDO tenemos que ocuparnos de más detalles de bajo nivel que una librería como Doctrine DBAL resuelve fácilmente, especialmente si ya estás usando un Symfony para “coser” la aplicación. A la larga, acabarías montando un envoltorio alrededor de PDO para simplificar un poco su uso, algo que te proporciona DBAL.

Aunque una vez montado, el resultado sería muy similar, por lo que no voy a incluirlo.

Prueba de concepto con Doctrine DBAL

El siguiente código es una prueba de como podría quedar SendProposalHandler usando una librería como Doctrine DBAL, que nos proporciona un envoltorio relativamente ligero por encima de la PDO, que es la abstracción de acceso a bases de datos de PHP. De este modo, evitamos algunos problemas comunes de seguridad y compatibilidad.

No es la versión definitiva, por supuesto, pero es suficiente para hacernos una idea de la problemática. En pocas palabras, lo que hacemos es crear una query que inserta los datos recibidos en la base de datos. Podríamos incluso escribir la query SQL directamente para simplificar el código, pero tendríamos que ocuparnos de varios detalles.

Como os podréis imaginar, desarrollar esto con TDD puede resultar bastante poco productivo.

class SendProposalHandler
{
    private Connection $connection;

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


    public function __invoke(SendProposal $command): SendProposalResponse
    {
        $builder = $this->connection->createQueryBuilder();

        $builder->insert('proposals')->values(
            [
                'id' => '?',
                'title' => '?',
                'description' => '?',
                'author' => '?',
                'email' => '?',
                'type' => '?',
                'sponsored' => '?',
                'location' => '?',
                'status' => '?',
            ]
        )
            ->setParameter(0, 'proposal-id')
            ->setParameter(1, $command->title)
            ->setParameter(2, $command->description)
            ->setParameter(3, $command->author)
            ->setParameter(4, $command->email)
            ->setParameter(5, $command->type)
            ->setParameter(6, $command->sponsored)
            ->setParameter(7, $command->location)
            ->setParameter(8, 'waiting')
        ;

        $builder->executeQuery();

        return new SendProposalResponse(true, 'proposal-id', $command->title);
    }
}

Aquí nos faltan varias cosas. Por ejemplo, la gestión de la transacción, la validación de los datos antes de pasarlos a la base de datos y completarlos con algunos más: identidad, estado inicial y, posiblemente, marcarlo con una fecha de recepción.

Uno de los problemas que nos plantea es que vamos a tener mezclada lógica de distintos niveles de abstracción, con mucho detalle sobre lo que ocurre en la parte de base de datos. Esto invalida un poco la idea original de hacer más sencillo el mantenimiento del código. Si me pides opinión, prefiero añadir un poco de estructura para ganar en claridad y testeabilidad. De hecho, lo que hace Bogard con Entity Framework y Automapper es esconder esos detalles.

Por otro lado, un elemento que no he mencionado, pero que cae de cajón, es que necesitamos añadir todo lo necesario para tener una base de datos lista para trabajar con las tablas necesarias. En este tipo de tareas, un Framework puede ayudar mucho al proporcionarnos herramientas de migración. Ese código lo podréis ver en el repositorio.

Prueba de concepto con ORM

¿Podemos simplificar el código usando un ORM? Vamos a verlo. En este caso, utilizaremos Doctrine, lo que nos debería resultar similar a utilizar Entity Framework.

class SendProposalHandler
{
    private EntityManagerInterface $em;

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


    public function __invoke(SendProposal $command): SendProposalResponse
    {
        $proposal = new Proposal();
        $proposal->setId('proposal-id');
        $proposal->setTitle($command->title);
        $proposal->setDescription($command->description);
        $proposal->setAuthor($command->author);
        $proposal->setEmail($command->email);
        $proposal->setType($command->type);
        $proposal->setSponsored($command->sponsored);
        $proposal->setLocation($command->location);
        $proposal->setStatus('waiting');
        $proposal->setReceivedAt(new \DateTimeImmutable());

        $this->em->persist($proposal);
        $this->em->flush();

        return new SendProposalResponse(true, $proposal->getId(), $proposal->getTitle());
}

Aunque siguen faltando los mismos elementos que en el ejemplo anterior, podemos apreciar cuanto se reduce el tamaño del código y aumenta su legibilidad. Como nota, en este caso mencionar que aprovechando la ocasión he añadido el campo receivedAt que faltaba (seguramente harán falta más, pero es un problema del futuro, no de ahora).

El hecho de que el código ahora sea más fácil de leer y que esconda muchos detalles facilita ver algunos problemas de la solución actual.

Como ya hemos mencionado antes, nos faltaría un generador de identidades. Ahora mismo, asignamos una identidad fija, con lo que la feature (Gherkin) va a fallar si la ejecutamos más de una vez. Este generador de identidades no sería más que un envoltorio para una librería que nos proporcione el tipo de identificador que queremos usar. En general, dado que en las aplicaciones comerciales queremos identificadores únicos tipo uuid, ulid, etc., que son no deterministas, lo mejor es tenerlos como servicios.

También deberíamos abstraer el reloj del sistema. Todo esto añade complejidad a corto plazo, pero mejora nuestra capacidad para testear. Inevitablemente, surgirá la pregunta: ¿dónde vamos a colocarlo si es algo transversal?

Por otro lado, necesitamos introducir un objeto Proposal como entidad de Doctrine:

#[ORM\Entity(repositoryClass: ProposalRepository::class)]
class Proposal
{
    #[ORM\Id]
    #[ORM\Column(length: 255)]
    private ?string $id = null;

    #[ORM\Column(length: 255)]
    private ?string $title = null;

    #[ORM\Column(type: Types::TEXT)]
    private ?string $description = null;

    #[ORM\Column(length: 255)]
    private ?string $author = null;

    #[ORM\Column(length: 255)]
    private ?string $email = null;

    #[ORM\Column(length: 255)]
    private ?string $type = null;

    #[ORM\Column]
    private ?bool $sponsored = null;

    #[ORM\Column(length: 255)]
    private ?string $location = null;

    #[ORM\Column(length: 255)]
    private ?string $status = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $received_at = null;

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

    public function setId(string $id): static
    {
        $this->id = $id;

        return $this;
    }

    public function getTitle(): ?string
    {
        return $this->title;
    }

    public function setTitle(string $title): static
    {
        $this->title = $title;

        return $this;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function setDescription(string $description): static
    {
        $this->description = $description;

        return $this;
    }

    public function getAuthor(): ?string
    {
        return $this->author;
    }

    public function setAuthor(string $author): static
    {
        $this->author = $author;

        return $this;
    }

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

    public function setEmail(string $email): static
    {
        $this->email = $email;

        return $this;
    }

    public function getType(): ?string
    {
        return $this->type;
    }

    public function setType(string $type): static
    {
        $this->type = $type;

        return $this;
    }

    public function isSponsored(): ?bool
    {
        return $this->sponsored;
    }

    public function setSponsored(bool $sponsored): static
    {
        $this->sponsored = $sponsored;

        return $this;
    }

    public function getLocation(): ?string
    {
        return $this->location;
    }

    public function setLocation(string $location): static
    {
        $this->location = $location;

        return $this;
    }

    public function getStatus(): ?string
    {
        return $this->status;
    }

    public function setStatus(string $status): static
    {
        $this->status = $status;

        return $this;
    }

    public function getReceivedAt(): ?\DateTimeImmutable
    {
        return $this->received_at;
    }

    public function setReceivedAt(\DateTimeImmutable $received_at): static
    {
        $this->received_at = $received_at;

        return $this;
    }
}

Y su repositorio:

/**
 * @extends ServiceEntityRepository<Proposal>
 */
class ProposalRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Proposal::class);
    }
}

Todo ello, junto a las configuraciones necesarias para coserlo todo y que pueda funcionar. Dejo esos detalles fuera del artículo.

Prueba de concepto con abstracción

¿El ORM es una abstracción? En todo caso es una abstracción genérica de la capa de persistencia. Supone una ventaja con respecto a usar directamente las librerías del lenguaje de más bajo nivel (PDO en PHP), o incluso una capa ligera de abstracción como DBAL.

Por otro lado, la solución con ORM requiere introducir un objeto que representa la entidad Proposal, fuertemente acoplado al propio ORM, claro, lo que nos obliga a mantener getters y setters, necesarios para que Doctrine haga su magia. Esto parece aceptable en VSA bajo el argumento de que el scope de esta entidad es esta única feature o caso de uso. No es un objeto de dominio y no tiene comportamiento relevante en esta operación. Se trata básicamente de un DTO.

¿Cuál es mi problema, entonces? Sigue siendo una dependencia de terceras partes, lo que nos expone a cambios de interfaz, conflictos potenciales con otras librerías, deprecations, etc. Esto ya nos daría un buen motivo para abstraer un poco su uso mediante un patrón adapter. Lo cual, por otro lado, nos va a permitir tanto posponer la decisión sobre la forma concreta de implementarlo, como la separación de los detalles de implementación que nos facilitará tanto el testeo como la optimización.

Esta abstracción no está destinada a ser compartida por el resto de la aplicación. No se trata de un repositorio al estilo de DDD, ni de reinventar el ORM. Se trata de ocultar los detalles, permitiendo además la posibilidad de reemplazar fácilmente las implementaciones.

Por otra parte, es relativamente fácil desarrollar esta solución como un refactor de cualquiera de las anteriores. La clase Proposal puede ser utilizada en cualquiera de las tres soluciones como un Write Model y, aunque en este caso, contiene las anotaciones (atributos en PHP) que la ligan al ORM Doctrine, podríamos usarla como transporte de datos independientemente de la tecnología concreta.

Sin más dilación, esta es la idea:

class SendProposalHandler
{
    private StoreProposal $storeProposal;

    public function __construct(StoreProposal $storeProposal)
    {
        $this->storeProposal = $storeProposal;
    }

    public function __invoke(SendProposal $command): SendProposalResponse
    {
        $proposal = new Proposal();
        $proposal->setId('proposal-id-2');
        $proposal->setTitle($command->title);
        $proposal->setDescription($command->description);
        $proposal->setAuthor($command->author);
        $proposal->setEmail($command->email);
        $proposal->setType($command->type);
        $proposal->setSponsored($command->sponsored);
        $proposal->setLocation($command->location);
        $proposal->setStatus('waiting');
        $proposal->setReceivedAt(new \DateTimeImmutable());

        try {
            ($this->storeProposal)($proposal);
            return new SendProposalResponse(true, $proposal->getId(),
                $proposal->getTitle());
        } catch (\Exception $e) {
            return new SendProposalResponse(false, $proposal->getId(),
                $e->getMessage());
        }
    }
}

Que nos exige introducir una interfaz:

interface StoreProposal
{
    public function __invoke(Proposal $proposal);
}

Y, al menos, una implementación. Por ejemplo, la siguiente basada en Doctrine:

final class DoctrineStoreProposal implements StoreProposal
{
    private EntityManagerInterface $em;

    public function __construct(EntityManagerInterface $em)
    {
        $this->em = $em;
    }

    public function __invoke(Proposal $proposal): void
    {
        $this->em->beginTransaction();
        try {
            $this->em->persist($proposal);
            $this->em->flush();
            $this->em->commit();
        } catch (Exception $e) {
            $this->em->rollback();
            throw new ProposalNotStored("Proposal could not be stored", 1, $e);
        }
    }
}

Pinta bien, ¿no? Con esta variante SendProposalHandler quedaría bastante limpio, ocultando los detalles. Por otro lado, habiendo abstraído el método de almacenamiento en una interfaz, no estamos atadas a una tecnología o implementación concreta.

¿Inconvenientes?

Quizá el más llamativo es que ha crecido el número de archivos, y eso que nos faltan tres o cuatro más. En otros lenguajes podríamos juntar varias clases en un archivo, o aprovecharnos de las inner classes, cosa que no podemos hacer en PHP.

src/ForSendProposals
└── SendProposal
    ├── DoctrineStoreProposal.php
    ├── Proposal.php
    ├── ProposalNotStored.php
    ├── ProposalRepository.php
    ├── SendProposal.php
    ├── SendProposalController.php
    ├── SendProposalHandler.php
    ├── SendProposalResponse.php
    └── StoreProposal.php

En PHP la mejor forma de organizar el código sería mediante sub-carpetas y namespaces.

En cualquier caso, hay que tener presente que estos archivos no se van a compartir con otras features o casos de uso. Son exclusivos de SendProposal.

Mi decisión

Yo creo que me voy a inclinar por esta última opción: abstraer el almacenamiento de Proposal, aunque el aspecto final será un poco distinto. Con esto no estoy declarando que esta forma sea más correcta que las otras. Sencillamente: es la que más encaja en mi forma de entender el desarrollo y la que considero que me va a resultar más barato de mantener a medio y largo plazo.

En principio, la VSA promueve no introducir abstracciones. Se refiere a hacerlo prematuramente y de forma global a la aplicación. Pero no se puede decir que las prohíba, sino que deben demostrarse necesarias y vivir únicamente en el ámbito en el que tienen sentido.

Sin embargo, en el contexto de una feature, diría que es buena idea introducir el tipo de abstracciones que nos proporcionan puntos de articulación como en nuestro ejemplo.

Empezando, de nuevo, con un test

Al principio, consideraba que este test puede estar bien para empezar. Los métodos build* construyen los dobles que usaremos como colaboradores de SendProposalHandler:

  • StoreProposal, que representa el servicio de persistencia que guardará los objetos.
  • IdentityProvider, que representa un proveedor de identificadores, independiente de la persistencia.
  • Clock, que representa un servicio de reloj que nos proporciona la hora del sistema en producción, y una arbitraria en test.
final class SendProposalHandlerTest extends TestCase
{
    /** @test */
    public function should_store_valid_proposal(): void
    {
        $storeProposal = $this->buildStoreProposal();

        $proposalId = '01HXE2R5JBCRKAA3K0BZ1TCXT2';
        $identityProvider = $this->buildIdentityProvider($proposalId);

        $now = new DateTimeImmutable();
        $clock = $this->buildClock($now);

        $handler = new SendProposalHandler(
            $storeProposal,
            $identityProvider,
            $clock
        );

        $proposalTitle = 'Proposal Title';
        $command = SendProposalExample::wellFormedWithTitle($proposalTitle);

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

        self::assertTrue($response->success);

        assertEquals($proposalTitle, $response->title);
        assertEquals($proposalId, $response->id);
    }

    private function buildStoreProposal(): MockObject|StoreProposal
    {
        $storeProposal = $this->createMock(StoreProposal::class);
        $storeProposal->expects(self::once())->method('__invoke');
        return $storeProposal;
    }

    private function buildIdentityProvider(string $proposalId
    ): MockObject|IdentityProvider {
        $identityProvider = $this->createMock(IdentityProvider::class);
        $identityProvider->method('next')->willReturn($proposalId);
        return $identityProvider;
    }

    private function buildClock(DateTimeImmutable $now): MockObject|Clock
    {
        $clock = $this->createMock(Clock::class);
        $clock->method('now')->willReturn($now);
        return $clock;
    }
}

Esto me lleva a construir un SendProposalHandler con este aspecto, muy similar a nuestra prueba de concepto:

class SendProposalHandler
{
    private StoreProposal $storeProposal;
    private IdentityProvider $identityProvider;
    private Clock $clock;

    public function __construct(
        StoreProposal $storeProposal,
        IdentityProvider $identityProvider,
        Clock $clock
    )
    {
        $this->storeProposal = $storeProposal;
        $this->identityProvider = $identityProvider;
        $this->clock = $clock;
    }

    public function __invoke(SendProposal $command): SendProposalResponse
    {
        $proposal = new Proposal();
        $proposal->setId($this->identityProvider->next());
        $proposal->setTitle($command->title);
        $proposal->setDescription($command->description);
        $proposal->setAuthor($command->author);
        $proposal->setEmail($command->email);
        $proposal->setType($command->type);
        $proposal->setSponsored($command->sponsored);
        $proposal->setLocation($command->location);
        $proposal->setStatus('waiting');
        $proposal->setReceivedAt($this->clock->now());

        try {
            ($this->storeProposal)($proposal);
            return new SendProposalResponse(true, $proposal->getId(),
                $proposal->getTitle());
        } catch (\Exception $e) {
            return new SendProposalResponse(false, $proposal->getId(),
                $e->getMessage());
        }
    }
}

Pero ahora, fíjate qué pasa si ocultamos los detalles de la creación de Proposal en un método para igual los niveles de abstracción:

class SendProposalHandler
{
    private StoreProposal $storeProposal;
    private IdentityProvider $identityProvider;
    private Clock $clock;

    public function __construct(
        StoreProposal $storeProposal,
        IdentityProvider $identityProvider,
        Clock $clock
    )
    {
        $this->storeProposal = $storeProposal;
        $this->identityProvider = $identityProvider;
        $this->clock = $clock;
    }

    public function __invoke(SendProposal $command): SendProposalResponse
    {
        $proposal = $this->buildProposalFromCommandData($command);

        try {
            ($this->storeProposal)($proposal);
            return new SendProposalResponse(true, $proposal->getId(),
                $proposal->getTitle());
        } catch (\Exception $e) {
            return new SendProposalResponse(false, $proposal->getId(),
                $e->getMessage());
        }
    }

    private function buildProposalFromCommandData(SendProposal $command): Proposal
    {
        $proposal = new Proposal();
        $proposal->setId($this->identityProvider->next());
        $proposal->setTitle($command->title);
        $proposal->setDescription($command->description);
        $proposal->setAuthor($command->author);
        $proposal->setEmail($command->email);
        $proposal->setType($command->type);
        $proposal->setSponsored($command->sponsored);
        $proposal->setLocation($command->location);
        $proposal->setStatus('waiting');
        $proposal->setReceivedAt($this->clock->now());
        
        return $proposal;
    }
}

Como se puede ver, dos de las dependencias que pasamos a SendProposalHandler se usan solo para construir Proposal. Tendría sentido llevarse este código a una clase ProposalBuilder.

final class ProposalBuilder
{
    private IdentityProvider $identityProvider;
    private Clock $clock;

    public function __construct(IdentityProvider $identityProvider, Clock $clock)
    {
        $this->identityProvider = $identityProvider;
        $this->clock = $clock;
    }

    public function fromCommandData(SendProposal $command): Proposal {
        $proposal = new Proposal();
        $proposal->setId($this->identityProvider->next());
        $proposal->setTitle($command->title);
        $proposal->setDescription($command->description);
        $proposal->setAuthor($command->author);
        $proposal->setEmail($command->email);
        $proposal->setType($command->type);
        $proposal->setSponsored($command->sponsored);
        $proposal->setLocation($command->location);
        $proposal->setStatus('waiting');
        $proposal->setReceivedAt($this->clock->now());
        
        return $proposal;
    }
}

Con lo que resultaría el siguiente handler, mucho más sencillo y con una lógica que se limita a coordinar las dos acciones principales: iniciar un objeto Proposal y persistirlo.

class SendProposalHandler
{
    private StoreProposal $storeProposal;
    private ProposalBuilder $builder;

    public function __construct(
        StoreProposal $storeProposal,
        ProposalBuilder $builder
    )
    {
        $this->storeProposal = $storeProposal;
        $this->builder = $builder;
    }

    public function __invoke(SendProposal $command): SendProposalResponse
    {
        $proposal = $this->builder->fromCommandData($command);

        try {
            ($this->storeProposal)($proposal);
            return new SendProposalResponse(true, $proposal->getId(),
                $proposal->getTitle());
        } catch (\Exception $e) {
            return new SendProposalResponse(false, $proposal->getId(),
                $e->getMessage());
        }
    }
}

Últimas implementaciones

Solo nos faltaría incluir las implementaciones de IdentityProvider y de Clock, así como coserlo todo en el contenedor de dependencias de Symfony. El proveedor de identidades:

final class UlidIdentityProvider implements IdentityProvider
{

    public function next(): string
    {
        $ulid = Ulid::generate();

        return (string)$ulid;
    }
}

Y el servicio de reloj:

final class SystemClock implements Clock
{

    public function now(): DateTimeImmutable
    {
        return new DateTimeImmutable();
    }
}

Ambos son básicamente adaptadores para usar librerías de terceras partes y la lógica es trivial, por lo que no voy a introducir tests aquí.

Finalizando el Handler

Con esto, una vez configurado el contenedor, el test Gherkin vuelve a pasar. Hay un paso más que tenemos comentado:

#Then The proposal appears in the list of sent proposals

De hecho, no estoy del todo seguro de como definir este paso. La motivación es verificar no sola el reconocimiento de la propuesta, sino que esta efectivamente ha sido añadida al sistema. Ahora mismo, el reconocimiento no garantiza eso. Para comprobarlo, lo lógico sería recuperarla.

Aquí tengo dos opciones que podrían valer:

  • Usar la header Location que debería devolver el endpoint y hacer una request a la URI proporcionada.
  • Obtener la lista de propuestas asociadas a un email y verificar que existe la que acabamos de introducir.

En ambos casos se trata de crear una nueva feature, lo que resulta muy interesante de cada a entender como evolucionar esta arquitectura. Así que lo dejaremos para el próximo artículo.

Por otro lado, tenemos la siguiente situación:

src/ForSendProposals
└── SendProposal
    ├── Clock.php
    ├── DoctrineStoreProposal.php
    ├── IdentityProvider.php
    ├── Proposal.php
    ├── ProposalBuilder.php
    ├── ProposalNotStored.php
    ├── ProposalRepository.php
    ├── SendProposal.php
    ├── SendProposalController.php
    ├── SendProposalHandler.php
    ├── SendProposalResponse.php
    ├── StoreProposal.php
    ├── SystemClock.php
    └── UlidIdentityProvider.php

¿Muchos archivos? Verdaderamente tenemos unos cuantos. En PHP no son posibles las inner classes, las cuales podrían ayudar a simplificar el paquete. Hay forma de simular algo parecido, pero creo que introduce más ruido del deseable. En su lugar, tendríamos que usar los Namespaces, lo que conlleva introducir sub-paquetes (o sub-carpetas). Estas residirán dentro de este paquete a menos que la evolución del proyecto lo reclame. Esta es mi propuesta que, quizá no sea la definitiva.

src/ForSendProposals
└── 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

Conclusiones de la segunda parte

En este artículo he explorado diversas soluciones para implementar la feature de Enviar Propuestas. El hecho de no tener que preocuparse por mantener una estructura común de la aplicación ha hecho más cómodo el desarrollo en algunos aspectos. Principalmente, no hay que tomar muchas decisiones acerca de donde poner ciertos elementos, ni hay que tomar precauciones sobre como van a interferir en otras partes del sistema. Cuando haya que implementar otras prestaciones, será todo independiente, a no ser que resulte más ventajoso compartir cosas.

Por otro lado, no puedo evitar introducir algunas prácticas como inversión de control en las dependencias. En el corto plazo, lo sencillo sería acoplarse a librerías y sistema. Sin embargo, creo que es un pequeño esfuerzo que merece la pena.

Puedes seguir el desarrollo del proyecto en el siguiente artículo.

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

May 10, 2024

Etiquetas: design-patterns  

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