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

DDD & API Platform 3

Robin Chalas
September 19, 2022

DDD & API Platform 3

At Les-Tilleuls.coop, we like to use API Platform. Mainly because it addresses a lot of questions about web API design, but also for its ease of use.
However this simplicity may come with a cost that is generally felt as the domain grows.

Fact is that at Les-Tilleuls.coop we also enjoy working on applications with strong business constraints, and even more to get off the rails.
And when appropriate, we do so based on the DDD principles.

Hexagonal message-oriented architecture, business logic decoupled from the infrastructure, … So many precepts that become prerequisites when it comes to putting the business at the center of our applications while ensuring their good maintainability.

But wouldn't all this go against what a RAD-oriented framework such as API Platform offers?
Can we really decouple our business logic from this framework via DDD-related techniques without losing its interest?

The answer is yes, and even more elegantly with API Platform v3.

Robin Chalas

September 19, 2022
Tweet

More Decks by Robin Chalas

Other Decks in Technology

Transcript

  1. However, it is able to ease your life when you

    have to deal with complex business expectations .” “Domain-Driven Design is not a fast way to build software. DDD ≠ RAD William Durand - DDD with Symfony 2: Making things clear @matarld @chalas_r
  2. Application Application services, DTOs, Commands, Queries Domain Models, Value objects,

    Events, Repositories Infrastructure Controllers, Databases, Caches, Vendors @matarld @chalas_r Hexagonal architecture
  3. Domain integrity is preserved Code is more testable Technological decisions

    can be deferred Domain is agnostic to the outside world @matarld @chalas_r Hexagonal architecture
  4. PATCH /books/{id} GET /books namespace App\BookStore\Domain\Model; final class Book {

    public readonly BookId $id; public function __construct( public BookName $name, public BookDescription $description, public Author $author, public Price $price, ) { $this->id = new BookId(); } } GET /books/cheapests DELETE /books/{id} POST /books/anonymize POST /books/{id}/discount [...] @matarld @chalas_r Reading is dreaming with open eyes... namespace App\BookStore\Domain\Model; final class Book { public readonly BookId $id; public function __construct( public BookName $name, public BookDescription $description, public Author $author, public Price $price, ) { $this->id = new BookId(); } }
  5. #[ApiResource(operations: [ new Get(), new Post('/books/{id}/discount'), ])] final class Book

    { public function __construct( #[Groups(['book:create'])] #[Assert\NotBlank] public ?BookId $id = null; #[Groups(['book:create', 'book:update'])] #[Assert\Positive] public ?Price $price = null, // ... ) { $this->id = $id ?? new BookId(); } } 11 warnings 7 errors @matarld @chalas_r Simple as sugar cookies?
  6. namespace App\BookStore\Domain\Model; final class Book { public readonly BookId $id;

    public function __construct( public BookName $name, public BookDescription $description, public Author $author, public Price $price, ) { // ... } } namespace App\BookStore\Infrastructure\ApiPlatform\Resource; #[ApiResource(operations: [new Get(), new Post('...')])] final class BookResource { public function __construct( #[ApiProperty(identifier: true)] #[Groups(['book:create'])] #[Assert\NotBlank] public ?Uuid $id = null; // ... ) { } } @matarld @chalas_r From API Platform to the Domain Model API Resource
  7. @matarld @chalas_r Query and QueryHandler namespace App\BookStore\Application\Query; final class FindCheapestBooksQuery

    { public function __construct( public readonly int $size = 10, ) { } } Query namespace App\BookStore\Application\Query; final class FindCheapestBooksQueryHandler { public function __construct( private BookRepositoryInterface $bookRepository ) { } public function __invoke(FindCheapestBooksQuery $query) { return $this->bookRepository ->withCheapestsFirst() ->withPagination(1, $query->size); } } QueryHandler
  8. @matarld @chalas_r Let's hold the query in the operation! API

    Platform providers GET /books/cheapest FooProvider CheapestBooksProvider BarProvider new QueryOperation( '/books/cheapest', FindCheapestBooksQuery::class, ) "Query" operation new GetCollection('/books/cheapest') Operation
  9. @matarld @chalas_r Let's hold the provider in the operation! Providers

    - the new way GET /books/cheapest FooProvider CheapestBooksProvider BarProvider new GetCollection( '/books/cheapest', provider: CheapestBooksProvider::class, ) Operation new GetCollection('/books/cheapest') Operation
  10. @matarld @chalas_r Domain Appli Infra Query providers' content namespace App\BookStore\Infrastructure\ApiPlatform\State\Provider;

    final class CheapestBooksProvider implements ProviderInterface { /** @return list<BookResource> */ public function provide(...): object|array|null { /** @var iterable<Book> $models */ $models = $this->queryBus->ask(new FindCheapestBooksQuery()); $resources = []; foreach ($models as $m) { $resources[] = BookResource::fromModel($m); } return $resources; } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 namespace App\BookStore\Infrastructure\ApiPlatform\State\Provider; /** @return list<BookResource> */ 1 2 final class CheapestBooksProvider implements ProviderInterface 3 { 4 5 public function provide(...): object|array|null 6 { 7 /** @var iterable<Book> $models */ 8 $models = $this->queryBus->ask(new FindCheapestBooksQuery()); 9 10 $resources = []; 11 foreach ($models as $m) { 12 $resources[] = BookResource::fromModel($m); 13 } 14 15 return $resources; 16 } 17 } 18 /** @var iterable<Book> $models */ $models = $this->queryBus->ask(new FindCheapestBooksQuery()); namespace App\BookStore\Infrastructure\ApiPlatform\State\Provider; 1 2 final class CheapestBooksProvider implements ProviderInterface 3 { 4 /** @return list<BookResource> */ 5 public function provide(...): object|array|null 6 { 7 8 9 10 $resources = []; 11 foreach ($models as $m) { 12 $resources[] = BookResource::fromModel($m); 13 } 14 15 return $resources; 16 } 17 } 18 /** @var iterable<Book> $models */ $models = $this->queryBus->ask(new FindCheapestBooksQuery()); $resources = []; foreach ($models as $m) { $resources[] = BookResource::fromModel($m); } return $resources; namespace App\BookStore\Infrastructure\ApiPlatform\State\Provider; 1 2 final class CheapestBooksProvider implements ProviderInterface 3 { 4 /** @return list<BookResource> */ 5 public function provide(...): object|array|null 6 { 7 8 9 10 11 12 13 14 15 16 } 17 } 18
  11. @matarld @chalas_r Command and CommandHandler namespace App\BookStore\Application\Command; final class DiscountBookCommand

    { public function __construct( public readonly Uuid $id, public readonly int $amount, ) { } } Command namespace App\BookStore\Application\Command; final class DiscountBookCommandHandler { public function __construct( private BookRepositoryInterface $bookRepository, ) { } public function __invoke(DiscountBookCommand $command) { // my super complex logic } } CommandHandler
  12. @matarld @chalas_r Processors - the new way POST /books/{id}/discount DiscountBookProcessor

    new Post( '/books/{id}/discount', input: DiscountBookPayload::class, provider: BookItemProvider::class, processor: DiscountBookProcessor::class, ) Operation new Post( '/books/{id}/discount', input: DiscountBookPayload::class, provider: BookItemProvider::class, ) Operation
  13. @matarld @chalas_r Domain Appli Infra Command processors' content namespace App\BookStore\Infrastructure\ApiPlatform\State\Provider;

    final class DiscountBookProcessor implements ProcessorInterface { /** * @var DiscountBookPayload $data * @return BookResource */ public function process(...): mixed { $this->commandBus->dispatch(new DiscountBookCommand( id: $context['previous_data']->id, discountPercentage: $data->discountPercentage, )); $model = $this->queryBus->ask(new FindBookQuery($command->id)); return BookResource::fromModel($model); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 namespace App\BookStore\Infrastructure\ApiPlatform\State\Provider; /** * @var DiscountBookPayload $data * @return BookResource */ public function process(...): mixed 1 2 final class DiscountBookProcessor implements ProcessorInterface 3 { 4 5 6 7 8 9 { 10 $this->commandBus->dispatch(new DiscountBookCommand( 11 id: $context['previous_data']->id, 12 discountPercentage: $data->discountPercentage, 13 )); 14 15 $model = $this->queryBus->ask(new FindBookQuery($command->id)); 16 17 return BookResource::fromModel($model); 18 } 19 } 20 $this->commandBus->dispatch(new DiscountBookCommand( id: $context['previous_data']->id, discountPercentage: $data->discountPercentage, )); namespace App\BookStore\Infrastructure\ApiPlatform\State\Provider; 1 2 final class DiscountBookProcessor implements ProcessorInterface 3 { 4 /** 5 * @var DiscountBookPayload $data 6 * @return BookResource 7 */ 8 public function process(...): mixed 9 { 10 11 12 13 14 15 $model = $this->queryBus->ask(new FindBookQuery($command->id)); 16 17 return BookResource::fromModel($model); 18 } 19 } 20 $model = $this->queryBus->ask(new FindBookQuery($command->id)); namespace App\BookStore\Infrastructure\ApiPlatform\State\Provider; 1 2 final class DiscountBookProcessor implements ProcessorInterface 3 { 4 /** 5 * @var DiscountBookPayload $data 6 * @return BookResource 7 */ 8 public function process(...): mixed 9 { 10 $this->commandBus->dispatch(new DiscountBookCommand( 11 id: $context['previous_data']->id, 12 discountPercentage: $data->discountPercentage, 13 )); 14 15 16 17 return BookResource::fromModel($model); 18 } 19 } 20 return BookResource::fromModel($model); namespace App\BookStore\Infrastructure\ApiPlatform\State\Provider; 1 2 final class DiscountBookProcessor implements ProcessorInterface 3 { 4 /** 5 * @var DiscountBookPayload $data 6 * @return BookResource 7 */ 8 public function process(...): mixed 9 { 10 $this->commandBus->dispatch(new DiscountBookCommand( 11 id: $context['previous_data']->id, 12 discountPercentage: $data->discountPercentage, 13 )); 14 15 $model = $this->queryBus->ask(new FindBookQuery($command->id)); 16 17 18 } 19 } 20
  14. @matarld @chalas_r RAD API Platform CRUD namespace App\Subscription\Entity; #[ApiResource(operations: [new

    GetCollection(), new Post()])] #[ORM\Entity] class Subscription { public function __construct( #[ApiProperty(identifier: true)] #[ORM\Id] #[ORM\Column(type: 'uuid', unique: true)] public ?Uuid $id = null, #[Assert\NotBlank(groups: ['create'])] #[Assert\Email(groups: ['create', 'Default'])] #[ORM\Column(name: 'name', nullable: false)] public ?string $email = null, ) { } }
  15. @matarld @chalas_r ├── BookStore │ ├── Application │ │ ├──

    Command │ │ │ ├── CreateSubscriptionCommand.php │ │ │ ├── CreateSubscriptionCommandHandler.php │ │ │ ├── DiscountBookCommand.php │ │ │ ├── DiscountBookCommandHandler.php │ │ │ └── ... │ │ └── Query │ │ ├── FindBookQuery.php │ │ ├── FindBookQueryHandler.php │ │ ├── FindCheapestBooksQuery.php │ │ ├── FindCheapestBooksQueryHandler.php │ │ └── ... │ ├── Domain │ └── Infrastructure └── Subscription ├── BookStore │ ├── Application │ ├── Domain │ └── Infrastructure │ └── ApiPlatform │ ├── Payload │ │ └── DiscountBookPayload.php │ ├── Resource │ │ └── BookResource.php │ └── State │ ├── Processor │ │ ├── AnonymizeBooksProcessor.php │ │ └── DiscountBookProcessor.php │ └── Provider │ ├── BookItemProvider.php │ └── CheapestBooksProvider.php └── Subscription ├── BookStore └── Subscription └── Entity └── Subscription.php Wrap it up Domain Application Infrastructure RAD
  16. @matarld @chalas_r Wrap it up #[ApiResource( operations: [ new GetCollection(

    '/books/cheapest', provider: CheapestBooksProvider::class, ), new Post( '/books/{id}/discount', input: DiscountBookPayload::class, provider: BookItemProvider::class, processor: DiscountBookProcessor::class, ), ], )] final class BookResource Business hexagonal Hexagonal business #[ApiResource( operations: [new Get(), new Post()], )] final class Subscription RAD CRUD