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

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
└── BookStore
├── Application
│ ├── Command
│ └── Query
│ ├── FindBookQuery.php
│ └── FindBookQueryHandler.php
├── Domain
│ ├── Model
│ │ └── Book.php
│ └── Repository
│ └── BookRepository.php
└── Infrastructure
├── ApiPlatform
└── 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
[...]
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();
}
}
@matarld
@chalas_r

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();
}
}
Simple as sugar cookies?
11 warnings
7 errors
namespace App\BookStore\Domain\Model;
#[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

From API Platform to the Domain
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;
// ...
) {
}
}
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,
) {
// ...
}
}
Model
API resource
@matarld
@chalas_r

final class Book
{
public readonly BookId $id;
public function __construct(
private BookName $name,
private Price $price,
) {
// ...
}
public function rename(BookName $name): void
{
$this->name = $name;
}
public function applyDiscount(Discount $discount): void
{
$this->price = $this->price->applyDiscount($discount);
}
}
@matarld
@chalas_r

Anemic vs Rich models
final class Book
{
public readonly Uuid $id;
public function __construct(
public BookName $name,
public Price $price,
) {
// ...
}
}
@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\BookStore\Application\Query;
final class FindCheapestBooksQuery implements QueryInterface
{
public function __construct(public readonly int $size = 10)
{
}
}
namespace App\BookStore\Application\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')
FindCheapestBooksQuery
???
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)
FindCheapestBooksQuery
BarProvider
namespace App\BookStore\Infrastructure\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 providers
@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\BookStore\Infrastructure\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 providers
, the new way
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;
}
}
@matarld
@chalas_r

Query providers
Domain
Infra
Appli
@matarld
@chalas_r

Applying
a discount.
Use case #2
Command and CommandHandler
@matarld
@chalas_r

namespace App\BookStore\Application\Command;
final class DiscountBookCommand implements CommandInterface
{
public function __construct(
public readonly Uuid $id,
public readonly int $amount,
) {
}
}
namespace App\BookStore\Application\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,
)
Command processors
namespace App\BookStore\Infrastructure\ApiPlatform\State\Provider;
final class DiscountBookProcessor implements ProcessorInterface
{
/**
* @var DiscountBookPayload $data
* @return BookResource
*/
public function process(...): mixed
{
$command = new DiscountBookCommand(
id: $context['previous_data']->id,
discountPercentage: $data->discountPercentage,
);
$this->commandBus->dispatch($command);
/** @var Book $model */
$model = $this->queryBus->ask(new FindBookQuery($command->id));
return BookResource::fromModel($model);
}
}
@matarld
@chalas_r

Domain
Infra
Appli
@matarld
@chalas_r

Subscribing
to new books.
Use case #3
@matarld
@chalas_r

Here we
CRUD again...

@matarld
@chalas_r

Slide sur les bounded contexts
Parler du fait que l'on peut avoir des archis différentes, que ça favorise la future découpe en potentiels microservices, etc
@matarld
@chalas_r

namespace App\Subscription\Entity;
#[ApiResource(
operations: [new Get(), new Post(), new Delete()],
)]
#[ORM\Entity]
final 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,
) {
}
}
@matarld
@chalas_r

CRUD providers
final class SubscriptionsProvider implements ProviderInterface
{
public function provide(...): object|array|null
{
/** @var iterable<Subscription> $models */
$models = $this->queryBus->ask(new FindSubscriptionsQuery());
$resources = [];
foreach ($models as $m) {
$resources[] = SubscriptionResource::fromModel($m);
}
return $resources;
}
}
final class SubscriptionProvider implements ProviderInterface
{
public function provide(...): object|array|null
{
/** @var Subscription|null $model */
$model = $this->queryBus->ask(new FindSubscriptionQuery($uriVariables['id']));
return null !== $model ? SubscriptionResource::fromModel($model) : null;
}
}
@matarld
@chalas_r

CRUD processors
final class CreateSubscriptionProcessor implements ProcessorInterface
{
public function process(...): object|array|null
{
$command = new CreateSubscriptionCommand($data->email);
$this->commandBus->dispatch($command);
return null;
}
}
final class DeleteSubscriptionProcessor implements ProcessorInterface
{
public function process(...): object|array|null
{
$command = new DeleteSubscriptionCommand($data->id);
$this->commandBus->dispatch($command);
return null;
}
}
Wrap it up
@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
│ │ ├── Model
│ │ │ ├── Book.php
│ │ │ └── Subscription.php
│ │ └── Repository
│ │ ├── BookRepositoryInterface.php
│ │ └── SubscriptionRepositoryInterface.php
│ └── Infrastructure
└── Subscription
├── BookStore
└── Subscription
└── Entity
└── Subscription.php
├── 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
@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!
Copy of DDD & API-P 3
By chalasr
Copy of DDD & API-P 3
- 404