De código acoplado al framework a microservicios pasando por DDD

El pasado 25 de Octubre tuvimos la oportunidad de dar una charla. Decidimos hablar sobre cómo ha evolucionado nuestro código desde los inicios, donde programábamos de forma acoplada al framework, hasta hoy día, donde intentamos desacoplar la lógica de negocio de la infrastructura que ésta necesite, incluyendo el punto de entrada para que así podamos reutilizar los casos de uso. En resumen, hicimos un repaso hablando de SOLID, testing, arquitectura hexagonal, y microservicios.

Público sentado

El evento lo organizó la gente de Nubelo, a la cuál agradecemos que coordinara toda la gestión :) . También agradecer a la gente de IronHack, que pusieron el sitio para celebrar el evento y grabaron la sesión. Por último, dar las gracias también a Moritz, que puso las cervezuelas para amenizar el tercer tiempo :D

Público de pie conversando entre ellos

Hecha la introducción, ¡al turrón!

Código acoplado al framework

Las particularidades de esta etapa se pueden ver en el vídeo a partir del minuto 6:50. Hablamos principalmente de la falta de aplicar principios que fomenten la cohesión, mantenibilidad, y cambiabilidad de nuestra aplicación.

Controlador

Como podéis ver en el ejemplo, realmente las particularidades de este tipo de bases de código van más allá incluso del hecho de acoplarse al framework. Como vemos, estaríamos haciendo uso de Doctrine (el ORM de la aplicación) de forma directa, sin implementar ningún tipo de nivel de indirección a modo de contrato con el fin de desacoplarnos. Este tipo de cosas son en las que se concretan aspectos como la poca cambiabilidad. Además, toda la lógica del caso de uso la estaríamos ejecutando en el propio controlador, penalizando así el poder reutilizar esta lógica desde otros puntos de entrada a la aplicación. Estos conceptos los hemos tratado en vídeos anteriores sobre SOLID:

// CourseController.php

<?php
namespace AppBundle\Controller;
use FOS\RestBundle\Controller\FOSRestController;
use Symfony\Component\HttpFoundation\Request;
final class CourseController extends FOSRestController
{
    public function getCourseAction(Request $request)
    {
        return $this->getDoctrine()
            ->getEntityManager()
            ->createQueryBuilder()
            ->select('c', 'v')
            ->from('AppBundle\Model\Course', 'c')
            ->where('c.level', '>', $request->get('from_level', 0))
            ->getQuery()
            ->execute();
    }
    public function getCourseVideosAction($courseId)
    {
        return $this->getDoctrine()
            ->getEntityManager()
            ->createQueryBuilder()
            ->select('c', 'v')
            ->from('AppBundle\Model\Course', 'c')
            ->leftJoin('a.Video', 'v')
            ->where('c.id', '=', $courseId)
            ->getQuery()
            ->execute();
    }
}

Ver gist

Test

En el minuto 12:25 empezamos a hablar de las implicaciones que tiene este tipo de código en cuanto al testing. Si venimos generando código acoplado, nos será más difícil de testear. El tipo de código anterior suele derivar en unos test como el siguiente. Donde lejos de testear nuestra lógica de negocio real, lo que estamos probando es que el ORM realmente hace lo que dice hacer. Es decir, este tipo de test no aporta ningún valor. Además, como podemos ver, estamos haciendo que nuestro test sea sumamente frágil ya que lo acoplamos a las tripas internas del ORM. Esto hace que, si cambia cómo trabaja el framework o el ORM, deberemos cambiar nuestros test. 💩! Hemos hablado alguna vez sobre estos conceptos en los vídeos sobre testing:

// CourseControllerTest.php

<?php

namespace AppBundle\Tests\Controller;

use AppBundle\Controller\CourseController;
use Doctrine\Bundle\DoctrineBundle\Registry;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\QueryBuilder;
use PHPUnit_Framework_TestCase;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;

final class CourseControllerTest extends PHPUnit_Framework_TestCase
{
    public function testGetCoursesFilteredByLevel()
    {
        $fromLevel = 0;
        $request   = new Request(['from_level' => $fromLevel]);

        $container     = \Mockery::mock(ContainerInterface::class);
        $doctrine      = \Mockery::mock(Registry::class);
        $entityManager = \Mockery::mock(EntityManager::class);
        $queryBuilder  = \Mockery::mock(QueryBuilder::class);
        $query         = \Mockery::mock(AbstractQuery::class);

        $container->shouldReceive('has')
            ->once()
            ->with('doctrine')
            ->andReturn(true);
        $container->shouldReceive('get')
            ->once()
            ->with('doctrine')
            ->andReturn($doctrine);
        $doctrine->shouldReceive('getEntityManager')
            ->once()
            ->withNoArgs()
            ->andReturn($entityManager);
        $entityManager->shouldReceive('createQueryBuilder')
            ->once()
            ->withNoArgs()
            ->andReturn($queryBuilder);
        $queryBuilder->shouldReceive('select')
            ->once()
            ->with('c', 'v')
            ->andReturn($queryBuilder);
        $queryBuilder->shouldReceive('from')
            ->once()
            ->with('AppBundle\Model\Course', 'c')
            ->andReturn($queryBuilder);
        $queryBuilder->shouldReceive('where')
            ->once()
            ->with('c.level', '>', $fromLevel)
            ->andReturn($queryBuilder);
        $queryBuilder->shouldReceive('getQuery')
            ->once()
            ->withNoArgs()
            ->andReturn($query);
        $query->shouldReceive('execute')
            ->once()
            ->withNoArgs()
            ->andReturn(
                [
                    [
                        'title' => 'Codely mola',
                        'level' => 2,
                    ],
                    [
                        'title' => 'Aprende a decir basicamente como Javi',
                        'level' => 5,
                    ],
                ]
            );

        $controller = new CourseController();
        $controller->setContainer($container);
        $controllerResult = $controller->getCourseAction($request);
        $this->assertEquals(
            [
                [
                    'title' => 'Codely mola',
                    'level' => 2,
                ],
                [
                    'title' => 'Aprende a decir basicamente como Javi',
                    'level' => 5,
                ],
            ],
            $controllerResult
        );
    }
}

Ver gist

Arquitectura Hexagonal + CQRS + Módulos de Domain-Driven Design

Una vez habiendo visto las particularidades de un diseño acoplado al framework y una estrategia de testing con la que realmente no estamos obteniendo el valor que creemos obtener, pasamos a una segunda fase. En esta siguiente fase que se explica a partir del minuto 15:55 del vídeo, vemos un tipo de código que introduce toda una serie de conceptos en forma de convenciones y capas de indirección adicionales a la fase anterior, pero que a cambio, tienen sus beneficios como ahora veremos :D Resumen:

  • Antes, 0 niveles de indirección: Controlador (ejecuta lógica)
  • Después, 3 niveles de indirección: Controlador (construye comando y lo tira al bus) -> Command Bus (lleva el comando hasta su handler) -> Command Handler (instancia modelos de dominio en base a datos del comando, e invoca al Application Service) -> Application Service (ejecuta lógica)

Como todo, al final somos nosotros los que más conocimiento tenemos de nuestro contexto, y deberemos decidir si tiene sentido añadir todo este tinglado, o no. Por ello, vamos a analizar cada uno de estos elementos y ver qué beneficios aporta y si nos compensan en nuestro contexto.

Controlador

Ahora nuestro Controlador únicamente tiene la responsabilidad de hacer de enlace entre el framework (punto de entrada a la aplicación), y el caso de uso a ejecutar. De hecho, no lo hace de forma directa atacando al Application Service, si no que lo único que hace es construir un Comando en forma de DTO, y tirarlo a un Command Bus. Este bus es el que realmente tiene las asociaciones entre qué Command Handler sabe tratar cada uno de los Commands.

// VideoController.php

<?php

namespace CodelyTv\Api\Controller;

use CodelyTv\Context\Meetup\Module\Video\Domain\Create\CreateVideoCommand;
use CodelyTv\Infrastructure\Bus\Command\CommandBus;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

final class VideoController extends Controller
{
    private $bus;

    public function __construct(CommandBus $bus)
    {
        $this->bus = $bus;
    }

    public function createAction(string $id, Request $request)
    {
        $command = new CreateVideoCommand(
            $id,
            $request->get('title'),
            $request->get('url'),
            $request->get('course_id')
        );

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

        return new Response('', Response::HTTP_CREATED);
    }
}

Ver gist

Command Handler

Este nivel de indirección adicional que también hemos introducido tiene la finalidad de saber traducir los datos que hay en el comando, a los objetos de dominio que espera el Application Service. Es importante este detalle porque quiere decir que, hasta este punto, los datos que tratábamos eran datos en plano. Esto posibilita que podamos lanzar comandos entre distintos módulos/contextos/servicios que ni siquieracompartan lenguaje de programación. Una vez traduce los datos en crudo a modelos de dominio, también sabe qué Application Service ejecutar y lo invoca.

// CreateVideoCommandHandler.php

<?php

namespace CodelyTv\Context\Meetup\Module\Video\Domain\Create;

use CodelyTv\Context\Meetup\Module\Video\Domain\VideoId;
use CodelyTv\Context\Meetup\Module\Video\Domain\VideoTitle;
use CodelyTv\Context\Meetup\Module\Video\Domain\VideoUrl;
use CodelyTv\Shared\Domain\CourseId;

final class CreateVideoCommandHandler
{
    private $creator;

    public function __construct(VideoCreator $creator)
    {
        $this->creator = $creator;
    }

    public function __invoke(CreateVideoCommand $command)
    {
        $id       = new VideoId($command->id());
        $title    = new VideoTitle($command->title());
        $url      = new VideoUrl($command->url());
        $courseId = new CourseId($command->courseId());

        $this->creator->create($id, $title, $url, $courseId);
    }
}

Ver gist

Application Service

Al introducir el concepto de Servicio de Aplicación, donde realmente implementamos la lógica del caso de uso, hacemos que éste se pueda aprovechar desde otros puntos de entrada. Además de ejecutar la lógica de negocio que pertoque, el Application Service hará de barrera a nivel de transacción y publicación de eventos. Es decir, debe garantizar la atomicidad a la hora de ejecutar una determinada lógica, asegurando así que si se ejecuta algo en nuestro sistema, eso tiene los side effects que debe (persistir y publicar evento por ejemplo).

// VideoCreator.php

<?php

namespace CodelyTv\Context\Meetup\Module\Video\Domain\Create;

use CodelyTv\Context\Meetup\Module\Video\Domain\Video;
use CodelyTv\Context\Meetup\Module\Video\Domain\VideoId;
use CodelyTv\Context\Meetup\Module\Video\Domain\VideoRepository;
use CodelyTv\Context\Meetup\Module\Video\Domain\VideoTitle;
use CodelyTv\Context\Meetup\Module\Video\Domain\VideoUrl;
use CodelyTv\Infrastructure\Bus\Event\DomainEventPublisher;
use CodelyTv\Shared\Domain\CourseId;

final class VideoCreator
{
    private $repository;
    private $publisher;

    public function __construct(VideoRepository $repository, DomainEventPublisher $publisher)
    {
        $this->repository = $repository;
        $this->publisher  = $publisher;
    }

    public function create(VideoId $id, VideoTitle $title, VideoUrl $url, CourseId $courseId)
    {
        $video = Video::create($id, $title, $url, $courseId);

        $this->repository->save($video);

        $this->publisher->publish($video->pullDomainEvents());
    }
}

Ver gist

Test

Por último, destacar también cómo cambia nuestro test. Ahora sí estamos probando que la lógica de negocio se comporta como esperamos. Estaríamos testeando desde el controlador hacia dentro. Es decir, estamos garantizando que, dado que se tire un comando al command bus, se van a llamar a los repositorios pertinentes para almacenar los cambios, y se van a publicar los eventos de dominio que pertoque.

// VideoModuleUnitTestCase.php

<?php

namespace CodelyTv\Context\Meetup\Module\Video\Test\PhpUnit;

use CodelyTv\Context\Meetup\Module\Video\Domain\Video;
use CodelyTv\Context\Meetup\Module\Video\Domain\VideoRepository;
use CodelyTv\Context\Meetup\Test\PhpUnit\MeetupContextUnitTestCase;
use Mockery\MockInterface;
use function CodelyTv\Test\similarTo;

abstract class VideoModuleUnitTestCase extends MeetupContextUnitTestCase
{
    private $repository;

    /** @return VideoRepository|MockInterface */
    protected function repository()
    {
        return $this->repository = $this->repository ?: $this->mock(VideoRepository::class);
    }

    protected function shouldSaveVideo(Video $video)
    {
        $this->repository()
            ->shouldReceive('save')
            ->with(similarTo($video))
            ->once()
            ->andReturn($video);
    }
}

Ver gist

Como estas dos piezas (repositorio y publicador de eventos de dominio) son de infraestructura y queremos evitar la fragilidad que veíamos en el caso anterior al acoplarnos al ORM, lo que aplicamos es el Principio de Inversión de Dependencias llevado al nivel de Ports and Adapters de la Arquitectura Hexagonal. Realmente hacemos un mock de los contratos a nivel de dominio para así poder testear únicamente el paso de mensajes entre nuestro Subject Under Test y sus colaboradores.

// VideoModuleUnitTestCase.php

<?php

namespace CodelyTv\Context\Meetup\Module\Video\Test\PhpUnit;

use CodelyTv\Context\Meetup\Module\Video\Domain\Video;
use CodelyTv\Context\Meetup\Module\Video\Domain\VideoRepository;
use CodelyTv\Context\Meetup\Test\PhpUnit\MeetupContextUnitTestCase;
use Mockery\MockInterface;
use function CodelyTv\Test\similarTo;

abstract class VideoModuleUnitTestCase extends MeetupContextUnitTestCase
{
    private $repository;

    /** @return VideoRepository|MockInterface */
    protected function repository()
    {
        return $this->repository = $this->repository ?: $this->mock(VideoRepository::class);
    }

    protected function shouldSaveVideo(Video $video)
    {
        $this->repository()
            ->shouldReceive('save')
            ->with(similarTo($video))
            ->once()
            ->andReturn($video);
    }
}

Ver gist

DDD Bounded Context y microservicios

Desde el minuto 37:38 en adelante comentamos otras estrategias a la hora de estructurar nuestra aplicación y qué implicaciones tendrían a nivel de código e infraestructura. Uno de los riesgos que corríamos con este tipo de charla era que, al intentar abarcar tanto, quedara todo demasiado en el aire y difícil de digerir para aquel que no tuviera conocimientos previos sobre el tema. Con lo cuál, para intentar evitar esto, preparamos la siguiente tabla donde intentamos poner de relieve las particularidades que consideramos clave de cada una de las estrategias que comentamos:

Tabla comparando código acoplado al framework vs módulos y Bounded Contexts de Domain-Driven Design vs Microservicios

Es una tabla que, tal y como decimos en la charla, hay que coger con pinzas. Desde el momento en el que sintetizas información para hacerla más digerible, se pierden muchos matices y excepciones. Y si además, agrupamos conceptos con tal de poner de relieve las diferencias entre ellos, puede interpretarse como una sucesión secuencial de estados -cuando realmente en muchos casos son aspectos ortogonales-. En cualquier caso, creímos oportuno incluir la tabla y comentarla porque pensamos que aporta valor. Tener algo así nos hubiera ayudado a nosotros en su día para, como mínimo, poder empezar a investigar un poco más y tirar del hilo :) . A día de hoy hemos comentado este tipo de temas en el vídeo de introducción a la Arquitectura Hexagonal, y planeamos explorarlos mucho más en futuros vídeos, con lo que si quieres estar al tanto, apúntate a la newsletter o subscríbete al canal de YouTube:

Material de la charla

Creemos que más allá de la sesión, todo el material que preparamos para la charla puede ser de utilidad para aclarar o ver a nivel de implementación algunos de los conceptos que explicamos. Con lo cuál, vamos a hacer un pequeño repaso por todo ello 😬

Después de todo este currazo 😅, simplemente comentar que si os ha sido útil o creéis que puede ayudar a vuestros contactos, nos ayudéis a llegar a más gente compartiendo el artículo, subscribiéndoos al canal de YouTube de CodelyTV, o tomándoos una birra a nuestra salud 😄

Sorteo camiseta

Y por último, ya que era una ocasión un tanto especial, decidimos hacer un pequeño sorteo de una camiseta de CodelyTV entre los asistentes al evento que rellenasen un formulario :P

Mockup camiseta CodelyTV

Tal y como acabamos de anunciar por Twitter, el afortunado ha sido Sergio Susa. ¡Felicidades! :D

Agradecimientos

Nos gustaría cerrar este post agradeciendo a todas esas personas que nos han ayudado a aprender todos estos conceptos que a día de hoy podemos transmitir, y también a las empresas que crean un entorno en el que podemos experimentar con todo este tipo de cosas. Desde letgo, que es el proyecto en el que estamos ahora, hasta Tangelo, Thatzad, y Uvinum, que son las empresas por las que hemos pasado. ¡¡¡Gracias!!!

Paga según tus necesidades

lite (sólo mensual)

Cargando…
al mes
  • Acceso a un subconjunto de cursos para sentar las bases para un código mantenible, escalable y testable
  • Factura de empresa
Popular

standard

Cargando…
Ahorra 121
Pago anual de 0
al mes
  • Catálogo completo de cursos
  • Retos de diseño y arquitectura
  • Vídeos de soluciones destacadas de los retos
  • Recibir ofertas de empleo verificadas por Codely
  • Factura de empresa

premium

Cargando…
Ahorra 89
Pago anual de 0
al mes
  • Todo lo anterior
  • Más beneficios próximamente

No subiremos el precio mientras mantengas tu suscripción activa