Upgrade to Pro — share decks privately, control downloads, hide ads and more …

The absolute beginner’s guide to DDD with Symfony

nealio82
November 20, 2020

The absolute beginner’s guide to DDD with Symfony

DDD is one of the most effective ways to address business problems, but it can be overwhelming to get started with. Even armed with the theory, beginning the journey is mysterious and finding answers to specific implementation details can be tough and scary.

In this talk we'll demystify the terminology and work out the most important aspects of DDD you'll need to get you going.

Then, by exploring and implementing a sample business domain, we'll discover such things as:
- How DDD entities differ from Doctrine entities
- What an aggregate root actually is
- How to organise your code
- How to define a bounded context
- How a domain service differs from an application service

Finally, we'll wire it all together with Symfony 5. So if you've been wanting to get started with DDD for a while but never knew how or where to start then this talk is for you.

nealio82

November 20, 2020
Tweet

More Decks by nealio82

Other Decks in Programming

Transcript

  1. DDD is an approach to understanding the business in order

    to represent it in software, and also an architectural pattern for simplifying business problems
  2. A DDD entity is a class which has some sort

    of identity, making it unique from other identical objects
  3. Invariants are business rules which are enforced in the domain

    code. They are separate to application-level rules.
  4. Application services orchestrate the execution of domain logic according to

    outside-world input, but don’t contain any domain logic themselves
  5. 4 layers of a DDD architecture Each bounded context contains:

    • Domain / Model • Infrastructure • Application • UI
  6. 4 layers of a DDD architecture Each bounded context contains:

    • Domain / Model • Infrastructure • Application • UI Contains all business logic / invariants, entities, domain services, and interfaces to allow communication with the outside world. Must not depend on anything outside of the domain layer.
  7. 4 layers of a DDD architecture Each bounded context contains:

    • Domain / Model • Infrastructure • Application • UI Contains concretions of the interfaces defined in the Domain layer. Can only depend on items in the domain layer.
  8. 4 layers of a DDD architecture Each bounded context contains:

    • Domain / Model • Infrastructure • Application • UI Provides ‘application services’ to facilitate communication to the Domain layer from the UI. May call domain services. Cannot contain business logic, but can perform validation. Can only depend on the domain layer and infrastructure layer
  9. 4 layers of a DDD architecture Each bounded context contains:

    • Domain / Model • Infrastructure • Application • UI UI layer (controllers, console commands, etc) Can only talk to the application layer
  10. 4 layers of a DDD architecture Each bounded context contains:

    • Domain / Model • Infrastructure • Application • UI Let’s simplify things by sharing the UI layer across contexts Symfony’s App namespace is a pre-made UI layer we can take advantage of We can use Symfony’s controllers and commands to keep things feeling familiar
  11. protected function configureContainer (ContainerConfigurator $container ): void { $container ->import('../../config/{packages}/*.yaml'

    ); $container ->import('../../config/{packages}/' .$this->environment .'/*.yaml'); if (is_file(\dirname(__DIR__).'/../config/services.yaml' )) { $container ->import('../../config/services.yaml' ); $container ->import('../../config/{services}_' .$this->environment .'.yaml'); } elseif (is_file($path = \dirname(__DIR__).'/../config/services.php' )) { (require $path)($container ->withPath($path), $this); } } protected function configureRoutes (RoutingConfigurator $routes): void { $routes->import('../../config/{routes}/' .$this->environment .'/*.yaml'); $routes->import('../../config/{routes}/*.yaml' ); if (is_file(\dirname(__DIR__).'/../config/routes.yaml' )) { $routes->import('../../config/routes.yaml' ); } elseif (is_file($path = \dirname(__DIR__).'/../config/routes.php' )) { (require $path)($routes->withPath($path), $this); } } public src config App templates tests composer.json Kernel.php Controller Entity Repository
  12. "autoload" : { "psr-4": { "App\\": "src/App/" } }, public

    src config App templates tests Kernel.php Controller Entity Repository composer.json
  13. 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. # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name App\: resource: '../src/ App/' exclude: - '../src/ App/DependencyInjection/' - '../src/ App/Entity/' - '../src/ App/Kernel.php' - '../src/ App/Tests/' # controllers are imported separately to make sure services can be injected # as action arguments even if you don't extend any base controller class App\Controller\ : resource: '../src/ App/Controller/' tags: ['controller.service_arguments'] public src config App templates tests Kernel.php Controller Entity Repository services.yaml composer.json
  14. doctrine: dbal: url: '%env(resolve:DATABASE_URL)%' # IMPORTANT: You MUST configure your

    server version, # either here or in the DATABASE_URL env var (see .env file) #server_version: '5.7' orm: auto_generate_proxy_classes : true naming_strategy : doctrine.orm.naming_strategy.underscore_number_aware auto_mapping : true mappings: App: is_bundle: false type: annotation dir: '%kernel.project_dir%/src/ App/Entity' prefix: 'App\Entity' alias: App public src config App templates tests Kernel.php Controller Entity Repository doctrine.yaml composer.json packages
  15. controllers : resource: ../../src/ App/Controller/ type: annotation kernel: resource: ../../src/

    App/Kernel.php type: annotation public src config App templates tests Kernel.php Controller Entity Repository annotations.yaml composer.json routes packages
  16. "autoload" : { "psr-4": { "App\\": "src/App/" , "AcmeVet\\" :

    "src/AcmeVet/" } }, "autoload-dev" : { "psr-4": { "App\\Tests\\": "tests/" } }, public src config AcmeVet templates tests composer.json App
  17. <?php namespace Tests\AcmeVet\Scheduling \Domain\Appointment ; class AppointmentTest extends TestCase {

    public function test_an_appointment_can_be_created (): void { } public function test_a_double_appointment_can_be_created (): void { } public function test_an_appointment_cannot_be_longer_than_a_double_slot (): void { } public function test_an_appointment_must_be_longer_than_zero_minutes_long (): void { } public function test_an_appointment_must_be_a_multiple_of_fifteen_minutes (): void { } } public src AcmeVet config Scheduling Domain composer.json AppointmentTest.php Appointment templates tests
  18. <?php namespace Tests\AcmeVet\Scheduling \Domain\Appointment ; class AppointmentTest extends TestCase {

    public function test_an_appointment_can_be_created (): void { $appointmentId = AppointmentId ::generate(); $pet = Pet::create( 'Luna', 'David Smith' , '07007771234' ); $startTime = new \DateTimeImmutable (); $appointmentLength = 15; $appointment = Appointment ::create( $appointmentId, $pet, $startTime, $appointmentLength ); static::assertSame ($appointmentId, $appointment ->getId()); static::assertSame ($pet, $appointment ->getPet()); static::assertSame ($startTime, $appointment ->getStartTime ()); static::assertSame ($appointmentLength, $appointment ->getLengthInMinutes ()); } } public src AcmeVet config Scheduling Domain composer.json AppointmentTest.php Appointment templates tests
  19. <?php namespace Tests\AcmeVet\Scheduling \Domain\Appointment ; class AppointmentTest extends TestCase {

    public function test_an_appointment_can_be_created (): void { $appointmentId = AppointmentId ::generate(); $pet = Pet::create( 'Luna', 'David Smith' , '07007771234' ); $startTime = new \DateTimeImmutable (); $appointmentLength = 15; $appointment = Appointment ::create( $appointmentId, $pet, $startTime, $appointmentLength ); static::assertSame ($appointmentId, $appointment ->getId()); static::assertSame ($pet, $appointment ->getPet()); static::assertSame ($startTime, $appointment ->getStartTime ()); static::assertSame ($appointmentLength, $appointment ->getLengthInMinutes ()); } } public src AcmeVet config Scheduling Domain composer.json AppointmentTest.php Appointment templates tests
  20. <?php namespace Tests\AcmeVet\Scheduling \Domain\Appointment ; class AppointmentTest extends TestCase {

    public function test_an_appointment_can_be_created (): void { $appointmentId = AppointmentId ::generate(); $pet = Pet::create( 'Luna', 'David Smith' , '07007771234' ); $startTime = new \DateTimeImmutable (); $appointmentLength = 15; $appointment = Appointment ::create( $appointmentId, $pet, $startTime, $appointmentLength ); static::assertSame ($appointmentId, $appointment ->getId()); static::assertSame ($pet, $appointment ->getPet()); static::assertSame ($startTime, $appointment ->getStartTime ()); static::assertSame ($appointmentLength, $appointment ->getLengthInMinutes ()); } } public src AcmeVet config Scheduling Domain composer.json AppointmentTest.php Appointment templates tests
  21. <?php namespace AcmeVet\Scheduling \Domain\Appointment ; use AcmeVet\Appointment \Domain\Exception\AppointmentLengthInvalid ; class

    Appointment { private function __construct( AppointmentId $appointmentId , Pet $pet, \DateTimeImmutable $appointmentTime , int $appointmentLengthInMinutes ) { $this ->appointmentId = $appointmentId ; $this ->pet = $pet; $this ->appointmentTime = $appointmentTime ; $this ->appointmentLengthInMinutes = $appointmentLengthInMinutes ; } } public src AcmeVet config Infrastructure Appointment.php Scheduling Application Domain Appointment composer.json App templates tests Bounded Context Aggregate Aggregate Root
  22. public src AcmeVet config Infrastructure Appointment.php <?php namespace AcmeVet\Scheduling \Domain\Appointment

    ; use AcmeVet\Appointment \Domain\Exception\AppointmentLengthInvalid ; class Appointment { private function __construct ( AppointmentId $appointmentId , Pet $pet, \DateTimeImmutable $appointmentTime , int $appointmentLengthInMinutes ) { $this ->appointmentId = $appointmentId ; $this ->pet = $pet; $this ->appointmentTime = $appointmentTime ; $this ->appointmentLengthInMinutes = $appointmentLengthInMinutes ; } } Scheduling Application Domain Appointment App composer.json templates tests Bounded Context Aggregate Aggregate Root
  23. public static function create( AppointmentId $appointmentId , Pet $pet, \DateTimeImmutable

    $appointmentTime , int $appointmentLength ): self { if (self::STANDARD_APPOINTMENT_LENGTH > $appointmentLength ) { throw new AppointmentLengthInvalid ( sprintf("The minimum appointment length is %s minutes" , self::STANDARD_APPOINTMENT_LENGTH ) ); } $maxLength = self::STANDARD_APPOINTMENT_LENGTH * 2; if ($maxLength < $appointmentLength ) { throw new AppointmentLengthInvalid ( sprintf("The maximum appointment length is %s minutes" , $maxLength) ); } if (0 !== $appointmentLength % self::STANDARD_APPOINTMENT_LENGTH ) { throw new AppointmentLengthInvalid ( sprintf("The appointment length must be a multiple of %s minutes" , self::STANDARD_APPOINTMENT_LENGTH ) ); } return new self ($appointmentId , $pet, $appointmentTime , $appointmentLength ); } public src AcmeVet config Infrastructure Appointment.php Scheduling Application Domain Appointment composer.json App templates tests Bounded Context Aggregate Aggregate Root
  24. public static function create( AppointmentId $appointmentId , Pet $pet, \DateTimeImmutable

    $appointmentTime , int $appointmentLength ): self { if (self::STANDARD_APPOINTMENT_LENGTH > $appointmentLength ) { throw new AppointmentLengthInvalid ( sprintf("The minimum appointment length is %s minutes" , self::STANDARD_APPOINTMENT_LENGTH ) ); } $maxLength = self::STANDARD_APPOINTMENT_LENGTH * 2; if ($maxLength < $appointmentLength ) { throw new AppointmentLengthInvalid ( sprintf("The maximum appointment length is %s minutes" , $maxLength) ); } if (0 !== $appointmentLength % self::STANDARD_APPOINTMENT_LENGTH ) { throw new AppointmentLengthInvalid ( sprintf("The appointment length must be a multiple of %s minutes" , self::STANDARD_APPOINTMENT_LENGTH ) ); } return new self ($appointmentId , $pet, $appointmentTime , $appointmentLength ); } public src AcmeVet config Infrastructure Appointment.php Scheduling Application Domain Appointment composer.json App templates tests Bounded Context Aggregate Aggregate Root
  25. public static function create( AppointmentId $appointmentId , Pet $pet, \DateTimeImmutable

    $appointmentTime , int $appointmentLength ): self { if (self::STANDARD_APPOINTMENT_LENGTH > $appointmentLength ) { throw new AppointmentLengthInvalid ( sprintf("The minimum appointment length is %s minutes" , self::STANDARD_APPOINTMENT_LENGTH ) ); } $maxLength = self::STANDARD_APPOINTMENT_LENGTH * 2; if ($maxLength < $appointmentLength ) { throw new AppointmentLengthInvalid ( sprintf("The maximum appointment length is %s minutes" , $maxLength) ); } if (0 !== $appointmentLength % self::STANDARD_APPOINTMENT_LENGTH ) { throw new AppointmentLengthInvalid ( sprintf("The appointment length must be a multiple of %s minutes" , self::STANDARD_APPOINTMENT_LENGTH ) ); } return new self ($appointmentId , $pet, $appointmentTime , $appointmentLength ); } public src AcmeVet config Infrastructure Appointment.php Scheduling Application Domain Appointment composer.json App templates tests Bounded Context Aggregate Aggregate Root
  26. public static function create( AppointmentId $appointmentId , Pet $pet, \DateTimeImmutable

    $appointmentTime , int $appointmentLength ): self { if (self::STANDARD_APPOINTMENT_LENGTH > $appointmentLength ) { throw new AppointmentLengthInvalid ( sprintf("The minimum appointment length is %s minutes" , self::STANDARD_APPOINTMENT_LENGTH ) ); } $maxLength = self::STANDARD_APPOINTMENT_LENGTH * 2; if ($maxLength < $appointmentLength ) { throw new AppointmentLengthInvalid ( sprintf("The maximum appointment length is %s minutes" , $maxLength) ); } if (0 !== $appointmentLength % self::STANDARD_APPOINTMENT_LENGTH ) { throw new AppointmentLengthInvalid ( sprintf("The appointment length must be a multiple of %s minutes" , self::STANDARD_APPOINTMENT_LENGTH ) ); } return new self ($appointmentId , $pet, $appointmentTime , $appointmentLength ); } public src AcmeVet config Infrastructure Appointment.php Scheduling Application Domain Appointment composer.json App templates tests Bounded Context Aggregate Aggregate Root
  27. public static function create( AppointmentId $appointmentId , Pet $pet, \DateTimeImmutable

    $appointmentTime , int $appointmentLength ): self { if (self::STANDARD_APPOINTMENT_LENGTH > $appointmentLength ) { throw new AppointmentLengthInvalid ( sprintf("The minimum appointment length is %s minutes" , self::STANDARD_APPOINTMENT_LENGTH ) ); } $maxLength = self::STANDARD_APPOINTMENT_LENGTH * 2; if ($maxLength < $appointmentLength ) { throw new AppointmentLengthInvalid ( sprintf("The maximum appointment length is %s minutes" , $maxLength) ); } if (0 !== $appointmentLength % self::STANDARD_APPOINTMENT_LENGTH ) { throw new AppointmentLengthInvalid ( sprintf("The appointment length must be a multiple of %s minutes" , self::STANDARD_APPOINTMENT_LENGTH ) ); } return new self ($appointmentId , $pet, $appointmentTime , $appointmentLength ); } public src AcmeVet config Infrastructure Appointment.php Scheduling Application Domain Appointment composer.json App templates tests Bounded Context Aggregate Aggregate Root
  28. public static function create( AppointmentId $appointmentId , Pet $pet, \DateTimeImmutable

    $appointmentTime , int $appointmentLength ): self { if (self::STANDARD_APPOINTMENT_LENGTH > $appointmentLength ) { throw new AppointmentLengthInvalid ( sprintf("The minimum appointment length is %s minutes" , self::STANDARD_APPOINTMENT_LENGTH ) ); } $maxLength = self::STANDARD_APPOINTMENT_LENGTH * 2; if ($maxLength < $appointmentLength ) { throw new AppointmentLengthInvalid ( sprintf("The maximum appointment length is %s minutes" , $maxLength) ); } if (0 !== $appointmentLength % self::STANDARD_APPOINTMENT_LENGTH ) { throw new AppointmentLengthInvalid ( sprintf("The appointment length must be a multiple of %s minutes" , self::STANDARD_APPOINTMENT_LENGTH ) ); } return new self ($appointmentId , $pet, $appointmentTime , $appointmentLength ); } public src AcmeVet config Infrastructure Appointment.php Scheduling Application Domain Appointment composer.json App templates tests Bounded Context Aggregate Aggregate Root
  29. <?php namespace AcmeVet\Scheduling \Domain\Appointment ; use Ramsey\Uuid\Uuid; class AppointmentId {

    private string $id; private function __construct (string $id) { $this ->id = $id; } public static function generate(): self { return new self (Uuid::uuid4()->toString()); } public static function fromString (string $id): self { if (false === Uuid::isValid($id)) { throw new \DomainException ( \sprintf("AppointmentId '%s' is not valid" , $id) ); } return new self ($id); } public function toString(): string { return $this->id; } } public src AcmeVet config Infrastructure Scheduling Application Domain Appointment composer.json AppointmentId.php Appointment.php App templates tests Bounded Context Aggregate Aggregate Root
  30. <?php namespace AcmeVet\Scheduling \Domain\Appointment ; use Ramsey\Uuid\Uuid; class AppointmentId {

    private string $id; private function __construct (string $id) { $this ->id = $id; } public static function generate(): self { return new self (Uuid::uuid4()->toString()); } public static function fromString (string $id): self { if (false === Uuid::isValid($id)) { throw new \DomainException ( \sprintf("AppointmentId '%s' is not valid" , $id) ); } return new self ($id); } public function toString(): string { return $this->id; } } public src AcmeVet config Infrastructure Scheduling Application Domain Appointment composer.json AppointmentId.php Appointment.php App templates tests Bounded Context Aggregate Aggregate Root
  31. class Pet { private string $name; private string $ownerName ;

    private string $contactNumber ; private function __construct ( string $name, string $ownerName , string $contactNumber ) { $this ->name = $name; $this ->ownerName = $ownerName ; $this ->contactNumber = $contactNumber ; } public static function create( string $name, string $ownerName , string $contactNumber ) { return new self ($name, $ownerName , $contactNumber ); } public function getName(): string { return $this->name; } public function getOwnerName (): string { return $this->ownerName; } public function getContactNumber (): string { return $this->contactNumber ; } } public src AcmeVet config Infrastructure Scheduling Application Domain composer.json AppointmentId.php Pet.php Appointment.php App templates tests Appointment Bounded Context Aggregate Aggregate Root
  32. <?php namespace AcmeVet\Scheduling \Domain\Appointment \Exception; class AppointmentLengthInvalid extends \DomainException {

    } public src AcmeVet config Infrastructure Scheduling Application Domain Exception composer.json AppointmentId.php Pet.php Appointment.php AppointmentLengthInvalid.php App templates tests Appointment Bounded Context Aggregate Aggregate Root
  33. <?php namespace AcmeVet\Scheduling \Domain\Appointment ; namespace AcmeVet\Scheduling \Domain\Appointment \Exception\CouldNotConfirmSlotException ;

    class SlotConfirmationService { private AppointmentRepository $appointmentRepository ; public function __construct ( AppointmentRepository $appointmentRepository ) { $this ->appointmentRepository = $appointmentRepository ; } public function confirmSlot (Appointment $appointment ): void { $this ->checkStartTimeIsNotInThePast ($appointment ); $this ->checkSlotStartTimeIsNotTaken ($appointment ); $this ->checkSlotIsNotTakenByADoubleLengthBooking ($appointment ); $this ->checkSlotDoesNotSpanAnotherBooking ($appointment ); $this ->appointmentRepository ->store($appointment ); } private function checkStartTimeIsNotInThePast (Appointment $appointment ): void { $dateDiff = $appointment ->getStartTime ()->diff(new \DateTimeImmutable ()); if ( $appointment ->getStartTime () < new \DateTimeImmutable () && 0 !== (int) $dateDiff->format('%y%m%d%h%i' ) ) { throw new CouldNotConfirmSlotException ("The slot must not be in the past" ); } } public src AcmeVet config Infrastructure Scheduling Application Domain Exception composer.json AppointmentId.php Pet.php Appointment.php SlotConfirmationService.php Appointment App templates tests Bounded Context Aggregate Aggregate Root
  34. <?php namespace AcmeVet\Scheduling \Domain\Appointment ; namespace AcmeVet\Scheduling \Domain\Appointment \Exception\CouldNotConfirmSlotException ;

    class SlotConfirmationService { private AppointmentRepository $appointmentRepository ; public function __construct ( AppointmentRepository $appointmentRepository ) { $this ->appointmentRepository = $appointmentRepository ; } public function confirmSlot (Appointment $appointment ): void { $this ->checkStartTimeIsNotInThePast ($appointment ); $this ->checkSlotStartTimeIsNotTaken ($appointment ); $this ->checkSlotIsNotTakenByADoubleLengthBooking ($appointment ); $this ->checkSlotDoesNotSpanAnotherBooking ($appointment ); $this ->appointmentRepository ->store($appointment ); } private function checkStartTimeIsNotInThePast (Appointment $appointment ): void { $dateDiff = $appointment ->getStartTime ()->diff(new \DateTimeImmutable ()); if ( $appointment ->getStartTime () < new \DateTimeImmutable () && 0 !== (int) $dateDiff->format('%y%m%d%h%i' ) ) { throw new CouldNotConfirmSlotException ("The slot must not be in the past" ); } } public src AcmeVet config Infrastructure Scheduling Application Domain Exception composer.json AppointmentId.php Pet.php Appointment.php SlotConfirmationService.php Appointment App templates tests Bounded Context Aggregate Aggregate Root
  35. <?php namespace AcmeVet\Scheduling \Domain\Appointment ; namespace AcmeVet\Scheduling \Domain\Appointment \Exception\CouldNotConfirmSlotException ;

    class SlotConfirmationService { private AppointmentRepository $appointmentRepository ; public function __construct ( AppointmentRepository $appointmentRepository ) { $this ->appointmentRepository = $appointmentRepository ; } public function confirmSlot (Appointment $appointment ): void { $this ->checkStartTimeIsNotInThePast ($appointment ); $this ->checkSlotStartTimeIsNotTaken ($appointment ); $this ->checkSlotIsNotTakenByADoubleLengthBooking ($appointment ); $this ->checkSlotDoesNotSpanAnotherBooking ($appointment ); $this ->appointmentRepository ->store($appointment ); } private function checkStartTimeIsNotInThePast (Appointment $appointment ): void { $dateDiff = $appointment ->getStartTime ()->diff(new \DateTimeImmutable ()); if ( $appointment ->getStartTime () < new \DateTimeImmutable () && 0 !== (int) $dateDiff->format('%y%m%d%h%i' ) ) { throw new CouldNotConfirmSlotException ("The slot must not be in the past" ); } } public src AcmeVet config Infrastructure Scheduling Application Domain Exception composer.json AppointmentId.php Pet.php Appointment.php SlotConfirmationService.php Appointment App templates tests Bounded Context Aggregate Aggregate Root
  36. <?php namespace AcmeVet\Scheduling \Domain\Appointment ; namespace AcmeVet\Scheduling \Domain\Appointment \Exception\CouldNotConfirmSlotException ;

    class SlotConfirmationService { private AppointmentRepository $appointmentRepository ; public function __construct ( AppointmentRepository $appointmentRepository ) { $this ->appointmentRepository = $appointmentRepository ; } public function confirmSlot (Appointment $appointment ): void { $this ->checkStartTimeIsNotInThePast ($appointment ); $this ->checkSlotStartTimeIsNotTaken ($appointment ); $this ->checkSlotIsNotTakenByADoubleLengthBooking ($appointment ); $this ->checkSlotDoesNotSpanAnotherBooking ($appointment ); $this ->appointmentRepository ->store($appointment ); } private function checkStartTimeIsNotInThePast (Appointment $appointment ): void { $dateDiff = $appointment ->getStartTime ()->diff(new \DateTimeImmutable ()); if ( $appointment ->getStartTime () < new \DateTimeImmutable () && 0 !== (int) $dateDiff->format('%y%m%d%h%i' ) ) { throw new CouldNotConfirmSlotException ("The slot must not be in the past" ); } } public src AcmeVet config Infrastructure Scheduling Application Domain Exception composer.json AppointmentId.php Pet.php Appointment.php SlotConfirmationService.php Appointment App templates tests Bounded Context Aggregate Aggregate Root
  37. <?php namespace AcmeVet\Scheduling \Domain\Appointment ; interface AppointmentRepository { public function

    getAppointmentAtTime (\DateTimeImmutable $appointmentTime ): ?Appointment ; public function store(Appointment $appointment ): void; /** * @return Appointment[] */ public function getAll(): array; } public src AcmeVet config Infrastructure Scheduling Application Domain Exception composer.json AppointmentId.php Pet.php Appointment.php AppointmentRepository.php Appointment SlotConfirmationService.php App templates tests Bounded Context Aggregate Aggregate Root
  38. <?php namespace AcmeVet\Scheduling \Domain\Appointment ; interface AppointmentRepository { public function

    getAppointmentAtTime (\DateTimeImmutable $appointmentTime ): ?Appointment ; public function store(Appointment $appointment ): void; /** * @return Appointment[] */ public function getAll(): array; } public src AcmeVet config Infrastructure Scheduling Application Domain Exception composer.json AppointmentId.php Pet.php Appointment.php AppointmentRepository.php Appointment SlotConfirmationService.php App templates tests Bounded Context Aggregate Aggregate Root
  39. <?php namespace AcmeVet\Scheduling \Domain\Appointment ; interface AppointmentRepository { public function

    getAppointmentAtTime (\DateTimeImmutable $appointmentTime ): ?Appointment ; public function store(Appointment $appointment ): void; /** * @return Appointment[] */ public function getAll(): array; } public src AcmeVet config Infrastructure Scheduling Application Domain Exception composer.json AppointmentId.php Pet.php Appointment.php AppointmentRepository.php Appointment SlotConfirmationService.php App templates tests Bounded Context Aggregate Aggregate Root
  40. <?php namespace Tests\AcmeVet\Scheduling \Domain\Appointment ; class SlotConfirmationServiceTest extends TestCase {

    public function test_a_slot_can_be_confirmed (): void { $repository = new InMemoryAppointmentRepository (); $service = new SlotConfirmationService ($repository); $dateTime = new \DateTimeImmutable (); $appointment = Appointment ::create( AppointmentId ::generate(), Pet::create('Luna', 'David Smith' , '07500777123' ), $dateTime, 15 ); $service ->confirmSlot ($appointment); static::assertSame ( $appointment, $repository ->getAppointmentAtTime ($dateTime) ); } public function test_an_appointment_cannot_be_made_in_the_past (): void { } public src AcmeVet config Scheduling Domain templates tests composer.json SlotConfirmationServiceTest.php Appointment AppointmentTest.php
  41. <?php namespace Tests\AcmeVet\Scheduling \Domain\Appointment ; class SlotConfirmationServiceTest extends TestCase {

    public function test_a_slot_can_be_confirmed (): void { $repository = new InMemoryAppointmentRepository (); $service = new SlotConfirmationService ($repository); $dateTime = new \DateTimeImmutable (); $appointment = Appointment ::create( AppointmentId ::generate(), Pet::create('Luna', 'David Smith' , '07500777123' ), $dateTime, 15 ); $service ->confirmSlot ($appointment); static::assertSame ( $appointment, $repository ->getAppointmentAtTime ($dateTime) ); } public function test_an_appointment_cannot_be_made_in_the_past (): void { } public src AcmeVet config Scheduling Domain composer.json SlotConfirmationServiceTest.php Appointment AppointmentTest.php templates tests
  42. <?php namespace Tests\AcmeVet\Scheduling \Doubles; use AcmeVet\Appointment \Domain\Appointment ; use AcmeVet\Appointment

    \Domain\AppointmentRepository ; class InMemoryAppointmentRepository implements AppointmentRepository { private array $appointments = []; public function getAppointmentAtTime ( \DateTimeImmutable $appointmentTime ): ?Appointment { // … } public function store(Appointment $appointment ): void { $this ->appointments [] = $appointment ; } public function getAll(): array { return $this->appointments ; } } public src AcmeVet config Scheduling Domain composer.json InMemoryAppointmentRepository.php Appointment AppointmentTest.php Doubles SlotConfirmationService.php templates tests
  43. namespace AcmeVet\Scheduling \Infrastructure ; use AcmeVet\Scheduling \Domain\Appointment \Appointment ; use

    AcmeVet\Scheduling \Domain\Appointment \AppointmentId ; use AcmeVet\Scheduling \Domain\Appointment \AppointmentRepository ; use AcmeVet\Scheduling \Domain\Appointment \Pet; use Doctrine\DBAL\Connection ; class DbalAppointmentRepository implements AppointmentRepository { private const DATE_FORMAT = "Y-m-d\TH:i" ; private Connection $connection ; public function __construct (Connection $connection ) { $this ->connection = $connection ; } public function getAppointmentAtTime ( \DateTimeImmutable $appointmentTime ): ?Appointment { $stmt = $this->connection ->prepare(' SELECT * FROM appointments WHERE start_time = :start_time '); $stmt ->bindValue('start_time' , $appointmentTime ->format(self::DATE_FORMAT )); $stmt ->execute(); } public src AcmeVet config Infrastructure Scheduling Application Domain composer.json DbalAppointmentRepository.php App templates tests Bounded Context
  44. namespace AcmeVet\Scheduling \Infrastructure ; use AcmeVet\Scheduling \Domain\Appointment \Appointment ; use

    AcmeVet\Scheduling \Domain\Appointment \AppointmentId ; use AcmeVet\Scheduling \Domain\Appointment \AppointmentRepository ; use AcmeVet\Scheduling \Domain\Appointment \Pet; use Doctrine\DBAL\Connection ; class DbalAppointmentRepository implements AppointmentRepository { private const DATE_FORMAT = "Y-m-d\TH:i" ; private Connection $connection ; public function __construct (Connection $connection ) { $this ->connection = $connection ; } public function getAppointmentAtTime ( \DateTimeImmutable $appointmentTime ): ?Appointment { $stmt = $this->connection ->prepare(' SELECT * FROM appointments WHERE start_time = :start_time '); $stmt ->bindValue('start_time' , $appointmentTime ->format(self::DATE_FORMAT )); $stmt ->execute(); } public src AcmeVet config Infrastructure Scheduling Application Domain DbalAppointmentRepository.php composer.json App templates tests Bounded Context
  45. namespace AcmeVet\Scheduling \Infrastructure ; use AcmeVet\Scheduling \Domain\Appointment \Appointment ; use

    AcmeVet\Scheduling \Domain\Appointment \AppointmentId ; use AcmeVet\Scheduling \Domain\Appointment \AppointmentRepository ; use AcmeVet\Scheduling \Domain\Appointment \Pet; use Doctrine\DBAL\Connection ; class DbalAppointmentRepository implements AppointmentRepository { private const DATE_FORMAT = "Y-m-d\TH:i" ; private Connection $connection ; public function __construct (Connection $connection ) { $this ->connection = $connection ; } public function getAppointmentAtTime ( \DateTimeImmutable $appointmentTime ): ?Appointment { $stmt = $this->connection ->prepare(' SELECT * FROM appointments WHERE start_time = :start_time '); $stmt ->bindValue('start_time' , $appointmentTime ->format(self::DATE_FORMAT )); $stmt ->execute(); } public src AcmeVet config Infrastructure Scheduling Application Domain DbalAppointmentRepository.php composer.json App templates tests Bounded Context
  46. namespace AcmeVet\Scheduling \Infrastructure ; use AcmeVet\Scheduling \Domain\Appointment \Appointment ; use

    AcmeVet\Scheduling \Domain\Appointment \AppointmentId ; use AcmeVet\Scheduling \Domain\Appointment \AppointmentRepository ; use AcmeVet\Scheduling \Domain\Appointment \Pet; use Doctrine\DBAL\Connection ; class DbalAppointmentRepository implements AppointmentRepository { private const DATE_FORMAT = "Y-m-d\TH:i" ; private Connection $connection ; public function __construct (Connection $connection ) { $this ->connection = $connection ; } public function getAppointmentAtTime ( \DateTimeImmutable $appointmentTime ): ?Appointment { $stmt = $this->connection ->prepare(' SELECT * FROM appointments WHERE start_time = :start_time '); $stmt ->bindValue('start_time' , $appointmentTime ->format(self::DATE_FORMAT )); $stmt ->execute(); } public src AcmeVet config Infrastructure Scheduling Application Domain DbalAppointmentRepository.php composer.json App templates tests Bounded Context
  47. public function getAppointmentAtTime ( \DateTimeImmutable $appointmentTime ): ?Appointment { $stmt

    = $this->connection ->prepare(' SELECT * FROM appointments WHERE start_time = :start_time '); $stmt ->bindValue('start_time' , $appointmentTime ->format(self::DATE_FORMAT )); $stmt ->execute(); $result = $stmt->fetchAssociative (); if (!$result) { return null ; } return Appointment ::create( AppointmentId ::fromString ($result['id']), Pet::create( $result[ 'pet_name' ], $result[ 'owner_name' ], $result[ 'contact_number' ] ), \DateTimeImmutable ::createFromFormat ( self::DATE_FORMAT , $result['start_time' ] ), $result[ 'length'] ); } public src AcmeVet config Infrastructure Scheduling Application Domain DbalAppointmentRepository.php composer.json App templates tests Bounded Context
  48. services: # ... # Tell Symfony to auto-wire everything that

    it finds # within the AcmeVet namespace AcmeVet\ : resource: '../src/AcmeVet/' public src AcmeVet config Infrastructure Scheduling Application Domain DbalAppointmentRepository.php services.yaml composer.json App templates tests Bounded Context
  49. namespace DoctrineMigrations ; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations \AbstractMigration ; final

    class Version20201107141910 extends AbstractMigration { public function getDescription () : string { return 'Add the table for appointments in the AcmeVet\Appointment context' ; } public function up(Schema $schema) : void { $this ->addSql(' CREATE TABLE appointments ( id CHARACTER(36) PRIMARY KEY, start_time VARCHAR, length CHARACTER(2), pet_name VARCHAR, owner_name VARCHAR, contact_number VARCHAR ) '); } public function down(Schema $schema): void { $this ->addSql('DROP TABLE appointments' ); } } public src AcmeVet config Infrastructure Scheduling Application Domain DbalAppointmentRepository.php Version2020110714910.php migrations composer.json App templates tests Bounded Context
  50. class AppointmentBookingCommand implements Command { private \DateTimeImmutable $appointmentTime ; private

    string $petName; private string $ownerName ; private string $contactNumber ; private int $appointmentLengthInMinutes ; public function __construct ( \DateTimeImmutable $appointmentTime , string $petName, string $ownerName , string $contactNumber , int $appointmentLengthInMinutes ) { $this ->appointmentTime = $appointmentTime ; $this ->petName = $petName; $this ->ownerName = $ownerName ; $this ->contactNumber = $contactNumber ; $this ->appointmentLengthInMinutes = $appointmentLengthInMinutes ; } public function getAppointmentTime (): \DateTimeImmutable { return $this->appointmentTime ; } public function getPetName (): string { return $this->petName; } public function getOwnerName (): string { return $this->ownerName; } public function getContactNumber (): string { return $this->contactNumber ; } public function getAppointmentLengthInMinutes (): int { return $this->appointmentLengthInMinutes ; } } public src AcmeVet config Infrastructure Scheduling Application Domain AppointmentBookingCommand.php Command Booking migrations composer.json App templates tests Bounded Context
  51. class AppointmentBookingCommand implements Command { private \DateTimeImmutable $appointmentTime ; private

    string $petName; private string $ownerName ; private string $contactNumber ; private int $appointmentLengthInMinutes ; public function __construct ( \DateTimeImmutable $appointmentTime , string $petName, string $ownerName , string $contactNumber , int $appointmentLengthInMinutes ) { $this ->appointmentTime = $appointmentTime ; $this ->petName = $petName; $this ->ownerName = $ownerName ; $this ->contactNumber = $contactNumber ; $this ->appointmentLengthInMinutes = $appointmentLengthInMinutes ; } public function getAppointmentTime (): \DateTimeImmutable { return $this->appointmentTime ; } public function getPetName (): string { return $this->petName; } public function getOwnerName (): string { return $this->ownerName; } public function getContactNumber (): string { return $this->contactNumber ; } public function getAppointmentLengthInMinutes (): int { return $this->appointmentLengthInMinutes ; } } public src AcmeVet config Infrastructure Scheduling Application Domain AppointmentBookingCommand.php Command Booking migrations composer.json App templates tests Bounded Context
  52. class AppointmentBookingHandler implements Handler { private SlotConfirmationService $slotConfirmationService ; public

    function __construct ( SlotConfirmationService $slotConfirmationService ) { $this ->slotConfirmationService = $slotConfirmationService ; } public function __invoke(Command $command): void { $appointment = Appointment ::create( AppointmentId ::generate(), Pet::create( $command->getPetName (), $command->getOwnerName (), $command->getContactNumber () ), $command->getAppointmentTime (), $command->getAppointmentLengthInMinutes () ); try { $this ->slotConfirmationService ->confirmSlot ($appointment); } catch (CouldNotConfirmSlotException $exception) { throw new \RuntimeException ("The slot could not be booked" ); } } } public src AcmeVet config Infrastructure Scheduling Application Domain AppointmentBookingCommand.php Command Booking AppointmentBookingHandler.php migrations composer.json App templates tests Bounded Context
  53. class AppointmentBookingHandler implements Handler { private SlotConfirmationService $slotConfirmationService ; public

    function __construct ( SlotConfirmationService $slotConfirmationService ) { $this ->slotConfirmationService = $slotConfirmationService ; } public function __invoke(Command $command): void { $appointment = Appointment ::create( AppointmentId ::generate(), Pet::create( $command->getPetName (), $command->getOwnerName (), $command->getContactNumber () ), $command->getAppointmentTime (), $command->getAppointmentLengthInMinutes () ); try { $this ->slotConfirmationService ->confirmSlot ($appointment); } catch (CouldNotConfirmSlotException $exception) { throw new \RuntimeException ("The slot could not be booked" ); } } } public src AcmeVet config Infrastructure Scheduling Application Domain AppointmentBookingCommand.php Command Booking AppointmentBookingHandler.php migrations composer.json App templates tests Bounded Context
  54. class AppointmentBookingHandler implements Handler { private SlotConfirmationService $slotConfirmationService ; public

    function __construct ( SlotConfirmationService $slotConfirmationService ) { $this ->slotConfirmationService = $slotConfirmationService ; } public function __invoke(Command $command): void { $appointment = Appointment ::create( AppointmentId ::generate(), Pet::create( $command->getPetName (), $command->getOwnerName (), $command->getContactNumber () ), $command->getAppointmentTime (), $command->getAppointmentLengthInMinutes () ); try { $this ->slotConfirmationService ->confirmSlot ($appointment); } catch (CouldNotConfirmSlotException $exception) { throw new \RuntimeException ("The slot could not be booked" ); } } } public src AcmeVet config Infrastructure Scheduling Application Domain AppointmentBookingCommand.php Command Booking AppointmentBookingHandler.php migrations composer.json App templates tests Bounded Context
  55. class AppointmentBookingHandler implements Handler { private SlotConfirmationService $slotConfirmationService ; public

    function __construct ( SlotConfirmationService $slotConfirmationService ) { $this ->slotConfirmationService = $slotConfirmationService ; } public function __invoke(Command $command): void { $appointment = Appointment ::create( AppointmentId ::generate(), Pet::create( $command->getPetName (), $command->getOwnerName (), $command->getContactNumber () ), $command->getAppointmentTime (), $command->getAppointmentLengthInMinutes () ); try { $this ->slotConfirmationService ->confirmSlot ($appointment); } catch (CouldNotConfirmSlotException $exception) { throw new \RuntimeException ("The slot could not be booked" ); } } } public src AcmeVet config Infrastructure Scheduling Application Domain AppointmentBookingCommand.php Command Booking AppointmentBookingHandler.php migrations composer.json App templates tests Bounded Context
  56. class AppointmentBookingHandler implements Handler { private SlotConfirmationService $slotConfirmationService ; public

    function __construct ( SlotConfirmationService $slotConfirmationService ) { $this ->slotConfirmationService = $slotConfirmationService ; } public function __invoke(Command $command): void { $appointment = Appointment ::create( AppointmentId ::generate(), Pet::create( $command->getPetName (), $command->getOwnerName (), $command->getContactNumber () ), $command->getAppointmentTime (), $command->getAppointmentLengthInMinutes () ); try { $this ->slotConfirmationService ->confirmSlot ($appointment); } catch (CouldNotConfirmSlotException $exception) { throw new \RuntimeException ("The slot could not be booked" ); } } } public src AcmeVet config Infrastructure Scheduling Application Domain AppointmentBookingCommand.php Command Booking AppointmentBookingHandler.php migrations composer.json App templates tests Bounded Context
  57. Services: # ... # Tell Symfony to auto-wire everything that

    it finds # within the AcmeVet namespace AcmeVet\ : resource: '../src/AcmeVet/' AcmeVet\Scheduling\Application\Command\Booking\AppointmentBookingHandler : tags: [messenger.message_handler ] public src AcmeVet config Infrastructure Scheduling Application Domain AppointmentBookingCommand.php Command Booking AppointmentBookingHandler.php services.yaml migrations composer.json App templates tests Bounded Context
  58. class AppointmentController extends AbstractController { private MessageBusInterface $messageBus ; public

    function __construct (MessageBusInterface $messageBus ) { $this ->messageBus = $messageBus ; } /** * @Route("/appointments", name="appointments"); */ public function list(Request $request): Response { $form = $this->createForm (AppointmentType ::class); $form ->handleRequest ($request); if ($form->isSubmitted () && $form->isValid()) { $command = new AppointmentBookingCommand ( $form ->get('appointmentTime' )->getData(), $form ->get('petName')->getData(), $form ->get('ownerName' )->getData(), $form ->get('contactNumber' )->getData(), true === $form->get('appointmentLength' )->getData() ? 30 : 15 ); $this ->messageBus ->dispatch($command); } return $this->render('appointment/list.html.twig' , [ 'form' => $form->createView () ]); } } public src AcmeVet config App templates tests composer.json Kernel.php AppointmentController.php migrations Controller Entity Form Repository
  59. class AppointmentController extends AbstractController { private MessageBusInterface $messageBus ; public

    function __construct (MessageBusInterface $messageBus ) { $this ->messageBus = $messageBus ; } /** * @Route("/appointments", name="appointments"); */ public function list(Request $request): Response { $form = $this->createForm (AppointmentType ::class); $form ->handleRequest ($request); if ($form->isSubmitted () && $form->isValid()) { $command = new AppointmentBookingCommand ( $form ->get('appointmentTime' )->getData(), $form ->get('petName')->getData(), $form ->get('ownerName' )->getData(), $form ->get('contactNumber' )->getData(), true === $form->get('appointmentLength' )->getData() ? 30 : 15 ); $this ->messageBus ->dispatch($command); } return $this->render('appointment/list.html.twig' , [ 'form' => $form->createView () ]); } } public src AcmeVet config App composer.json AppointmentController.php migrations Controller Entity Form Repository templates tests Kernel.php
  60. class AppointmentController extends AbstractController { private MessageBusInterface $messageBus ; public

    function __construct (MessageBusInterface $messageBus ) { $this ->messageBus = $messageBus ; } /** * @Route("/appointments", name="appointments"); */ public function list(Request $request): Response { $form = $this->createForm (AppointmentType ::class); $form ->handleRequest ($request); if ($form->isSubmitted () && $form->isValid()) { $command = new AppointmentBookingCommand ( $form ->get('appointmentTime' )->getData(), $form ->get('petName')->getData(), $form ->get('ownerName' )->getData(), $form ->get('contactNumber' )->getData(), true === $form->get('appointmentLength' )->getData() ? 30 : 15 ); $this ->messageBus ->dispatch($command); } return $this->render('appointment/list.html.twig' , [ 'form' => $form->createView () ]); } } public src AcmeVet config App composer.json Kernel.php AppointmentController.php migrations Controller Entity Form Repository templates tests
  61. class AppointmentController extends AbstractController { private MessageBusInterface $messageBus ; public

    function __construct (MessageBusInterface $messageBus ) { $this ->messageBus = $messageBus ; } /** * @Route("/appointments", name="appointments"); */ public function list(Request $request): Response { $form = $this->createForm (AppointmentType ::class); $form ->handleRequest ($request); if ($form->isSubmitted () && $form->isValid()) { $command = new AppointmentBookingCommand ( $form ->get('appointmentTime' )->getData(), $form ->get('petName')->getData(), $form ->get('ownerName' )->getData(), $form ->get('contactNumber' )->getData(), true === $form->get('appointmentLength' )->getData() ? 30 : 15 ); $this ->messageBus ->dispatch($command); } return $this->render('appointment/list.html.twig' , [ 'form' => $form->createView () ]); } } public src AcmeVet config App composer.json Kernel.php AppointmentController.php migrations Controller Entity Form Repository templates tests
  62. <?php namespace App\Form\Type; class AppointmentType extends AbstractType { public function

    buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('appointmentTime' , DateTimeType ::class, [ 'input' => 'datetime_immutable' , 'widget' => 'single_text' , 'attr' => [ 'step' => 900, 'min' => (new \DateTimeImmutable ())->format('Y-m-d') ] ]) ->add('petName', TextType::class) ->add('ownerName' , TextType::class) ->add('contactNumber' , TextType::class) ->add('appointmentLength' , ChoiceType ::class, [ 'choices' => [ 'single' => false, 'double' => true ] ]) ->add('submit', SubmitType ::class); } } public src AcmeVet config App composer.json Kernel.php AppointmentController.php migrations Controller Entity Form Repository Type templates tests
  63. <?php namespace AcmeVet\Scheduling \Application \Query; use AcmeVet\Appointment \Domain\Appointment ; use

    AcmeVet\Appointment \Domain\AppointmentRepository ; class AppointmentQuery { private AppointmentRepository $repository ; public function __construct (AppointmentRepository $repository ) { $this ->repository = $repository ; } public function fetchAll(): array { $results = $this->repository ->getAll(); return \array_map(function (Appointment $item) { return new AppointmentDTO ( $item->getStartTime (), 15 === $item->getLengthInMinutes () ? false : true, $item->getPet()->getName(), $item->getPet()->getOwnerName (), $item->getPet()->getContactNumber () ); }, $results); } } public src AcmeVet config Infrastructure Scheduling Application Domain App templates tests composer.json Command Query AppointmentQuery.php migrations Bounded Context
  64. <?php namespace AcmeVet\Scheduling \Application \Query; use AcmeVet\Appointment \Domain\Appointment ; use

    AcmeVet\Appointment \Domain\AppointmentRepository ; class AppointmentQuery { private AppointmentRepository $repository ; public function __construct (AppointmentRepository $repository ) { $this ->repository = $repository ; } public function fetchAll(): array { $results = $this->repository ->getAll(); return \array_map(function (Appointment $item) { return new AppointmentDTO ( $item->getStartTime (), 15 === $item->getLengthInMinutes () ? false : true, $item->getPet()->getName(), $item->getPet()->getOwnerName (), $item->getPet()->getContactNumber () ); }, $results); } } public src AcmeVet config Infrastructure Scheduling Application Domain App composer.json Command Query AppointmentQuery.php migrations templates tests Bounded Context
  65. <?php namespace AcmeVet\Scheduling \Application \Query; use AcmeVet\Appointment \Domain\Appointment ; use

    AcmeVet\Appointment \Domain\AppointmentRepository ; class AppointmentQuery { private AppointmentRepository $repository ; public function __construct (AppointmentRepository $repository ) { $this ->repository = $repository ; } public function fetchAll(): array { $results = $this->repository ->getAll(); return \array_map(function (Appointment $item) { return new AppointmentDTO ( $item->getStartTime (), 15 === $item->getLengthInMinutes () ? false : true, $item->getPet()->getName(), $item->getPet()->getOwnerName (), $item->getPet()->getContactNumber () ); }, $results); } } public src AcmeVet config Infrastructure Scheduling Application Domain App composer.json Command Query AppointmentQuery.php migrations templates tests Bounded Context
  66. <?php namespace AcmeVet\Scheduling \Application \Query; use AcmeVet\Appointment \Domain\Appointment ; use

    AcmeVet\Appointment \Domain\AppointmentRepository ; class AppointmentQuery { private AppointmentRepository $repository ; public function __construct (AppointmentRepository $repository ) { $this ->repository = $repository ; } public function fetchAll(): array { $results = $this->repository ->getAll(); return \array_map(function (Appointment $item) { return new AppointmentDTO ( $item->getStartTime (), 15 === $item->getLengthInMinutes () ? false : true, $item->getPet()->getName(), $item->getPet()->getOwnerName (), $item->getPet()->getContactNumber () ); }, $results); } } public src AcmeVet config Infrastructure Scheduling Application Domain App composer.json Command Query AppointmentQuery.php migrations templates tests Bounded Context
  67. <?php namespace AcmeVet\Scheduling \Application \Query; use AcmeVet\Appointment \Domain\Appointment ; use

    AcmeVet\Appointment \Domain\AppointmentRepository ; class AppointmentQuery { private AppointmentRepository $repository ; public function __construct (AppointmentRepository $repository ) { $this ->repository = $repository ; } public function fetchAll(): array { $results = $this->repository ->getAll(); return \array_map(function (Appointment $item) { return new AppointmentDTO ( $item->getStartTime (), 15 === $item->getLengthInMinutes () ? false : true, $item->getPet()->getName(), $item->getPet()->getOwnerName (), $item->getPet()->getContactNumber () ); }, $results); } } public src AcmeVet config Infrastructure Scheduling Application Domain App composer.json Command Query AppointmentQuery.php migrations templates tests Bounded Context
  68. class AppointmentDTO { private \DateTimeImmutable $startTime ; private bool $isDoubleAppointment

    ; private string $petName; private string $ownerName ; private string $contactNumber ; public function __construct ( \DateTimeImmutable $startTime , bool $isDoubleAppointment , string $petName, string $ownerName , string $contactNumber ) { $this ->startTime = $startTime ; $this ->isDoubleAppointment = $isDoubleAppointment ; $this ->petName = $petName; $this ->ownerName = $ownerName ; $this ->contactNumber = $contactNumber ; } public function getStartTime (): \DateTimeImmutable { return $this->startTime; } public function getPetName (): string { return $this->petName; } public function getOwnerName (): string { return $this->ownerName; } public function getContactNumber (): string { return $this->contactNumber ; } public function isDoubleAppointment (): bool { return $this->isDoubleAppointment ; } } public src AcmeVet config Infrastructure Scheduling Application Domain App composer.json Command Query AppointmentQuery.php migrations AppointmentDTO.php templates tests Bounded Context
  69. class AppointmentController extends AbstractController { /** * @Route("/appointments", name="appointments"); */

    public function list(Request $request, AppointmentQuery $appointmentList ): Response { $form = $this->createForm (AppointmentType ::class); $form ->handleRequest ($request); if ($form->isSubmitted () && $form->isValid()) { $command = new AppointmentBookingCommand ( $form ->get('appointmentTime' )->getData(), $form ->get('petName')->getData(), $form ->get('ownerName' )->getData(), $form ->get('contactNumber' )->getData(), true === $form->get('appointmentLength' )->getData() ? 30 : 15 ); $this ->messageBus ->dispatch($command); } $appointments = $appointmentList ->fetchAll(); return $this->render('appointment/list.html.twig' , [ 'appointments' => $appointments, 'form' => $form->createView () ]); } } public src AcmeVet config App composer.json Kernel.php AppointmentController.php migrations Controller Entity Form Repository templates tests
  70. class AppointmentController extends AbstractController { /** * @Route("/appointments", name="appointments"); */

    public function list(Request $request, AppointmentQuery $appointmentList ): Response { $form = $this->createForm (AppointmentType ::class); $form ->handleRequest ($request); if ($form->isSubmitted () && $form->isValid()) { $command = new AppointmentBookingCommand ( $form ->get('appointmentTime' )->getData(), $form ->get('petName')->getData(), $form ->get('ownerName' )->getData(), $form ->get('contactNumber' )->getData(), true === $form->get('appointmentLength' )->getData() ? 30 : 15 ); $this ->messageBus ->dispatch($command); } $appointments = $appointmentList ->fetchAll(); return $this->render('appointment/list.html.twig' , [ 'appointments' => $appointments, 'form' => $form->createView () ]); } } public src AcmeVet config App composer.json Kernel.php AppointmentController.php migrations Controller Entity Form Repository templates tests
  71. class AppointmentController extends AbstractController { /** * @Route("/appointments", name="appointments"); */

    public function list(Request $request, AppointmentQuery $appointmentList ): Response { $form = $this->createForm (AppointmentType ::class); $form ->handleRequest ($request); if ($form->isSubmitted () && $form->isValid()) { $command = new AppointmentBookingCommand ( $form ->get('appointmentTime' )->getData(), $form ->get('petName')->getData(), $form ->get('ownerName' )->getData(), $form ->get('contactNumber' )->getData(), true === $form->get('appointmentLength' )->getData() ? 30 : 15 ); $this ->messageBus ->dispatch($command); } $appointments = $appointmentList ->fetchAll(); return $this->render('appointment/list.html.twig' , [ 'appointments' => $appointments, 'form' => $form->createView () ]); } } public src AcmeVet config App composer.json Kernel.php AppointmentController.php migrations Controller Entity Form Repository templates tests
  72. public src AcmeVet config Infrastructure Consultation Application Domain App composer.json

    migrations Scheduling templates tests Bounded Context Bounded Context
  73. <?php namespace AcmeVet\Consultation \Domain\Pet; class Pet { private PetId $petId;

    private Animal $animal; private string $petName; private \DateTimeImmutable $dateOfBirth ; private array $diagnoses = []; private array $consultations = []; private function __construct ( PetId $petId, Animal $animal, string $petName, \DateTimeImmutable $dateOfBirth ) { $this ->petId = $petId; $this ->animal = $animal; $this ->petName = $petName; $this ->dateOfBirth = $dateOfBirth ; } public static function create( PetId $petId, Animal $animal, string $petName, \DateTimeImmutable $dateOfBirth ): self { return new self ($petId, $animal, $petName, $dateOfBirth ); } public function recordConsultation (Consultation $consultation ): void { $this ->consultations [] = $consultation ; } public function getBreed(): string { return (new \ReflectionClass ($this->animal))->getShortName () . '/' . $this->animal->getBreedName (); } public function addDiagnosis (Diagnosis $diagnosis ): void { $this ->diagnoses[] = $diagnosis ; } public src AcmeVet config Infrastructure Consultation Application Domain App composer.json migrations Scheduling Pet Pet.php templates tests Bounded Context Aggregate Aggregate Root Bounded Context
  74. <?php namespace App\Entity; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\UserInterface; /**

    * @ORM\Table(name="app_users") * @ORM\Entity(repositoryClass="App\Repository\UserRepository") */ class User implements UserInterface, \Serializable { /** * @ORM\Column(type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\Column(type="string", length=25, unique=true) */ private $username; /** * @ORM\Column(type="string", length=64) */ private $password; /** * @ORM\Column(type="string", length=254, unique=true) */ private $email; /** * @ORM\Column(name="is_active", type="boolean") */ private $isActive; public function __construct() { $this->isActive = true; // may not be needed, see section on salt below // $this->salt = md5(uniqid('', true)); } public function getUsername() public src AcmeVet config App composer.json Kernel.php User.php migrations Controller Entity Form Repository templates tests
  75. Do it yourself • Think about the words you use

    in the business • Identify Bounded Contexts • Think about who the domain experts are for each Bounded Context • Start talking about business processes and invariants in each Bounded Context • Identify the aggregates • Build the software
  76. Do it yourself • Think about the words you use

    in the business • Identify Bounded Contexts • Think about who the domain experts are for each Bounded Context • Start talking about business processes and invariants in each Bounded Context • Identify the aggregates • Build the software https://github.com/nealio82/ absolute-beginners-guide-to-ddd-with-symfony
  77. Do it yourself https://github.com/nealio82/ absolute-beginners-guide-to-ddd-with-symfony • Think about the words

    you use in the business • Identify Bounded Contexts • Think about who the domain experts are for each Bounded Context • Start talking about business processes and invariants in each Bounded Context • Identify the aggregates • Build the software @nealio82
  78. Do it yourself https://github.com/nealio82/ absolute-beginners-guide-to-ddd-with-symfony • Think about the words

    you use in the business • Identify Bounded Contexts • Think about who the domain experts are for each Bounded Context • Start talking about business processes and invariants in each Bounded Context • Identify the aggregates • Build the software @nealio82 Thanks to @edd_mann, @lucas_courot, and @jim_jqim
  79. Do it yourself https://github.com/nealio82/ absolute-beginners-guide-to-ddd-with-symfony • Think about the words

    you use in the business • Identify Bounded Contexts • Think about who the domain experts are for each Bounded Context • Start talking about business processes and invariants in each Bounded Context • Identify the aggregates • Build the software @nealio82 Thanks to @edd_mann, @lucas_courot, and @jim_jqim, and you!