Pro Yearly is on sale from $80 to $50! »

The absolute beginner’s guide to DDD with Symfony

Da2d2829b89cde136392973a35b68959?s=47 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.

Da2d2829b89cde136392973a35b68959?s=128

nealio82

November 20, 2020
Tweet

Transcript

  1. The absolute beginner’s guide to Domain-Driven Design with Symfony

  2. What’s the point?

  3. What’s the point?

  4. What’s the point? Tackling complexity in the heart of software

  5. What’s the point?

  6. What’s the point?

  7. What’s the point?

  8. None
  9. Domain-Driven Design Beginner’s Guide

  10. None
  11. None
  12. What is Domain-Driven Design?

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

    to represent it in software, and also an architectural pattern for simplifying business problems
  14. Definitions & revision

  15. Domain

  16. Domain Experts

  17. Ubiquitous Language

  18. The software must use the same words as the business

  19. Property

  20. Instruction

  21. Instruction Property

  22. Bounded Contexts

  23. None
  24. None
  25. None
  26. A Bounded Context is a logical boundary around some behaviour

    within the business
  27. Entities

  28. A DDD entity is a class which has some sort

    of identity, making it unique from other identical objects
  29. Aggregates

  30. An aggregate is a collection of objects that represent one

    concept in the domain
  31. Aggregate Roots

  32. The aggregate root is the entry-point into the aggregate

  33. Invariants

  34. Invariants are business rules which are enforced in the domain

    code. They are separate to application-level rules.
  35. Domain Services and Application Services

  36. Domain services contain domain logic which doesn’t naturally fit within

    an aggregate, entity, or value object
  37. Application services orchestrate the execution of domain logic according to

    outside-world input, but don’t contain any domain logic themselves
  38. Architecture

  39. 4 layers of a DDD architecture Each bounded context contains:

    • Domain / Model • Infrastructure • Application • UI
  40. 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.
  41. 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.
  42. 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
  43. 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
  44. Domain-Driven Design Getting started

  45. 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
  46. None
  47. None
  48. None
  49. Preparation

  50. $ symfony new --full \ absolute-beginners-guide-to-ddd-with-symfony

  51. public src config templates tests composer.json

  52. public src config templates tests composer.json Kernel.php Controller Entity Repository

  53. public src config App templates tests composer.json Kernel.php Controller Entity

    Repository
  54. 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
  55. "autoload" : { "psr-4": { "App\\": "src/App/" } }, public

    src config App templates tests Kernel.php Controller Entity Repository composer.json
  56. 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
  57. 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
  58. 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
  59. The First Bounded Context

  60. "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
  61. public src AcmeVet config Scheduling App composer.json templates tests Bounded

    Context
  62. public src AcmeVet config Infrastructure Scheduling Application Domain App composer.json

    templates tests Bounded Context
  63. Build the aggregate

  64. public src AcmeVet config Scheduling Domain composer.json AppointmentTest.php Appointment templates

    tests
  65. <?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
  66. <?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
  67. <?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
  68. <?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
  69. <?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
  70. 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
  71. 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
  72. 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
  73. 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
  74. 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
  75. 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
  76. 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
  77. <?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
  78. <?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
  79. 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
  80. <?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
  81. Create a domain service

  82. <?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
  83. <?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
  84. <?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
  85. <?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
  86. <?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
  87. <?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
  88. <?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
  89. <?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
  90. <?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
  91. <?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
  92. Infrastructure

  93. 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
  94. 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
  95. 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
  96. 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
  97. 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
  98. 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
  99. $ php bin/console doctrine:migrations:generate

  100. 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
  101. Application services

  102. None
  103. 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
  104. 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
  105. 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
  106. 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
  107. 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
  108. 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
  109. 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
  110. 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
  111. The UI

  112. 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
  113. 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
  114. 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
  115. 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
  116. <?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
  117. None
  118. None
  119. <?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
  120. <?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
  121. <?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
  122. <?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
  123. <?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
  124. 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
  125. 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
  126. 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
  127. 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
  128. None
  129. None
  130. The second bounded context

  131. public src AcmeVet config Infrastructure Consultation Application Domain App composer.json

    migrations Scheduling templates tests Bounded Context Bounded Context
  132. <?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
  133. Application data

  134. <?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
  135. 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
  136. 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
  137. 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
  138. 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
  139. 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!