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

[API Platform Con] Domain Driven Design with AP...

Mathias Arlaud
September 15, 2022

[API Platform Con] Domain Driven Design with 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. This simplicity, however, comes with a cost that is generally felt when you need to get out of the box.

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.

Mathias Arlaud

September 15, 2022
Tweet

Other Decks in Programming

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. 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
  3. Application Use cases, Application services, DTOs, Commands, Queries Domain Models,

    Value objects, Events, Repositories @matarld @chalas_r Hexagonal architecture
  4. Application Use cases, Application services, DTOs, Commands, Queries Domain Models,

    Value objects, Events, Repositories Infrastructure Controllers, Databases, Caches, Vendors @matarld @chalas_r Hexagonal architecture
  5. 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
  6. @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(); } }
  7. 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...
  8. #[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(); } } @matarld @chalas_r Simple as sugar cookies?
  9. #[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 @matarld @chalas_r Simple as sugar cookies?
  10. #[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?
  11. 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
  12. @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
  13. @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
  14. @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
  15. @matarld @chalas_r Providers - the new way GET /books/cheapest FooProvider

    CheapestBooksProvider BarProvider new GetCollection('/books/cheapest') Operation
  16. @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
  17. @matarld @chalas_r Let's hold the provider in the operation! Providers

    - the new way GET /books/cheapest CheapestBooksProvider new GetCollection( '/books/cheapest', provider: CheapestBooksProvider::class, ) Operation
  18. @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
  19. @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
  20. @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
  21. @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
  22. @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
  23. @matarld @chalas_r Processors - the new way POST /books/{id}/discount new

    Post( '/books/{id}/discount', input: DiscountBookPayload::class, provider: BookItemProvider::class, ) Operation
  24. @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
  25. @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
  26. @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
  27. @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
  28. @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
  29. @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
  30. @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, ) { } }
  31. @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 Wrap it up Domain Application
  32. @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 Wrap it up Domain Application Infrastructure
  33. @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
  34. @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