Domain-Driven Design with
API Platform
@matarld
@chalas_r

Mathias Arlaud
mtarld

@matarld

les-tilleuls.coop


Robin Chalas
chalasr

@chalas_r

les-tilleuls.coop

DDD is not prescriptive.
@matarld
@chalas_r
Disclaimer
DDD ≠ RAD
“Domain Driven Design is not a fast way to build software.
William Durand - DDD with Symfony 2: Making things clear
However, it is able to ease your life when you have to deal with complex business expectations.”
@matarld
@chalas_r
DDD ≠ RAD
“Domain Driven Design is not a fast way to build software.
William Durand - DDD with Symfony 2: Making things clear
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
API Platform common
└── src
├── DataProvider
│
├── DataPersister
│
├── Entity
│ └── Panda.php
│
└── Repository
└── PandaRepository.php
Directory structure
@matarld
@chalas_r
API Platform hexagonal
└── src
├── Application
│ └── Forest
│ ├── Command
│ ├── Payload
│ ├── Query
│ └── View
├── Domain
│ └── Forest
│ ├── Event
│ ├── Model
│ │ └── Panda.php
│ └── Repository
│ └── PandaRepository.php
└── Infrastructure
└── Forest
└── Repository
Hexagonal Architecture
@matarld
@chalas_r
The Layers and The Dependency Rule
Domain
Models, Value objects, Events, Repositories
Application
Use cases, Application services, DTOs, Commands, Queries
Infrastructure
Controllers, Databases, Caches, Vendors
Hexagonal Architecture
@matarld
@chalas_r
Benefits
Domain integrity is preserved
Code is more testable
Technological decisions can be deferred
Domain is agnostic to the outside world
Our tiny panda

Doctrine
Schema
API Platform
Resource

PHP
Object

@matarld
@chalas_r
// src/Domain/Forest/Model/Panda.php
#[ORM\Entity]
#[ApiResource]
final class Panda
{
public function __construct(
private UuidInterface $uuid,
// ...
#[ORM\Column(type: 'int')]
private int $hungerAmount,
) {
}
// ...
public function isHungry(): bool
{
return $this->hungerAmount > self::HUNGERNESS_THRESHOLD;
}
public function feed(Bamboo $bamboo): void
{
$this->hungerAmount = min(0, $this->hunger - $bamboo->getSize());
}
}
@matarld
@chalas_r
Decouple from Doctrine



#[ORM\Entity]
#[ApiResource]
final class Panda
{
public function __construct(
private UuidInterface $uuid,
// ...
#[ORM\Column(type: 'int')]
private int $hunger,
) {
}
// ...
}
<!-- src/Infrastructure/Forest/Doctrine/Mapping/Panda.xml -->
<entity name="Acme\Domain\Forest\Model\Panda" table="panda">
<field name="uuid" type="uuid" unique="true" />
<!-- ... -->
<field name="hungry" type="integer" />
</entity>
@matarld
@chalas_r
Decouple from API Platform

#[ApiResource]
final class Panda
{
public function __construct(
private UuidInterface $uuid,
// ...
private int $hunger,
) {
}
// ...
}
<!-- src/Infrastructure/Forest/ApiPlatform/Resource/Panda.xml -->
<resource class="Acme\Domain\Forest\Model\Panda">
<itemOperations><!-- ... --></itemOperations>
<collectionOperations><!-- ... --></collectionOperations>
</resource>


Finding pandas
> curl /api/pandas/{uuid}

API Platform
ReadListener

@matarld
@chalas_r
ChainItemDataProvider
[resourceClass, operationName, context]
Doctrine
ItemDataProvider
Finding pandas
> curl /api/pandas/{uuid}

API Platform
ReadListener

ChainItemDataProvider
[resourceClass, operationName, context]
DependencyInjection
Compiler Pass

Clearing tags
Our DataProvider
@matarld
@chalas_r
Command/Query pattern
@matarld
@chalas_r
Command

Domain
Infra
Query
Query based data provider
// src/Application/Forest/Query/FindPandaByUuidQuery.php
final class FindPandaByUuidQuery extends FindByUuidQuery
{
}
FindByUuidQuery
@matarld
@chalas_r
// src/Application/Forest/Query/FindPandaByUuidQueryHandler.php
use Acme\Domain\Forest\Repository\PandaRepository;
final class FindPandaByUuidQueryHandler implements QueryHandler
{
public function __construct(private PandaRepository $repository) {}
final public function __invoke(FindPandaByUuidQuery $query): ?Panda
{
return $this->repository->searchByUuid($query->uuid);
}
}
Query based data provider
FindPandaByUuidQuery
Messenger
Query bus

Panda
A new resource operation attribute

@matarld
@chalas_r
<itemOperation name="get">
<attribute name="query">FindPandaByUuidQuery</attribute>
</itemOperation>
// src/Infrastructure/Shared/ApiPlatform/DataProvider/ItemQueryDataProvider.php
final class ItemQueryDataProvider implements DataProviderInterface
{
public function getItem(...): ?object
{
$queryClass = $this->resourceMetadataFactory
->create($resourceClass)
->getItemOperationAttribute($operationName, 'query');
return $this->queryBus->ask(new $queryClass($identifiers['uuid']));
}
public function supports(...): bool { /* ... */}
}
ItemQueryDataProvider
Check that we have a query attribute defined
Ensure that's a
FindByUuidQuery
DependencyInjection
Compiler Pass

Query based data provider
@matarld
@chalas_r
// src/Infrastructure/Shared/ApiPlatform/DataProvider/CollectionQueryDataProvider.php
final class CollectionQueryDataProvider implements DataProviderInterface
{
public function getCollection(...): iterable
{
$queryClass = $this->resourceMetadataFactory
->create($resourceClass)
->getCollectionOperationAttribute($operationName, 'query');
return $this->queryBus->ask($queryClass::fromContext($context));
}
}
<collectionOperation name="get">
<attribute name="query">FindPandasQuery</attribute>
</collectionOperation>
CollectionQueryDataProvider
Query based data provider
FindPandasQuery
Pandas





FindByCriteriaQuery
@matarld
@chalas_r
Custom DataProvider
Query based data provider
FindExtraterrialPandasQuery

Custom DataProvider
ExtraterrialPandaDataProvider
FindPandaByUuidQuery

CRUD DataProvider
ItemQueryDataProvider

Altering pandas
> curl -X POST /api/pandas

API Platform
WriteListener

ChainDataPersister
[resourceClass, operationName, context]
DependencyInjection
Compiler Pass

Clearing tags
Our DataPersister
@matarld
@chalas_r
// src/Infrastructure/Shared/ApiPlatform/DataPersister/DataPersister.php
final class DataPersister implements DataPersisterInterface
{
public function persist(...): ?object
{
$commandClass = $this->resourceMetadataFactory->create($resourceClass)
->getOperationAttribute($context, 'command');
return $this->commandBus->dispatch($commandClass::fromModel($data));
}
public function remove(...): void
{
$commandClass = $this->resourceMetadataFactory->create($resourceClass)
->getOperationAttribute($context, 'command');
return $this->commandBus->dispatch($commandClass::fromModel($data));
}
}
DataPersister
Command based data persister
@matarld
@chalas_r
PersistCommand
RemoveCommand
Command based data persister
Commands
// src/Application/Forest/Command/RemovePandaCommandHandler.php
final class RemovePandaCommandHandler implements CommandHandler
{
public function __construct(private PandaRepository $repository) {}
public function __invoke(RemovePandaCommand $command): void
{
$this->repository->remove($command->id());
}
}
@matarld
@chalas_r
// src/Application/Forest/Command/RemovePandaCommand.php
final class RemovePandaCommand implements RemoveCommand
{
public function __construct(private int $id) {}
public static function fromModel(object $panda): self
{
return new self($panda->id());
}
}
Command based data persister
Commands
@matarld
@chalas_r
<itemOperation name="remove">
<attribute name="query">FindPandaByUuidQuery</attribute>
<attribute name="command">RemovePandaCommand</attribute>
</itemOperation>
Delete it
Find a panda by its uuid
In a nutshell

ItemQueryDataProvider
FindByUuidQuery
CollectionQueryDataProvider
FindByCriteriaQuery
DataPersister
PersistCommand
DataPersister
RemoveCommand
API Platform
ReadListener

API Platform
WriteListener

@matarld
@chalas_r
Anemic models
Use case
final class Panda
{
public function __construct(private string $name)
{
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
}
{"name": "Pedro Panda"}
PATCH
Symfony
PropertyAccessor

Public properties
Setters
Constructor arguments
final class Panda
{
public function __construct(private string $name)
{
}
public function name(): string
{
return $this->name;
}
public function rename(string $firstname, string $lastname): void
{
$this->name = sprintf('%s %s', $firstname, $lastname);
}
}
Public properties
Setters
Constructor arguments
@matarld
@chalas_r
Rich models
DTOs
Data Transfer Objects

@matarld
@chalas_r
DTOs
Payload and views
{
"uuid": "...",
"name": "Pedro Panda",
"hungry": true
}
{
"firstname": "Pedro",
"lastname": "Panda"
}
final class Panda
{
public function __construct(
private UuidInterface $uuid,
private string $name,
private int $hunger,
) {
}
// ...
}
Payload
Model
View
Concatenate firstname and lastname
Convert hunger to "hungry" boolean
@matarld
@chalas_r
DTOs
API Platform configuration
<itemOperation name="get">
<attribute name="input">Acme\Application\Forest\Payload\PandaPayload</attribute>
<attribute name="output">Acme\Application\Forest\View\PandaView</attribute>
</itemOperation>
API Platform
DeserializeListener

API Platform
SerializeListener

@matarld
@chalas_r
Payload
Input DTO
// src/Application/Forest/Payload/PandaPayload.php
final class PandaPayload
{
public function __construct(
public readonly ?string $firstname = null,
public readonly ?string $lastname = null,
) {
}
}
// src/Domain/Forest/Model/Panda.php
final class Panda
{
public function __construct(private string $name)
{
}
public function rename(
string $firstname,
string $lastname,
): void {
$this->name = sprintf('%s %s', $firstname, $lastname);
}
}
PandaPayloadDataTransformer
Use rename method
@matarld
@chalas_r
Payload
PayloadDataTransformer
// src/Infrastructure/Forest/ApiPlatform/DataTransformer/PandaPayloadDataTransformer.php
final class PandaPayloadDataTransformer implements DataTransformerInterface
{
public function transform($payload, string $to, array $context = []) {
$panda = $context['object_to_populate'] ?? new Panda();
$panda->rename($payload->firstname, $payload->lastname);
return $panda;
}
public function supportsTransformation($data, string $to, array $context = []): bool {
return PandaPayload::class === ($context['input']['class'] ?? null)
&& Panda::class === $to;
}
}
@matarld
@chalas_r
View
Output DTO
// src/Domain/Forest/Model/Panda.php
final class Panda
{
public function __construct(
private UuidInterface $uuid
private string $name,
private int $hunger,
) {
}
public function isHungry(): bool
{
return $this->hunger > self::HUNGERNESS_THRESHOLD;
}
}
// src/Application/Forest/View/PandaView.php
final class PandaView
{
public function __construct(
public readonly string $uuid,
public readonly string $name,
public readonly bool $hungry,
) {
}
}
PandaViewDataTransformer
Cast uuid, use isHungry method
@matarld
@chalas_r
View
ViewDataTransformer
// src/Infrastructure/Forest/ApiPlatform/DataTransformer/PandaViewDataTransformer.php
final class PandaViewDataTransformer implements DataTransformer
{
public function transform($panda, string $to, array $context = [])
{
return PandaView((string) $panda->uuid(), $panda->name(), $panda->isHungry());
}
public function supportsTransformation($data, string $to, array $context = []): bool
{
return PandaView::class === $to && $data instanceof Panda;
}
}
@matarld
@chalas_r
To Summarize
@matarld
@chalas_r
API Platform is fully extensible.
Don't do this for your personal blog!
Take back control over the domain
Embrace complexity
Write more scalable & testable code
To Summarize
@matarld
@chalas_r

Thanks!
@matarld

@chalas_r
@coopTilleuls
[Forum PHP] DDD & APIP
By Mathias Arlaud
[Forum PHP] DDD & APIP
- 9,596