Symfony, DDD, CQRS and Hexagonal architecture

Hi, I am Arnaud!

Freelancer software engineer

@arnolanglade

When I started coding, PHP looked like...

include 'db_config.php';

if (!$connection = mysql_connect($hostname, $username, $password)) {
    die('Erreur de connexion : ' . mysql_error());
}
mysql_select_db($dbname, $connection);

if (!isset($_SESSION['userLoggedIn'])) exit;

if (isset($_POST['name'] && $_POST['description'])) {
  $name = mysql_real_escape_string($_GET['name']);
  $description = mysql_real_escape_string($_POST['description']);
  $query = "INSERT INTO map (name, description) " .
           "VALUES ('$name', '$description')";

  if (!$result = mysql_query($query, $connection)) {
  	die('Invalid query: ' . mysql_error());
  }
}

mysql_close($connection);

Symfony 2 was released, and it changed the game!

It prevents the need to reinvent the wheel by solving technical problems

Symfony and its ecosystem have made the development of PHP apps easier

ionicons-v5-i

Then I became

Sylius core team

developer

It helped me to ship CRUD applications more quickly

ionicons-v5-i

I mainly contributed to developing the resource bundle

Entity Manager

Database

Doctrine entity

HTTP Call

Serializer

Form

Request

Controller

Then I became

Akeneo coRe team

developer

We needed more than just a framework and libraries to solve our problems

Akeneo PIM looks like a big CRUD app, but it isn't

ionicons-v5-i

What are the problems with CRUD ?

From CRUD apps to Business apps

Controller

Form / Serializer

Anemic model

HTTP Call

Request

Anemic models have their limitations

ORM

Database

problems #1 : Anemic models do not specifically describe business problems

DDD tactical patterns

to the rescue!

Entities, Value objects and Aggregates

help to design rich domain objects

Value object

Entity

Identity-less

Attribute based equality

Immutable

Behavior-rich

Self-validating

Enforce invariant

Focus on behavior (not data)

 Has an identity

Identity based equality

What is an aggregate?

An aggregate is a cluster of objects (entities and value objects) that together represent a domain concept

Map

Marker

Media

Id

Name

Location

Name

Path

aggregate root

entity

entity

Value object

Value object

Name

Name

Id

Marker List

Map Gallery

Maker Gallery

Value object

Value object

Primitive

Value object

Map

Marker

Media

Cartographer

MarkerCategory

ID

ID

ID

final class Map
{
    public function __construct(
        private readonly string $mapId,
        private Name $name,
        private MarkerList $markers,
    ) {
    }
	
    public static function create(string $mapId, string $name): self
    {
        //...
    }
  
  	public function addMarker(
  		string $markerId, string $name, float $latitude, float $longitude
    ): void {
        //...
    }

    public function shareMap(string $cartographerId): void {
        //...
    }
}

map entity

final class MarkerList
{
    private readonly array $markers;
  
    public function __construct(Marker ...$markers) {
        $this->markers = $markers;
    }

    public static function empty(): MarkerList
    {
        return new self();
    }

    public function add(Marker $marker): MarkerList
    {
        return new self($marker, ...$this->markers);
    }

    public function remove(string $markerId): MarkerList
    {
        return new self(...array_filter(
            $this->markers,
            fn (Marker $marker) => !$marker->equal($markerId),
        ));
    }
}

Marker List value object

problems #2 : We don't know what happens !

Collecter les events des aggregates

addMarker

Get and dispatch

Map

records

MarkerAdded

final class MarkerAdded
{
  public function __construct(
    private readonly string $mapId,
    private readonly string $markerId,
    private readonly Name $name,
    private readonly Location $location,
  ) {}
}

final class Map
{
  public array $events = [];
  
  // ...
  
  public function addMarker(string $markerId, string $name, /** Etc. */): void
  {
    $name = new Name($name);
    $location = new Location($latitude, $longitude);

    $this->markers = $this->markers->add(
      new Marker($markerId, $name, $location, $addedAt)
    );

    $this->events[] = new MarkerAdded($this->mapId, $markerId, $name, $location);
  }
}

Controller

Form / Serializer

Anemic model

HTTP Call

Request

How to  persist data ?

ORM

Database

What is a Repository?

A repository behaves like a collection of aggregates that aims to hide the complexity of storing an object’s state
interface Maps
{
   /**
    * @throws \LogicException
    * @throws UnknownMap
    */
   public function get(string $mapId): Map;

   /**
    * @throws \LogicException
    */
   public function add(Map $map): void;
}

Le pattern Repository

Persistence model

Anemic model

Domain model

Rich model

Strategy #1 : Domain and Persistence models are the same

Store

Query

Domain model

Persistence model

final class DoctrineMaps implements Maps
{
  public function __construct(
    private readonly EntityManagerInterface $entityManager,
  ) {
  }

  public function add(Map $map): void
  {
    try {
      $this->entityManager->persist($map);
   	  $this->entityManager->flush();
    } catch (ORMInvalidArgumentException | ORMException $e) {
      throw new \LogicException('Cannot persist the map', 0, $e);
    }
  }
  
  // ...
}
final class DoctrineMaps implements Maps
{
  // ...

  public function get(string $id): Map
  {
    try {
      $map = $this->entityManager->find(Map::class, $id);

      if (null === $map) {
        throw new UnknownMap::fromId(id);
      }

      return $map;
    } catch (ORMInvalidArgumentException | ORMException $e) {
      throw new \LogicException('Cannot retreive the map', 0, $e);
    }
  }
}

Strategy #2 : Domain and Persistence models are Different

Persistence

model

Store

Query

Convert

Domain model

final class DoctrineMaps implements Maps
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
    ) {
    }

    public function add(Map $map): void
    {
        try {
            $doctrineMap = $this->entityManager->find(
            	DoctrineMap::class, 
                (string) $map->id
            );
            if (!$doctrineMap) {
                $doctrineMap = new DoctrineMap();
                $this->entityManager->persist($doctrineMap);
            }
          
            $map->mapTo($doctrineMap);
            
            $this->entityManager->flush();
        } catch (ORMInvalidArgumentException|ORMException $e) {
            throw new \LogicException('Cannot persist the map', 0, $e);
        }
    }
    // ...
}
final class DoctrineMaps implements Maps
{
    // ...

    public function get(Id $id): Map
    {
        try {
            $doctrineMap = $this->entityManager->find(
              DoctrineMap::class, 
              (string) $id
            );  
            
            if (!$doctrineMap) {
                throw UnknownMap::fromId($id);
            }

            return Map::fromState($doctrineMap);
        } catch (ORMInvalidArgumentException|ORMException $e) {
            throw new \LogicException('Cannot retrieve the map', 0, $e);
        }
    }
  
    // ...
}

Controller

Form / Serializer

Anemic model

HTTP Call

Request

How to handle use cases ?

ORM

Database

What are the problems?

The logic for the use case is implemented in the controller

What happens if you need to handle the same use case through a web controller and a CLI?

It can end with 'Service' or 'Manager' classes

Command &Command Handler

to the rescue!

Commands and command handlers help to handle use cases

What is a Command and a Command Handler?

A command represents the user’s intent, while the command handler performs the actions needed to achieve the use case

How does a handler work?

A command must be valid to be handled

A command handler only handles a single command

Create command

Handle command

Validate command

Dispatch Event

validator

form & serializer

messenger

final class AddMarkerToMap
{
    public function __construct(
        public readonly string $mapId,
        public readonly string $markerId,
        public readonly string $name,
        public readonly float $latitude,
        public readonly float $longitude,
    ) {
    }
}

final class AddMarkerToMapHandler
{
    public function __construct(private readonly Maps $maps) {}

    public function __invoke(AddMarkerToMap $addMarkerToMap): void
    {
        $map = $this->maps->get($addMarkerToMap->mapId);
        $map->addMarker(
            $addMarkerToMap->markerId,
            $addMarkerToMap->name,
            $addMarkerToMap->latitude,
            $addMarkerToMap->longitude
        );
        $this->maps->add($map);
    }
}

Controller

Repository

Anemic model

HTTP Call

Request

How to handle Data retrieval ?

Serializer

Response

You need getters to access an object’s state, but they can break encapsulation

What are the problems?

Why use serializers for tasks that your database can handle?

Multiple queries for complex read use cases

CQRS is not that complicated!

Greg Young's Blog screenshot (only available on archive.org)

What is CQRS?

CQRS is simply the creation of two objects where there was previously only one. The separation occurs based upon whether the methods are a command or a query

 

Greg Young

Map Service

(write)

addMarker

moveMarker

Map Service

addMarker

moveMarker

openMap

findMyMap

Map Service

(read)

openMap

findMyMap

Write side

Read side

Query

Command

Command Handler

Aggregate

Query  Function

Result

Read Model

final class DoctrineOpenMap implements OpenMap
{
	// Constructor
    public function __invoke(string $mapId): Map
    {
        $sql = "<<<SQL
            SELECT map.map_id, map.name, COALESCE(
                json_agg(marker) FILTER (WHERE marker.marker_id IS NOT NULL),
                '[]'
            ) AS markers
            FROM map
            LEFT JOIN marker ON map.map_id = marker.map_id
            WHERE map.map_id = :map_id
            GROUP BY map.map_id
        SQL";
        try {
        	$statement = $this->connection->executeQuery($sql, ['map_id' => $mapId]);
            $map = $statement->fetchAssociative();
        } catch (DBALException $e) {
            throw new LogicException("SQL query failed {$e->getMessage()}", 0, $e);
        }

        if (false === $map) {
            throw UnknownMap::fromId($mapId);
        }

        return Map::fromArray($map);
    }
}
class Map
{
    public function __construct(
        public readonly string $mapId,
        public readonly string $name,
        public readonly array  $markers,
    ) {
    }
    
    public static function fromArray(array $map): self
    {
        return new self(
            $map['map_id'],
            $map['name'],
            array_map(
                fn(array $marker) => Marker::fromArray($marker),
                json_decode($map['markers'], true)
            )
        );
    }
}

class Marker
{
    public function __construct(
        public readonly string $markerId,
        public readonly string $name,
        public readonly float $latitude,
        public readonly float $longitude,
    ) {
    }
}

It is useful to collect data from multiple aggregates

It is better to work with smaller business-oriented objects #myTwoCents

We can do more with CQRS, but do you really need that?

That's all, folks!

What about the Hexagonal architecture?

or the Port & Adapter Architecture

Domain

Secondary ports

Primary ports

Adapter

Port

Port

Adapter

Port

ionicons-v5-j

Adapter

Port

Adapter

Domain

The domain is the area where we solve our business problems no matter the technical constraints

No framework, business focused

No Input Output (tools)

No framework doesn't mean no libraries

Test with shoot feedback loop

Domain

command handlers

Entities

Value objects

Domain services

No IO? How will my app work in the real world?

Thanks to Secondary Ports

Open a (secondary) port

Domain (no IO)

Infrastructure (IO)

Port

Adapter

Adapter

Applying the Dependency inversion principle (SOLID)

Domain (no IO)

Infrastructure (IO)

Interface

Concrete class

Concrete class

Domain (no IO)

Infrastructure (IO)

Maps

PosgresqlMaps

InMemoryMaps

We can easily postpone the decision on which tools to use

How can we drive the application?

Thanks to Primary Ports

Domain

Primary ports

ionicons-v5-j

Port

Web

CLI

Secondary ports

Adapter

Port

Adapter

class WebAddMarkerToMap extends AbstractController
{
	// constructor

    #[Route('/maps', methods: ['PUT'])]
    public function __invoke(Request $request): Response
    {
        $command = $this->serializer->deserialize(
            $request->getContent(),
            AddMarkerToMap::class,
            'json',
        );
        
        if (count($errors = $this->validator->validate($command)) > 0) {
            return new Response(
            	$this->serializer->serialize($errors, 'json'), 
            	400
            );
        }

        $this->entityManager->wrapInTransaction(
        	fn () => ($this->createMapHandler)($command)
        );

        return new Response();
    }
}
class CliAddMarkerToMap extends Command
{
	// constructor

    protected function configure(): void
    {
        $this->addArgument('name', InputArgument::REQUIRED, 'Marker name');
        // Etc.
    }

    protected function execute(
    	InputInterface $input, OutputInterface $output
    ): int {
        $command = new AddMarkerToMap($input->getArgument('name'), /** etc. */);

        if (count($this->validator->validate($command)) > 0) {
            return Command::FAILURE;
        }

        $this->entityManager->wrapInTransaction(
        	fn () => ($this->createMapHandler)($command)
        );

        return Command::SUCCESS;
    }
}

Can we simplify this?

Yes, with a Command Bus

The command bus

Logging

middleware

Validation

middleware

Transactional

middleware

Handle command

middleware

Command

Command bus

messenger

class WebAddMarkerToMap extends AbstractController
{
	// constructor

    #[Route('/maps', methods: ['PUT'])]
    public function __invoke(Request $request): Response
    {
        $command = $this->serializer->deserialize(
            $request->getContent(),
            AddMarkerToMap::class,
            'json',
        );

        try {
            $this->bus->dispatch($command);
        } catch (ValidationFailedException $e) {
            return new Response(
                $this->serializer->serialize($e->getViolations(), 'json'), 
                400
            );
        }

        return new Response();
    }
}

Domain

Flow of control

Port

Port

ionicons-v5-j

Web

Adapter

Input

Output

What test strategy should be applied?

test pyramid

Integration tests

(cover the infra)

End to End tests

Unit tests

(cover the domain and more)

Fast

Slow

Dev

Regression

 Two Symfony environments

Controller

Handler

Aggregate

Repository

Command

APP_ENV=test

APP_ENV=testio

Primary adapters

Secondary adapters

Domain

Agile Pays Basque (6-7sept)

Thank you!

Any Questions?

@arnolanglade

 Two feedback loops

Controller

Handler

Aggregate

Repository

Command

tests without IO

tests with IO

Long feedback loop

Fast feedback loop

Pyramide des tests ?

// it uses the WebTestCase
describe('user interface - web - create a map', function () {
    it('creates a map from the web end point', function () {
        /** @var KernelBrowser $client */
        $client = static::createClient();
        $client->request('POST', '/maps', content: json_encode([
            'name' => 'My map',
        ]));

        expect($client->getResponse()->getStatusCode())->toEqual(200);
    });
});

How to test Primary Adapters

describe('map use cases', function () {
    test('a cartographer adds marker to a map', function () {
        $mapId = Uuid::v4();
        $maps = new InMemoryMaps([Map::whatever(id: $mapId, markers: [])]);

        $markerId = Uuid::v4();
        $name = 'Sunset';
        $latitude = 43.4833;
        $longitude = -1.5167;
        (new AddMarkerToMapHandler($maps))(
          new AddMarkerToMap(
            $mapId, $markerId, $name, $latitude, $longitude
          )
        );

        expect($maps->get(new Id($mapId)))->toEqual(
          	Map::whatever(id: $mapId, markers: [
            	Marker::whatever($markerId, $name, $latitude, $longitude)
        	])
        );
    });
});

How to test Use cases

describe('infra - maps', function () {
    it('persists and retrieves a map with marker', function ($serviceName) {
        $this->container->get(Database::class)->truncateAllTables();
        $maps = $this->container->get($serviceName);

        $map = Map::whatever(Uuid::v4(), markers: [
            Marker::whatever(Uuid::v4()),
            Marker::whatever(Uuid::v4()),
        ]);

        $maps->add($map);

        expect($maps->get($map->id))->toEqual($map);
    })->with([
        InMemoryMaps::class,
        DoctrineMaps::class,
    ]);
});

How to test Secondary Adapters

Strategy with one primary Adapter

Acceptance tests

tests without IO

tests with IO

Controller

Handler

Aggregate

Repository

Unit tests

Integration tests

Controller

Handler

Aggregate

Repository

Command

Acceptance tests

Integration tests

Unit tests

Unit tests

tests without IO

tests with IO

Strategy with Two primary Adapters

Doctrine entities do not specifically describe business problems

Doctrine entities are persistence models

Doctrine entities are data structures (POJO)

What are the problems?

Problème 1

The repository design pattern is often misunderstood

Persistence model versus Domain model

What are the problems?

DDD tactical patterns

to the rescue!

A repository helps to store the state of domain objects

Focus: Data retrieval

Request

Repository

Doctrine entity

Serialiser

Controller

Problem : The repository design pattern is often misunderstood

Ce n'est pas quelque chose dédié à la lecture

Focus: use cases handling

Serializer

Form

Request

Controller

Entity Manager

Doctrine entity

Focus: Data persistence

Entity Manager

Serializer

Form

Request

Controller

Doctrine entity

Focus: Anemic model

Entity Manager

Doctrine entity

Serializer

Form

Request

Controller

Should you apply this blindly?

The answer is no!