Krótka historia o tym jak projektuję aplikacje przy pomocy TDD

Kilka słów o mnie

@l3l0

Powiedzmy że chcemy zarejestrować użytkownika

Jak byście zaimplementowali funkcje tworzenia użytkownika w systemie?

<?php

declare(strict_types=1);

namespace Cocoders\SymfonyLiveWarsaw;

class User
{
    private $id;
    private $email;
    private $password;
    private $firstName;
    private $lastName;

    public function id(): int
    {
        return $this->id;
    }

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

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

    public function getFirstName(): ?string
    {
        return $this->firstName;
    }

    public function setFirstName(string $firstName): void
    {
        $this->firstName = $firstName;
    }

    public function getLastName(): ?string
    {
        return $this->lastName;
    }

    public function setLastName(string $lastName): void
    {
        $this->lastName = $lastName;
    }
}

DAO - gdzie problem?

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

class UserController extends AbstractController
{
   /**
    * @Route("/users", name="users", methods={"GET", "POST"})
    */
    public function registerUser(Request $request, UserPasswordEncoder $encoder): void
    {
        $form = $this->createForm(RegisterUserType::class, new User());
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $user = $form->getData();
            $user->setPassword($encoder->encodePassword($user, $user->getPlainPassword()));
            $this->getDoctrine()->persist($user);
            $this->getDoctrine()->flush();

            return $this->redirectToRoute('user', ['id' => $user->getId()]);
        }

        return $this->render();
    }
}
<?php

class UserController extends AbstractController
{
   /**
    * @Route("/users", name="users", methods={"GET", "POST"})
    */
    public function registerUser(Request $request): void
    {
        $form = $this->createForm(RegisterUserType::class, new User());
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            if ($this->getDoctrine()->getRepository(User::class)
                ->findOneByEmail($form->getData()->getEmail())) {
                // form error
                return $this->render();
            }
            $user = $form->getData();
            $user->setPassword($passwordEncoder->encodePassword($user, $user->getPlainPassword());
            $this->getDoctrine()->persist($user);
            $this->getDoctrine()->flush();

            // additional logic
            if ($this->someChecks) {
                $this->emailSender->send();
            }
            $this->firestoreUpdater->update();

            return $this->redirectToRoute('user', ['id' => $user->getId()]);
        }

        return $this->render();
    }
}

Kontroler - gdzie problem?

<?php

class CreateUserCommand extends Command
{
    // the name of the command (the part after "bin/console")
    protected static $defaultName = 'app:create-user';

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        if ($this->getDoctrine()->getRepository(User::class)->findOneByEmail($form->getData()->getEmail())) {
            // form error
            $output->writeln('User already exists');
            return;
        }
        $user = new User();
        if ($input->getOption('email')) {
           $user->setEmail($input->getOption('email'));
        }
        if ($input->setPassword('password')) {
            $user->setPassword($passwordEncoder->encodePassword(
                $user,
                $input->getOption('password')
            ));
        }
        $this->getDoctrine()->persist($user);
        $this->getDoctrine()->flush();

        // additional logic
        if ($this->someChecks) {
           $this->emailSender->send();
        }
        $this->firestoreUpdater->update()
    }
}
<?php

declare(strict_types=1);
  
namespace App\DataFixtures;

use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;

class AppFixtures extends Fixture
{
    public function load(ObjectManager $manager)
    {
        foreach (['leszek.prabucki@gmail.com', 'contact@cocoders.com'] as $email) {

            $user = new User();
            $user->setEmail($email);
            $user->setPassword($passwordEncoder->encodePassword(
                $user,
                'secretPasswd'
            ));
            $this->getDoctrine()->persist($user);
            $this->getDoctrine()->flush();

            $this->firestoreUpdater->update();
        }
    }
}

Gdyby tak skupić się na rozwiązaniu problemu, a potem podłączyć pod to framework?

Ok, zaprojektujmy sobie nasz projekt od nowa zostawiając framework na później

TDD

vendor/bin/phpspec desc "SymfonyLiveWarsaw\Domain\User"

<?php

declare(strict_types=1);

namespace spec\SymfonyLiveWarsaw\Domain;

use SymfonyLiveWarsaw\Domain\User;
use SymfonyLiveWarsaw\Domain\Email;
use SymfonyLiveWarsaw\Domain\PasswordHash;
use PhpSpec\ObjectBehavior;

/**
 * @mixin User
 */
class UserSpec extends ObjectBehavior
{
    function let()
    {
        $this->beConstructedWith(
            User\Id::fromString('35f15ad1-b764-4f22-a655-e2ef3873dfc3'),
            Email::fromString('leszek.prabucki@gmail.com'),
            PasswordHash::fromString('$argon2id$v=19$m=1024,t=2,p=2$WXl6Wk5zWWpPY3RvWFloMA$emM94iLGzIMjqDC/uk6UFlRIhXQQ72t1f67L+LZEVQA')
        );
    }

    function it_is_initialized_with_required_data_like_id_email_and_username()
    {
        $this->shouldBeAnInstanceOf(User::class);
    }

    function it_allows_to_get_email()
    {
        $this->email()->shouldBeLike(Email::fromString('leszek.prabucki@gmail.com'));
    }

    function it_allows_to_get_id()
    {
        $this->id()->shouldBeLike(User\Id::fromString('35f15ad1-b764-4f22-a655-e2ef3873dfc3'));
    }
}
<?php

declare(strict_types=1);

namespace SymfonyLiveWarsaw\Domain;

use SymfonyLiveWarsaw\Domain\User\PasswordHash;

class User
{
    /**
     * @var User\Id
     */
    private $id;
    /**
     * @var Email
     */
    private $email;
    /**
     * @var PasswordHash
     */
    private $passwordHash;

    public function __construct(User\Id $id, Email $email, PasswordHash $passwordHash)
    {
        $this->id = $id;
        $this->email = $email;
        $this->passwordHash = $passwordHash;
    }

    public function email(): Email
    {
        return $this->email;
    }

    public function id(): User\Id
    {
        return $this->id;
    }
}
<?php

declare(strict_types=1);

namespace spec\SymfonyLiveWarsaw\Domain;

use SymfonyLiveWarsaw\Domain\User;
use SymfonyLiveWarsaw\Domain\Email;
use SymfonyLiveWarsaw\Domain\User\PasswordHash;
use SymfonyLiveWarsaw\Domain\User\Fullname;
use PhpSpec\ObjectBehavior;

/**
 * @mixin User
 */
class UserSpec extends ObjectBehavior
{
    function let()
    {
        $this->beConstructedWith(
            User\Id::fromString('35f15ad1-b764-4f22-a655-e2ef3873dfc3'),
            Email::fromString('leszek.prabucki@gmail.com'),
            PasswordHash::fromString('$argon2id$v=19$m=1024,t=2,p=2$WXl6Wk5zWWpPY3RvWFloMA$emM94iLGzIMjqDC/uk6UFlRIhXQQ72t1f67L+LZEVQA')
        );
    }

    //...

    function it_can_have_full_name()
    {
        $this->fullname()->shouldBe(null);

        $this->changeFullname(Fullname::fromFirstAndLastName('Leszek', 'Prabucki'));
        $this->fullname()->shouldBeLike(Fullname::fromFirstAndLastName('Leszek', 'Prabucki'));
    }
}
<?php

namespace spec\SymfonyLiveWarsaw\Domain;

use SymfonyLiveWarsaw\Domain\Email;
use PhpSpec\ObjectBehavior;

/**
 * @mixin Email
 */
class EmailSpec extends ObjectBehavior
{
    function it_do_not_initialize_with_value_which_is_not_in_html5_allowed_format()
    {
        $this->beConstructedThrough(
            'fromString',
            [
                'lesze@k'
            ]
        );

        $this->shouldThrow(\InvalidArgumentException::class)->duringInstantiation();
    }
    function it_is_initialized_for_valid_value()
    {
        $this->beConstructedThrough('fromString', ['contact@cocoders.com']);

        $this->toString()->shouldBe('contact@cocoders.com');
    }
    function it_normalize_given_value()
    {
        $this->beConstructedThrough('fromString', ['Contact@cocoders.com']);

        $this->toString()->shouldBe('contact@cocoders.com');
    }
}
<?php

declare(strict_types=1);

namespace SymfonyLiveWarsaw\Domain;

use SymfonyLiveWarsaw\Domain\User\PasswordHash;

class User
{
    //...

    /**
     * @var Fullname
     */
    private $fullname;

    //...

    public function fullname(): ?Fullname
    {
        return $this->fullname;
    }

    public function changeFullname(Fullname $fullname): void
    {
        $this->fullname = $fullname;
    }
}

No dobra, mamy prosty obiekt modelu co teraz?

Musimy pozwolić na utworzenie konta (rejestracje użytkownika)

Pomysł: Czemu by nie zrobić na to serwisu?

No to specujemy!

<?php

namespace spec\SymfonyLiveWarsaw\Application\UseCase;

use SymfonyLiveWarsaw\Application\UseCase\RegisterUser;
use SymfonyLiveWarsaw\Domain\Users;
use SymfonyLiveWarsaw\Domain\User;
use SymfonyLiveWarsaw\Application\UseCase\RegisterUser\UserFactory;
use PhpSpec\ObjectBehavior;

class RegisterUserSpec extends ObjectBehavior
{
    function let(Users $users, UserFactory $userFactory)
    {
        $this->beConstructedWith($users, $userFactory);
    }

    function it_creates_user_base_on_given_data_and_store_in_persistence_layer(
        Users $users,
        UserFactory $userFactory,
        User $user
    ) {
        $command = new RegisterUser\Command(
            'a35c7f52-fdf3-40ed-a69e-2c7f17d174e9',
            'leszek.prabucki@gmail.com',
            '$argon2id$v=19$m=1024,t=2,p=2$WXl6Wk5zWWpPY3RvWFloMA$emM94iLGzIMjqDC/uk6UFlRIhXQQ72t1f67L+LZEVQA'
        );
        $command = $command->withFullName('Leszek', 'Prabucki');

        $userFactory->create($command)->willReturn($user);

        $this->handle($command);

        $users->add($user)->shouldHaveBeenCalled();
    }
}
<?php

namespace spec\SymfonyLiveWarsaw\Application\UseCase;

use SymfonyLiveWarsaw\Application\UseCase\RegisterUser;
use SymfonyLiveWarsaw\Domain\Users;
use SymfonyLiveWarsaw\Domain\User;
use SymfonyLiveWarsaw\Application\UseCase\RegisterUser\UserFactory;
use SymfonyLiveWarsaw\Application\Exception\UserAlreadyExists;
use PhpSpec\ObjectBehavior;

class RegisterUserSpec extends ObjectBehavior
{
    function let(Users $users, UserFactory $userFactory)
    {
        $this->beConstructedWith($users, $userFactory);
    }

    //..

    function it_cannot_register_user_with_same_email(Users $users)
    {
        $command = new RegisterUser\Command(
            'a35c7f52-fdf3-40ed-a69e-2c7f17d174e9',
            'leszek.prabucki@gmail.com',
            '$argon2id$v=19$m=1024,t=2,p=2$WXl6Wk5zWWpPY3RvWFloMA$emM94iLGzIMjqDC/uk6UFlRIhXQQ72t1f67L+LZEVQA'
        );

        $users->has(Email::fromString('leszek.prabucki@gmail.com'))->willReturn(true);

        $this->shouldThrow(UserAlreadyExists::class)->duringHandle($command);
    }
}
<?php

declare(strict_types=1);

namespace SymfonyLiveWarsaw\Application\UseCase;

use SymfonyLiveWarsaw\Application\Exception\UserAlreadyExists;
use SymfonyLiveWarsaw\Domain\Users;

class RegisterUser
{
    /**
     * @var Users
     */
    private $users;
    /**
     * @var RegisterUser\UserFactory
     */
    private $userFactory;

    public function __construct(Users $users, RegisterUser\UserFactory $userFactory)
    {
        $this->users = $users;
        $this->userFactory = $userFactory;
    }

    public function handle(RegisterUser\Command $command): void
    {
        if ($this->users->has($command->email())) {
            throw UserAlreadyExists::forEmail($command->email());
        }

        $user = $this->userFactory->create($command);
        $this->users->add($user);
    }
}

Dzięki TDD udało się napisać kod który realizuje potrzebny biznesowe bez myślenia o szczegółach. TDD i abstrakcja WTF!!

Ok, ale chciałbym jakiś kod wykonać przed i po wykonaniu serwisu (np. tranzakcje, logger)

Command Bus

<?php

namespace SymfonyLiveWarsaw\Application\Infrastructure;

interface Command
{}

interface CommandHandler
{
    public function handle(Command $command): void;
}

interface CommandBus
{
    public function registerHandler(string $commandName, CommandHandler $handler): void;
    public function handle(Command $command): void;
}
<?php

declare(strict_types=1);

namespace SymfonyLiveWarsaw\Application\Infrastructure;

use SymfonyLiveWarsaw\Application\Exception\HandlerNotFound;

final class SynchronousCommandBus implements CommandBus
{
    /**
     * @var CommandHandler[]
     */
    private $handlers = [];
    public function registerHandler(string $commandName, CommandHandler $handler): void
    {
        $this->handlers[$commandName] = $handler;
    }

    public function handle(Command $command): void
    {
        if (!isset($this->handlers[get_class($command)])) {
            throw new HandlerNotFound(get_class($command));
        }

        $handler = $this->handlers[get_class($command)];
        $handler->handle($command);
    }
}
<?php

declare(strict_types=1);

namespace SymfonyLiveWarsaw\Application\UseCase;

use SymfonyLiveWarsaw\Application\Exception\UserAlreadyExists;
use SymfonyLiveWarsaw\Application\Infrastructure\Command;
use SymfonyLiveWarsaw\Application\Infrastructure\CommandHandler;
use SymfonyLiveWarsaw\Domain\Users;

class RegisterUser implements CommandHandler
{
    /**
     * @var Users
     */
    private $users;
    /**
     * @var RegisterUser\UserFactory
     */
    private $userFactory;

    public function __construct(Users $users, RegisterUser\UserFactory $userFactory)
    {
        $this->users = $users;
        $this->userFactory = $userFactory;
    }

    /**
     * @param RegisterUser\Command $command
     */
    public function handle(Command $command): void
    {
        if ($this->users->has($command->email())) {
            throw UserAlreadyExists::forEmail($command->email());
        }

        $user = $this->userFactory->create($command);
        $this->users->add($user);
    }
}
<?php

namespace SymfonyLiveWarsaw\Application\Infrastructure;

use Psr\Log\LoggerInterface;

final class LoggerCommandBus implements CommandBus
{
    private $decoratedCommandBus;
    private $logger;

    public function __construct(CommandBus $decoratedCommandBus, LoggerInterface $logger)
    {
        $this->decoratedCommandBus = $decoratedCommandBus;
        $this->logger = $logger;
    }

    public function registerHandler(string $commandName, CommandHandler $handler): void
    {
        $this->decoratedCommandBus->registerHandler($commandName, $handler);
    }

    public function handle(Command $command): void
    {
        $this->logger->info(sprintf('Command appear %s', get_class($command)));

        try {
            $this->decoratedCommandBus->handle($command);
            $this->logger->info(sprintf('Command handled %s', get_class($command)));
        } catch (\Exception $exception) {
            $this->logger->error(
                sprintf('Error from command %s', get_class($command)),
                [
                    'exception' => $exception
                ]
            );

            throw $exception;
        }
    }
}
<?php

namespace SymfonyLiveWarsaw\Application\Infrastructure;

use Exception;

final class TransactionalCommandBus implements CommandBus
{
    private $decoratedCommandBus;
    private $transactionManager;

    public function __construct(
        CommandBus $decoratedCommandBus,
        TransactionManager $transactionManager
    ) {
        $this->decoratedCommandBus = $decoratedCommandBus;
        $this->transactionManager = $transactionManager;
    }

    public function registerHandler(string $commandName, CommandHandler $handler): void
    {
        $this->decoratedCommandBus->registerHandler($commandName, $handler);
    }

    public function handle(Command $command): void
    {
        $this->transactionManager->begin();
        try {
            $this->decoratedCommandBus->handle($command);
            $this->transactionManager->commit();
        } catch (Exception $exception) {
            $this->transactionManager->rollback();

            throw $exception;
        }
    }
}

Yay! Dodaliśmy funkcje bez modyfikacji istniejącego kodu, a tylko dokładając nowe klasy!

Ok mamy nasz kod co teraz? W jakich miejscach możemy to integrować?

Gdzie możemy się podpiąć?

<?php

// Port 1 - Persistence (możemy implementować interfejs)
interface Users
{
    public function add(User $user): void;
    public function has(Email $email): bool;
}

// Port 2 - Aplikacja - tworzenie obiektu domenowego (możemy dziedziczyć - brak final)
class UserFactory
{
    public function create(Command $command): User {}
}

// Port 3 - Domena (możemy dziedziczyć - brak final)
class User {}

// Port 4 - Infrastruktura (możemy implementować interfejs)
interface CommandBus {}

// Port 5 - Infrastruktura (możemy implementować interfejs)
interface TransactionManager {}

Pora na adaptery, teraz możemy sobie wybrać framework

Instalacja flexa

  "extra": {
    "src-dir": "src/App"
  }

Do composer.json dodajemy

composer require symfony/flex

composer require annotations asset orm-pack twig \
  logger mailer form security translation validator
 composer require --dev dotenv maker-bundle orm-fixtures profiler

Konfiguracja flexa i doctrine

<?php

namespace App;

use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\Config\Resource\FileResource;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
use Symfony\Component\Routing\RouteCollectionBuilder;

class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    // ... registerBundles

    public function getProjectDir(): string
    {
        return \dirname(__DIR__).'/../';
    }

    // ... configure container
}
# config/services.yml

parameters:
    locale: 'en'

services:
    # default configuration for services in *this* file
    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.

    App\:
        resource: '../src/App'
        exclude: '../src/App/{DependencyInjection,Entity,Migrations,Tests,Kernel.php}'

    App\Controller\:
        resource: '../src/App/Controller'
        tags: ['controller.service_arguments']

    SymfonyLiveWarsaw\:
        resource: '../src/SymfonyLiveWarsaw'
        exclude: '../src/SymfonyLiveWarsaw/Domain/{User.php,Email.php}'
doctrine:
    dbal:
        # configure these for your database server
        driver: 'pdo_pgsql'
        server_version: '11.3'
        url: '%env(resolve:DATABASE_URL)%'
        types:
            user_id:
                class: App\Doctrine\Type\UserIdType
    orm:
        auto_generate_proxy_classes: true
        naming_strategy: doctrine.orm.naming_strategy.underscore
        auto_mapping: true
        mappings:
            SymfonyLiveWarsaw:
                is_bundle: false
                type: xml
                dir: '%kernel.project_dir%/src/App/Doctrine/mapping'
                prefix: 'SymfonyLiveWarsaw\Domain'
                alias: SymfonLiveWarsaw
<?xml version="1.0" encoding="UTF-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
                  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                  xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
                          http://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd">


    <entity name="SymfonyLiveWarsaw\Domain\User" table="users">
        <indexes>
            <index columns="email"/>
        </indexes>

        <unique-constraints>
            <unique-constraint columns="email" name="email_idx" />
        </unique-constraints>

        <id name="id" type="user_id" column="id" />
        <embedded name="email" class="SymfonyLiveWarsaw\Domain\Email" 
             use-column-prefix="false" />
        <embedded name="passwordHash" class="SymfonyLiveWarsaw\Domain\User\PasswordHash" 
             use-column-prefix="false" />
        <embedded name="fullname" class="SymfonyLiveWarsaw\Domain\User\Fullname" 
             use-column-prefix="false" />
    </entity>
</doctrine-mapping>

Konfiguracja Command Bus

<?php

declare(strict_types=1);

namespace App\DependencyInjection;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Reference;
use SymfonyLiveWarsaw\Application\Infrastructure\CommandBus;

class CommandBusCompilerPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        if (!$container->has(CommandBus::class)) {
            return;
        }
        $definition = $container->findDefinition(CommandBus::class);
        $taggedServices = $container->findTaggedServiceIds('command_handler');
        foreach ($taggedServices as $id => $tags) {
            foreach ($tags as $attribues) {
                $definition->addMethodCall(
                    'registerHandler',
                    [
                        $id . '\\Command',
                        new Reference($id)
                    ]
                );
            }
        }
    }
}
<?php

namespace App;

use App\DependencyInjection\CommandBusCompilerPass;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\Config\Resource\FileResource;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
use Symfony\Component\Routing\RouteCollectionBuilder;
use SymfonyLiveWarsaw\Application\Infrastructure\CommandHandler;

class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    // ... rest of code

    protected function build(ContainerBuilder $container)
    {
        $container
            ->registerForAutoconfiguration(CommandHandler::class)
            ->addTag('command_handler')
        ;
        $container
            ->addCompilerPass(new CommandBusCompilerPass())
        ;
    }
}
services:
    # other services config

    SymfonyLiveWarsaw\Domain\Users: '@App\Doctrine\Users'
    SymfonyLiveWarsaw\Application\Infrastructure\TransactionManager:
        alias: 'App\Doctrine\TransactionManager'

    SymfonyLiveWarsaw\Application\Infrastructure\CommandBus:
        class: SymfonyLiveWarsaw\Application\Infrastructure\SynchronousCommandBus

    SymfonyLiveWarsaw\Application\Infrastructure\LoggerCommandBus:
        decorates: SymfonyLiveWarsaw\Application\Infrastructure\CommandBus
        decoration_priority: 1
        arguments: ['@SymfonyLiveWarsaw\Application\Infrastructure\LoggerCommandBus.inner', '@logger']

    SymfonyLiveWarsaw\Application\Infrastructure\TransactionalCommandBus:
        decorates: SymfonyLiveWarsaw\Application\Infrastructure\CommandBus
        decoration_priority: 2
        arguments: 
            - '@SymfonyLiveWarsaw\Application\Infrastructure\TransactionalCommandBus.inner', 
            - '@SymfonyLiveWarsaw\Application\Infrastructure\TransactionManager'

Ok mamy spiętą apkę! Jak będzie wyglądał teraz nasz kontroler?

<?php

namespace App\Controller;

// all uses

class UserController extends AbstractController
{
    private $commandBus;
    private $encoderFactory;

    public function __construct(CommandBus $commandBus, EncoderFactoryInterface $encoderFactory)
    {
        $this->commandBus = $commandBus;
        $this->encoderFactory = $encoderFactory;
    }

    /**
     * @Route("/user", name="user", methods={"POST"})
     */
    public function registerUser(Request $request)
    {
        $form = $this->createForm(RegisterUserType::class);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $formData = $form->getData();
            $this->commandBus->handle(new RegisterUser\Command(
                (string) User\Id::generateNew(),
                $formData['email'],
                $this->encoderFactory->getEncoder(User::class)->encodePassword($formData['password'], '')
            ));

            return $this->redirectToRoute('user');
        }
        return $this->render('user/index.html.twig', ['form' => $form->createView()]);
    }
}

Możemy tej samego kodu użyć np w fixtures

<?php

declare(strict_types = 1);

namespace App\DataFixtures;

use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
use SymfonyLiveWarsaw\Application\Infrastructure\CommandBus;
use SymfonyLiveWarsaw\Application\UseCase\RegisterUser;
use SymfonyLiveWarsaw\Domain\User;

class AppFixtures extends Fixture
{
    private $commandBus;
    private $encoderFactory;

    public function __construct(CommandBus $commandBus, EncoderFactoryInterface $encoderFactory)
    {
        $this->commandBus = $commandBus;
        $this->encoderFactory = $encoderFactory;
    }

    public function load(ObjectManager $manager): void
    {
        foreach (['leszek.prabucki@gmail.com', 'contact@cocoders.com'] as $email) {
            $this->commandBus->handle(new RegisterUser\Command(
                User\Id::generateNew()->toString(),
                $email,
                $this->encoderFactory->getEncoder(User::class)->encodePassword('abc123%asd', '')
            ));
        }
    }
}

Co gdyby tak wykonać nasze polecenie asynchronicznie?

Messenger Component

<?php

declare(strict_types=1);

namespace App\Messenger;

use Symfony\Component\Messenger\MessageBusInterface;
use SymfonyLiveWarsaw\Application\Infrastructure\Command;
use SymfonyLiveWarsaw\Application\Infrastructure\CommandBus;
use SymfonyLiveWarsaw\Application\Infrastructure\CommandHandler;

final class MessengerCommandBus implements CommandBus
{
    /**
     * @var MessageBusInterface
     */
    private $messageBus;

    public function __construct(MessageBusInterface $messageBus)
    {
        $this->messageBus = $messageBus;
    }

    public function registerHandler(string $commandName, CommandHandler $handler): void
    {
    }

    public function handle(Command $command): void
    {
        $this->messageBus->dispatch($command);
    }
}
<?php

declare(strict_types=1);

namespace App\Messenger;

use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
use SymfonyLiveWarsaw\Application\Infrastructure\Command;
use SymfonyLiveWarsaw\Application\Infrastructure\SynchronousCommandBus;

final class CommandBusMessengerHandler implements MessageHandlerInterface
{
    /**
     * @var SynchronousCommandBus
     */
    private $commandBus;

    public function __construct(SynchronousCommandBus $commandBus)
    {
        $this->commandBus = $commandBus;
    }

    public function __invoke(Command $command)
    {
        $this->commandBus->handle($command);
    }
}

Podsumowanie

Plusy takiej architektury:

  • Modułowość - możliwość dołożenia kodu bez wielkich modyfikacji

  • Jasny podział warstw aplikacji

  • Warstwa testów regresji (tworzymy w TDD)

  • Kod może być łatwo komponowany

  • Nowa wersja zależności to nie problem

Minusy takiej architektury:

  • Więcej kodu - dokładanie dodatkowych danych w domenie powoduje dużo kodu w każdej warstwie

  • Wolniej (trzeba pisać kod nie generować)

  • Dużo więcej kodu infrastruktury (na szczęście to kod pisany zazwyczaj raz)

Krótka historia o tym jak możemy projektować i implementować aplikacje Symfony przy użyciu TDD

By Leszek Prabucki

Krótka historia o tym jak możemy projektować i implementować aplikacje Symfony przy użyciu TDD

  • 1,023