Domain-Driven Design and
API Platform 3
@matarld
@chalas_r
Mathias Arlaud
@matarld
mtarld
les-tilleuls.coop
Robin Chalas
@chalas_r
chalasr
les-tilleuls.coop
DDD is not prescriptive.
Disclaimer
@matarld
@chalas_r
@matarld
@chalas_r
@robin Stratégique / Ubiquitous / ...
DDD ≠ RAD
“Domain-Driven Design is not a fast way to build software.
However, it is able to ease your life when you have to deal with complex business expectations.”
@matarld
@chalas_r
Directory structure
@matarld
@chalas_r
Common API Platform
└── src
├── Provider
│
├── Processor
│
├── Entity
│ └── Book.php
│
└── Repository
└── BookRepository.php
Directory structure
@matarld
@chalas_r
Hexagonal API Platform
└── src
├── Application
│ └── BookStore
│ ├── Command
│ └── Query
│ ├── FindBookQuery.php
│ └── FindBookQueryHandler.php
├── Domain
│ └── BookStore
│ ├── Model
│ │ └── Book.php
│ └── Repository
│ └── BookRepository.php
└── Infrastructure
└── BookStore
└── Doctrine
Layers and dependency rule
Hexagonal architecture
Domain
Models, Value objects, Events, Repositories
Application
Use cases, Application services, DTOs, Commands, Queries
Infrastructure
Controllers, Databases, Caches, Vendors
@matarld
@chalas_r
Benefits
Hexagonal architecture
Domain integrity is preserved
Code is more testable
Technological decisions can be deferred
Domain is agnostic to the outside world
@matarld
@chalas_r
GET /books
Reading is dreaming with open eyes...
GET /books/cheapests
PATCH /books/{id}
DELETE /books/{id}
POST /books/anonymize
POST /books/{id}/discount
[...]
Business op.
CRUD op.
namespace App\Domain\BookStore\Model;
final class Book
{
public readonly Uuid $id;
public function __construct(
public string $name,
public string $description,
public string $author,
public string $content,
public int $price,
) {
$this->id = Uuid::v4();
Assert::positive($price);
}
}
namespace App\Domain\BookStore\Model;
final class Book
{
public readonly Uuid $id;
public function __construct(
public string $name,
public string $description,
public string $author,
public string $content,
public int $price,
) {
$this->id = Uuid::v4();
Assert::positive($price);
}
}
@matarld
@chalas_r
Simple as sugar cookies?
11 warnings
7 errors
namespace App\Domain\BookStore\Model;
#[ApiResource(operations: [
new Get(),
new Post('/books/{id}/discount'),
])]
final class Book
{
public function __construct(
#[Groups(['book:create'])]
#[Assert\NotBlank]
public ?Uuid $id = null;
#[Groups(['book:create', 'book:update'])]
#[Assert\Positive]
public ?int $price = null,
// ...
) {
$this->id = $id ?? Uuid::v4();
}
}
@matarld
@chalas_r
From API Platform to the Domain
namespace App\Infrastructure\BookStore\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;
// ...
) {
$this->id = $id ?? Uuid::v4();
}
}
namespace App\Domain\BookStore\Model;
final class Book
{
public readonly Uuid $id;
public function __construct(
public string $name,
public string $description,
public string $author,
public string $content,
public int $price,
) {
// ...
}
}
Model
API resource
@matarld
@chalas_r
namespace App\Domain\BookStore\Model;
final class Book
{
public readonly Uuid $id;
public function __construct(
private string $name,
private string $description,
private readonly string $author,
private string $content,
private Price $price,
) {
// ...
}
// public function publish(): void {}
// public function unpublish(): void {}
// public function discount(): void {}
}
Rich models:
Entities have behavior
@matarld
@chalas_r
@matarld
@chalas_r
Application Layer
@matarld
@chalas_r
Application Layer
Command/Query pattern
Between domain and infrastructure
Model
Domain layer
API resource
Infrastructure layer
Command/Query bus
Application layer
@matarld
@chalas_r
@matarld
@chalas_r
Find the
cheapest books.
Use case #1
Query and QueryHandler
@matarld
@chalas_r
namespace App\Application\BookStore\Query;
final class FindCheapestBooksQuery implements QueryInterface
{
public function __construct(public readonly int $size = 10)
{
}
}
namespace App\Application\BookStore\Query;
final class FindCheapestBooksQueryHandler implements QueryHandlerInterface
{
public function __construct(private BookRepositoryInterface $bookRepository)
{
}
public function __invoke(FindCheapestBooksQuery $query): iterable
{
return $this->bookRepository
->withCheapestsFirst()
->withPagination(1, $query->size);
}
}
API Platform providers
GET /books/cheapest
@matarld
@chalas_r
Provider
new Get('/books/cheapest')
FindCheapestBooksQueryHandler
???
API Platform providers
@matarld
@chalas_r
FooProvider
CheapestBooksProvider
new GetCollection('/books/cheapest')
GET /books/cheapest
Let's hold the query in the operation
new QueryOperation('/books/cheapest', FindCheapestBooksQuery::class)
FindCheapestBooksQueryHandler
BarProvider
namespace App\Infrastructure\BookStore\ApiPlatform\State\Provider;
final class CheapestBooksProvider implements ProviderInterface
{
public function provide(...): object|array|null
{
// return cheapest books
}
public function supports(...): bool
{
return $operation instanceof QueryOperation
&& FindCheapestBooksQuery::class === $operation->query;
}
}
Handles the specific FindCheapestBooksQuery
@matarld
@chalas_r
Query provider
@matarld
@chalas_r
Can we
do better?
Spoiler: yes
Spoiler: yes
API Platform providers
GET /books/cheapest
@matarld
@chalas_r
CheapestBooksProvider
Let's hold the provider in the operation
new GetCollection('/books/cheapest', provider: CheapestBooksProvider::class)
new GetCollection('/books/cheapest')
, the new way
namespace App\Infrastructure\BookStore\ApiPlatform\State\Provider;
final class CheapestBooksProvider implements ProviderInterface
{
public function provide(...): object|array|null
{
// return cheapest books
}
}
@matarld
@chalas_r
No more supports method
Query provider
, the new way
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;
}
}
@matarld
@chalas_r
Query provider
Domain
Infra
Appli
@matarld
@chalas_r
Applying
a discount.
Use case #3
Command and CommandHandler
@matarld
@chalas_r
namespace App\Application\BookStore\Command;
final class DiscountBookCommand implements CommandInterface
{
public function __construct(
public readonly Uuid $id,
public readonly int $amount,
) {
}
}
namespace App\Application\BookStore\Command;
final class DiscountBookCommandHandler implements CommandHandlerInterface
{
public function __construct(private BookRepositoryInterface $bookRepository)
{
}
public function __invoke(DiscountBookCommand $command): void
{
// my super complex logic
}
}
API Platform processors
POST /books/{id}/discount
@matarld
@chalas_r
DiscountBookProcessor
Let's hold the processor in the operation
new Post(
'/books/{id}/discount',
input: DiscountBookPayload::class,
provider: BookItemProvider::class,
)
, the new way
new Post(
'/books/{id}/discount',
input: DiscountBookPayload::class,
provider: BookItemProvider::class,
processor: DiscountBookProcessor::class,
)
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;
}
}
@matarld
@chalas_r
Command processor
Domain
Infra
Appli
Command processor
namespace App\Infrastructure\BookStore\ApiPlatform\State\Processor;
final class DiscountBookProcessor implements ProcessorInterface
{
public function process(...): void
{
$bookResource = $context['previous_data'];
$command = new DiscountBookCommand(
$bookResource->id,
$data->discountPercentage,
);
$this->commandBus->dispatch($command);
/** @var Book $model */
$model = $this->queryBus->ask(new FindBookQuery($command->id));
return BookResource::fromModel($model);
}
}
Handles the specific DiscountBookCommand
@matarld
@chalas_r
@matarld
@chalas_r
Subscribing to new books
Use case #4
@matarld
@chalas_r
Look, we have CRUD !
Subscription
Resource
@matarld
@chalas_r
<?php
declare(strict_types=1);
namespace App\Infrastructure\BookStore\ApiPlatform\Resource;
#[ApiResource(
shortName: 'Subscription',
operations: [
new GetCollection(
'/subscriptions',
provider: SubscriptionsProvider::class
),
new Post(
'/subscriptions',
processor: CreateSubscriptionProcessor::class
),
new Delete(
'/subscriptions/{id}',
provider: SubscriptionProvider::class,
processor: DeleteSubscriptionProcessor::class,
),
],
)]
final class SubscriptionResource
{
}
Subscription
Model
@matarld
@chalas_r
<?php
declare(strict_types=1);
namespace App\Domain\Subscription\Model\Subscription;
final class Subscription
{
public function __construct(
private readonly string Uuid $id,
private readonly string $email,
) {}
}
Subscription
Resource
@matarld
@chalas_r
<?php
declare(strict_types=1);
namespace App\Infrastructure\BookStore\ApiPlatform\Resource;
#[ApiResource(
shortName: 'Subscription',
operations: [
new GetCollection(
'/subscriptions',
provider: SubscriptionsProvider::class
),
new Post(
'/subscriptions',
processor: CreateSubscriptionProcessor::class
),
new Delete(
'/subscriptions/{id}',
provider: SubscriptionProvider::class,
processor: DeleteSubscriptionProcessor::class,
),
],
)]
final class SubscriptionResource
{
}
CRUD provider
Retrieve a single book
Retrieve a book collection
Handle filters and pagination
namespace App\Infrastructure\BookStore\ApiPlatform\State\Provider;
final class BookCrudProvider implements ProviderInterface
{
public function provide(...): object|array|null
{
if (!$context['operation']->isCollection()) {
$book = $this->queryBus->ask(new FindBookQuery($identifiers['id']));
return BookResource::fromModel($book);
}
$author = $context['filters']['author'] ?? null;
$offset = $this->pagination->getPage(...);
$limit = $this->pagination->getLimit(...);
$models = $this->queryBus->ask(new FindBooksQuery($author, $offset, $limit));
$resources = [];
foreach ($models as $model) { $resources[] = BookResource::fromModel($model); }
return new Paginator($resources, $models->currentPage(), ...);
}
public function supports(...): bool { return BookResource::class === $resourceClass; }
}
@matarld
@chalas_r
CRUD processor
Update a book
Delete a book
Create a book
namespace App\Infrastructure\BookStore\ApiPlatform\State\Processor;
final class BookCrudProcessor implements ProcessorInterface
{
public function process(...): ?Book
{
if ($context['operation']->isDelete()) {
$this->commandBus->dispatch(new DeleteBookCommand(...));
return null;
}
$command = !isset($identifiers['id'])
? new CreateBookCommand(...)
: new UpdateBookCommand(...)
;
$book = $this->commandBus->dispatch($command);
return BookResource::fromModel($book);
}
public function supports(...): bool
{
return BookResource::class === $context['operation']->getClass();
}
}
@matarld
@chalas_r
Wrap it up
@matarld
@chalas_r
└── Domain
└── BookStore
├── Model
│ └── Book.php
└── Repository
└── BookRepositoryInterface.php
└── Application
└── BookStore
├── Command
│ ├── CreateBookCommand.php
│ ├── CreateBookCommandHandler.php
│ ├── DiscountBookCommand.php
│ ├── DiscountBookCommandHandler.php
│ └── ...
└── Query
├── FindBookQuery.php
├── FindBookQueryHandler.php
├── FindCheapestBooksQuery.php
├── FindCheapestBooksQueryHandler.php
└── ...
└─ Infrastructure
├─ BookStore
│ └─ ApiPlatform
│ ├─ DataTransformer
│ │ └─ DiscountBookCommandDataTransformer.php
│ ├─ Payload
│ │ └─ DiscountBookPayload.php
│ ├─ Resource
│ │ └─ BookResource.php
│ └─ State
│ ├─ Processor
│ │ ├─ BookCrudProcessor.php
│ │ └─ DiscountBookProcessor.php
│ └─ Provider
│ ├─ BookCrudProvider.php
│ └─ CheapestBooksProvider.php
└─ Shared
├─ ApiPlatform
│ └─ Metadata
│ ├─ CommandOperation.php
│ └─ QueryOperation.php
└─ Symfony
Wrap it up
@matarld
@chalas_r
#[ApiResource(operations: [
// Queries
new QueryOperation('/books/{id}/overview', query: FindBookQuery::class),
new QueryOperation('/books/cheapest', query: FindCheapestBooksQuery::class, output: BookOverviewView::class),
// Commands
new CommandOperation('/books/anonymize', AnonymizeBooksCommand::class),
new CommandOperation('/books/discount', command: DiscountBooksCommand::class, output: false, status: 202),
new CommandOperation('/books/{id}/discount', command: DiscountBookCommand::class, input: DiscountBookPayload::class),
// CRUD
new GetCollection(),
new Get(),
new Post(),
new Put(),
new Patch(),
new Delete(),
])]
final class BookResource { /* ... */ }
And it just works!
@matarld
@chalas_r
@matarld
@chalas_r
Thanks!
[APIPCON22] DDD & API-P 3
By Robin Chalas
[APIPCON22] DDD & API-P 3
- 581