Domain-Driven Design and

API Platform 3

@matarld
@chalas_r

Mathias Arlaud

@matarld
mtarld
les-tilleuls.coop

Robin Chalas

@chalas_r
les-tilleuls.coop
chalasr

DDD is not prescriptive.

@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
└──   src
      ├──   Provider
      │
      ├──   Processor
      │
      ├──   Entity
      │     └──   Book.php
      │
      └──   Repository
            └──   BookRepository.php
@matarld
@chalas_r

Directory structure

Directory structure

Directory structure

@matarld
@chalas_r
└──   src
      └──   BookStore
            ├──   Application
            │     ├──   Command
            │     └──   Query
            │           ├──   FindBookQuery.php
            │           └──   FindBookQueryHandler.php
            ├──   Domain
            │     ├──   Model
            │     │     └──   Book.php
            │     └──   Repository
            │           └──   BookRepository.php
            └──   Infrastructure
                  ├──   ApiPlatform
                  └──   Doctrine

Directory structure

Directory structure

Directory structure

Domain

Models, Value objects, Events, Repositories 

Application

Use cases, Application services, DTOs, Commands, Queries

Infrastructure

Controllers, Databases, Caches, Vendors
@matarld
@chalas_r

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

Hexagonal architecture

GET /books
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

Reading is dreaming with open eyes...

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();
    }
}
11 warnings
7  errors
#[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

Simple as sugar cookies?

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

@matarld
@chalas_r

From API Platform to the Domain

API Resource

@matarld
@chalas_r

The Application Layer

Command/Query bus

Application layer
@matarld
@chalas_r

Command/Query pattern

Model

Domain layer

API resource

Infrastructure layer
@matarld
@chalas_r

Find the

cheapest books.

Use case #1
@matarld
@chalas_r

Query and QueryHandler

namespace App\BookStore\Application\Query;

final class FindCheapestBooksQueryHandler
{
  public function __construct(
      private BookRepositoryInterface $bookRepository
  ) {
  }

  public function __invoke(FindCheapestBooksQuery $query)
  {
    return $this->bookRepository
      ->withCheapestsFirst()
      ->withPagination(1, $query->size);
  }
}

QueryHandler

namespace App\BookStore\Application\Query;

final class FindCheapestBooksQuery
{
    public function __construct(
        public readonly int $size = 10,
    ) {
    }
}

Query

GET /books/cheapest
@matarld
@chalas_r

API Platform providers

FindCheapestBooksQuery
Provider
new GetCollection('/books/cheapest')

Operation

@matarld
@chalas_r

Let's hold the query in the operation!

API Platform providers

GET /books/cheapest
FooProvider
CheapestBooksProvider
BarProvider
new QueryOperation(
 '/books/cheapest',
 FindCheapestBooksQuery::class,
)

"Query" operation

new GetCollection('/books/cheapest')

Operation

Handles the specific
FindCheapestBooksQuery
@matarld
@chalas_r

Query providers

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;
    }
}
@matarld
@chalas_r

Can we

do better?

Spoiler: yes
@matarld
@chalas_r

Let's hold the provider in the operation!

Providers - the new way

GET /books/cheapest
FooProvider
CheapestBooksProvider
BarProvider
new GetCollection(
 '/books/cheapest',
 provider: CheapestBooksProvider::class,
)

Operation

new GetCollection('/books/cheapest')

Operation

@matarld
@chalas_r
No more
supports method

Query providers - 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

Domain

Query providers' content

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;
  }
}

Appli

Infra

@matarld
@chalas_r

Applying

a discount.

Use case #2
@matarld
@chalas_r

Command and CommandHandler

namespace App\BookStore\Application\Command;

final class DiscountBookCommandHandler
{
  public function __construct(
    private BookRepositoryInterface $bookRepository,
  ) {
  }

  public function __invoke(DiscountBookCommand $command)
  {
    // my super complex logic
  }
}

CommandHandler

namespace App\BookStore\Application\Command;

final class DiscountBookCommand
{
    public function __construct(
        public readonly Uuid $id,
        public readonly int $amount,
    ) {
    }
}

Command

@matarld
@chalas_r

Let's hold the processor in the operation!

Processors - the new way

POST /books/{id}/discount
DiscountBookProcessor
new Post(
  '/books/{id}/discount',
  input: DiscountBookPayload::class,
  provider: BookItemProvider::class,
  processor: DiscountBookProcessor::class,
)

Operation

new Post(
  '/books/{id}/discount',
  input: DiscountBookPayload::class,
  provider: BookItemProvider::class,
)

Operation

@matarld
@chalas_r

Domain

Command processors' content

namespace App\BookStore\Infrastructure\ApiPlatform\State\Provider;

final class DiscountBookProcessor implements ProcessorInterface
{
  /**
   * @var DiscountBookPayload $data
   * @return BookResource
   */
  public function process(...): mixed
  {
    $this->commandBus->dispatch(new DiscountBookCommand(
      id: $context['previous_data']->id,
      discountPercentage: $data->discountPercentage,
    ));
    
    $model = $this->queryBus->ask(new FindBookQuery($command->id));

    return BookResource::fromModel($model);
  }
}

Appli

Infra

@matarld
@chalas_r

Subscribing to

new books.

Use case #3
@matarld
@chalas_r

Bounded contexts

Subscription
BookStore
Payment

Hexagonal

Regular

@matarld
@chalas_r

Regular API Platform CRUD

namespace App\Subscription\Entity;

#[ApiResource(operations: [new Get(), new Post()])]
#[ORM\Entity]
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

Hexagonal API Platform CRUD

namespace App\BookStore\ApiPlatform\Resource;

#[ApiResource(
  operations: [
    new Get(provider: AuthorItemProvider::class), 
    new Post(processor: CreateAuthorProcessor::class),
  ],
)]
final class Author
{
    public function __construct(
        public ?Uuid $id = null,
        public ?string $name = null,
    ) {
    }
}
@matarld
@chalas_r

Hexagonal API Platform CRUD

final class AuthorItemProvider
{
  public function __construct(
    private QueryBusInterface $queryBus,
  ) {
  }

  public function provide(...): object|array|null
  {
    $id = new AuthorId($uriVariables['id']);
    $model = $this->queryBus->ask(new FindBookQuery($id));

    return null !== $model 
      ? BookResource::fromModel($model)
      : null;
  }
}
final class CreateAuthorProcessor
{
  public function __construct(
    private CommandBusInterface $commandBus,
  ) {
  }

  public function process(...): mixed
  {
    $command = new CreateAuthorCommand(
      new AuthorName($data->name),
    );
    
    $this->commandBus->dispatch($command);
    
    return null;
  }
}
@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

Domain

Application

Infrastructure

Regular API-P

@matarld
@chalas_r

Wrap it up

Business hexagonal

#[ApiResource(
  operations: [
    new GetCollection(
      '/books/cheapest.{_format}',
      provider: CheapestBooksProvider::class,
    ),
    new Post(
      '/books/{id}/discount.{_format}',
      input: DiscountBookPayload::class,
      provider: BookItemProvider::class,
      processor: DiscountBookProcessor::class,
    ),
  ],
)]
final class BookResource

Hexagonal business

Regular CRUD

#[ApiResource(
  operations: [new Get(), new Post()],
)]
final class Subscription

Hexagonal CRUD

#[ApiResource(
  operations: [
    new Get(provider: AuthorItemProvider::class), 
    new Post(processor: CreateAuthorProcessor::class),
  ],
)]
final class Author

@matarld
@chalas_r

And it just works!

Thanks!

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,
    ) {
        // ...
    }
}

Copy of [APIP Con] DDD & API-P 3

By chalasr

Copy of [APIP Con] DDD & API-P 3

  • 399