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

  • 5,173