close

Découvrez nos offres pour faire du digital le levier de votre croissance !

download
Modèles

Téléchargez le Guide Ultime de gestion de projet digitale pour vous aider à piloter vos transformations et faire les bons choix !

Image mise en avant pour l'article

Implémentation d’une architecture hexagonale avec Symfony

22 avril 2024
Pourquoi choisir une architecture hexagonale plutôt qu’une architecture classique ? Comment implémenter ce type d’architecture avec le framework Symfony ?


Dans cet article, nous allons explorer l’implémentation d’une architecture hexagonale avec le framework Symfony.

L’architecture hexagonale, aussi connue sous le nom de Ports and Adapters Pattern, est une méthode de conception de logiciels qui vise à créer des applications indépendantes de toute technologie spécifique, rendant ainsi le code plus maintenable et adaptable.

L’idée de cette article sera donc de montrer comment grâce à ce type d’architecture il est possible de rendre notre code moins dépendant de son infrastructure. Mais avant de parler d’implémentation, revenons très rapidement sur les avantages et les différentes couches de l’architecture hexagonale.

 

Sur la photo, nous voyons un développeur Web échanger avec une cheffe de projet.

Pourquoi choisir une architecture hexagonale 

Là où une architecture plus « classique » pourrait mélanger la logique métier avec des détails d'implémentation spécifiques, rendant le code plus difficile à comprendre, à maintenir et à tester, l'architecture hexagonale offre plusieurs avantages significatifs.

Tout d'abord, elle permet de séparer clairement la logique métier du reste de l'application, facilitant la maintenance et la compréhension du code. Elle rend également l'application moins dépendante des technologies tierces. Ce qui permet de changer la base de données, le framework ou autres composants de l'infrastructure sans affecter la logique métier. L'application devient donc plus facile à tester.


Quelles sont les différentes couches de l’architecture hexagonale ?

L’architecture hexagonale est composée de trois couches principales :

  1. La couche « Domaine » - C’est le cœur de l’application qui contient la logique métier. Elle est indépendante de toute technologie spécifique et ne sait rien des couches extérieures.
  2. La couche « Application » - Cette couche orchestre le flux de données entre la couche « Domaine » et les Ports. Elle contient également la logique d’application qui n’est pas purement métier (comme les transactions de base de données).
  3. La couche « Infrastructure » - C’est la couche externe qui interagit avec le monde extérieur comme la base de données, le système de fichiers, les services Web, etc. Elle implémente les Ports définis dans la couche « Domaine », et est le point d’entrée et de sortie de l’application.
 

Comment l’implémenter dans notre application ?

Notre objectif sera de créer une application qui permet de gérer des articles éditoriaux. Nous pourrons ainsi récupérer des articles existants ou en sauvegarder de nouveau.

La structure de fichier reflétera cette architecture :

Capture d'écran de l'architecture globale

Voyons maintenant plus en détails comment implémenter chaque couche dans notre application.


La couche « domaine »

Capture d'écran de l'architecture domaine

Dans un premier temps nous allons créer notre Model, la classe représentant notre structure de données Article, cette classe comprendra simplement les différentes propriétés qui composent notre article ainsi que des accesseurs à celles-ci :



namespace App\Domain\Model;

class Post {
  
  protected int $id;

  protected string $title;
  
  protected string $content;

  public function getId(): int
  {
    return $this->id;
  }

  public function setId(int $id): self
  {
    $this->id = $id;

    return $this;
  }

  public function getTitle(): string
  {
    return $this->title;
  }

  public function setTitle(string $title): self
  {
    $this->title = $title;

    return $this;
  }

  public function getContent(): string
  {
    return $this->content;
  }

  public function setContent(string $content): self
  {
    $this->content = $content;

    return $this;
  }
}

Vous noterez que Symfony gère son Model avec des entités. Ces entités peuvent être liées à l’ORM Doctrine de plusieurs manières : via des attributs ou annotations, ou via des fichiers de configuration. Dans l’idée d’éviter d’inclure Doctrine dans notre Domain nous préfèrerons utiliser un fichier de configuration dédié. Notre classe ne contiendra donc plus aucune référence à l’ORM.

La configuration du service Doctrine se fera donc de cette manière :


doctrine:
    dbal:
        url: '%env(resolve:DATABASE_URL)%'
        profiling_collect_backtrace: '%kernel.debug%'
        use_savepoints: true
    orm:
        mappings:
            App:
                type: xml
                is_bundle: false
                dir: '%kernel.project_dir%/config/doctrine'
                prefix: 'App\Domain\Model'
                alias: App
        auto_mapping: true

Il faudra également spécifier un « mapping » pour notre Model. Ici nous le ferons en XML, mais il existe d’autres possibilités :

capture d'écran d'un bout de code issu de la doc XMLCapture d'écran de l'implémentation de la doc listée ci-dessus (config/doctrine/Post.orm.xml)


Le couche « Application »

Capture d'écran de l'architecture application

La couche d’application agit comme un médiateur entre la couche de « domaine » et la couche « d’infrastructure ». Elle orchestre le flux de données entre ces deux couches.

Dans le cadre de notre application Symfony, cette couche comprend des commandes et des requêtes dans le cadre du modèle CQRS (Command Query Responsibility Segregation). Les commandes représentent les opérations d’écriture que nous pouvons effectuer, tandis que les requêtes représentent les opérations de lecture.

Dans ce contexte, nous avons deux actions, une commande CreatePostCommand pour créer un nouvel article, et une requête GetPostQuery pour récupérer un article existant. Ces classes contiendront la logique nécessaire pour effectuer ces actions.

La classe CreatePostCommand.php contiendra donc des propriétés nécessaires à la création d’un article.


src/Application/Handler/GetPostHandler.php

namespace App\Application\Command;

class CreatePostCommand {
  
  protected string $title;
  
  protected string $content;

  public function __construct($title, $content) {
    $this->title = $title;
    $this->content = $content;
  }

  public function getTitle(): string
  {
    return $this->title;
  }

  public function setTitle(string $title): self
  {
    $this->title = $title;

    return $this;Domaine
  }

  public function getContent(): string
  {
    return $this->content;
  }

  public function setContent(string $content): self
  {
    $this->content = $content;

    return $this;
  }
}

Et la classe GetPostQuery.php contiendra les données nécessaires à la récupération d’un article.


namespace App\Application\Query;

class GetPostQuery {

  public function __construct(protected int $id) {}

  public function getId(): int
  {
    return $this->id;
  }

  public function setId(int $id): self
  {
    $this->id = $id;

    return $this;
  }
}

L’implémentation de la logique de ces actions se fait ensuite dans des Handlers. Nous allons donc créer un handler par Command/Query. Chaque Handler contiendra une méthode handle où se trouvera sa logique.

Pour réaliser ces actions nous aurons besoin d’interagir avec la base de données, et donc d’utiliser notre ORM. Dans notre architecture l’ORM fait partie de la dernière couche « infrastructure ». Nous souhaitons au maximum isoler notre couche « Application » de cette dernière.

Pour ce faire nous allons passer par le pattern Ports et Adapters qui vise à rendre notre application indépendante de toutes dépendances externes, tel qu’un framework ou une base de données. Pour ce faire, nous définissons des Ports, c’est-à-dire des interfaces, puis nous créerons des Adapters qui sont les implémentations de celles-ci.


namespace App\Application\Port\Database;

use App\Domain\Model\Post;

interface PostDatabasePort {
  
  public function save(Post $post, bool $flush = false): void;
  
  public function findOneById(int $id): Post;

}

Nous implémentons ensuite les handlers qui exécutent les actions liées à la Command/Query en utilisant cette interface :


namespace App\Application\Handler;

use App\Application\Command\CreatePostCommand;
use App\Application\Port\Database\PostDatabasePort;
use App\Domain\Model\Post;

class CreatePostHandler {
  public function __construct(
    protected PostDatabasePort $databasePort
  ) {
  }

  public function handle(CreatePostCommand $command): Post {
    $post = new Post();

    $post
      ->setTitle($command->getTitle())
      ->setContent($command->getContent());
    
    $this->databasePort->save($post, true);

    return $post;
  }
}


namespace App\Application\Handler;

use App\Application\Port\Database\PostDatabasePort;
use App\Application\Query\GetPostQuery;
use App\Domain\Model\Post;

class GetPostHandler {
  public function __construct(
    protected PostDatabasePort $databasePort
  ) {
  }

  public function handle(GetPostQuery $query): Post {
    return $this->databasePort->findOneById($query->getId());
  }
}

Notre couche applicative est donc complète. Il nous faut donc implémenter l’infrastructure, puis créer l'Adapter pour notre Port de base de données.


La couche « Infrastructure »

Capture d'écran de l'architecture infrastructure

La couche « Infrastructure » orchestre la communication avec les services externes, comme la communication avec la base de données, l’envoi de courriels, etc. Elle est la plus externe des trois couches et implémente les Ports définis dans la couche « Application ».

Dans notre cas, nous avons besoin d’une implémentation pour notre Port PostDatabasePort, qui sera chargée de l’interaction réelle avec la base de données. Pour cela, nous allons utiliser Doctrine ORM, et créer une classe PostRepository qui sera notre adaptateur de base de données.


namespace App\Infrastructure\Doctrine\Repository;

use App\Application\Port\Database\PostDatabasePort;
use App\Domain\Model\Post;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class PostRepository extends ServiceEntityRepository implements PostDatabasePort
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Post::class);
    }

    public function save(Post $post, bool $flush = false): void
    {
        $this->getEntityManager()->persist($post);

        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }

    public function findOneById(int $id): Post
    {
        return $this->findOneBy(['id' => $id]);
    }
}

Il faudra spécifier à Symfony d’utiliser cette classe comme implémentation de notre PostDatabasePort

App\Application\Port\DatabaseGateway\PostDatabaseGateway:
'@App\Infrastructure\Doctrine\Repository\PostRepository'

En plus de la base de données, la couche « Infrastructure » comprend également les contrôleurs qui traitent les requêtes HTTP entrantes. Dans notre cas, nous avons deux contrôleurs, CreatePostController pour gérer la création d’un nouvel article, et GetPostController pour récupérer un article existant.


namespace App\Infrastructure\Symfony\Controller\API;

use App\Application\Command\CreatePostCommand;
use App\Application\Handler\CreatePostHandler;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;

class CreatePostController extends AbstractController
{
    public function __construct(protected CreatePostHandler $handler) 
    {
    }

    #[Route('/posts', name: 'create_post', methods: ['POST'])]
    public function __invoke(#[MapRequestPayload] CreatePostCommand $command): Response
    {
        $post = $this->handler->handle($command);

        return $this->json($post);
    }
}


namespace App\Infrastructure\Symfony\Controller\API;

use App\Application\Query\GetPostQuery;
use App\Application\Handler\GetPostHandler;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class GetPostController extends AbstractController
{
    public function __construct(protected GetPostHandler $handler) {
    }

    #[Route('/posts/{id}', name: 'get_post', methods: ['GET'])]
    public function __invoke(int $id): Response
    {
        $post = $this->handler->handle(new GetPostQuery($id));

        return $this->json($post);
    }
}

Notre architecture hexagonale est désormais pleinement complétée dans notre application Symfony. Les différentes couches sont bien séparées et indépendantes, ce qui rend notre code plus maintenable et adaptable. Cela reste un exemple simplifié d’application mais j’espère qu’il vous a permis de mieux cerner l’implémentation d’une telle architecture.

Vous avez aimé cet article ? Poursuivez votre lecture avec cet autre article : Symfony Online 2023 : Design your API for the future




Source : exemples présentés dans l’article

Crédit photo : gorodenkoff

Image mise en avant pour l'article
Thomas Says
Développeur Web
E-BOOK & WEBINAR
Comment choisir la bonne technologie pour votre projet Web ?
Télécharger l'e-book
Quelle technologie choisir pour votre projet digital ?
Drupal, Symfony, WordPress..., nos experts vous conseillent la meilleure solution technique pour votre projet
Contactez-nous !
Pourquoi s'abonner à
notre newsletter ?
Pour recevoir tous les mois, un condensé de contenus utiles et pertinents pour votre transformation digitale !