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!