Ma structure sur Symfony

Posté le 10 février 2021 - Astuces pour développeurs - Par Grafikart - Proposer une correction

Vous avez été plutôt nombreux à me questionner sur la structure adoptée pour le développement du site sous Symfony. Aussi, je vous propose de partager avec vous l'objectif de cette structure et les raisons derrière ces choix.

Pourquoi ne pas utiliser la structure MVC de base ?

Par défaut, les frameworks groupent les classes en fonction de leur rôle (controllers, entities, repositories, events...). Ce découpage est logique pour un petit projet, mais s'avère très rapidement limitant lorsque l'on travaille sur un projet plus conséquent (on se retrouve avec des dossiers contenant de nombreux fichiers (qui ne concernent pas forcément les mêmes sections du site).

/Controller
/Model
/Subscriber
/Repository
  CategoryRepository.php
  PostRepository.php
  SpamRepository.php
  UserRepository.php
  ...

Une première solution est d'introduire un nouveau niveau de dossier.

/Controller
  /Account
  /Blog
  /Course
  /Forum
/Model
  /Account
  /Blog
  /Course
  /Forum
/Subscriber
  /Account
  /Blog
  /Course
  /Forum
/Repository
  /Account
    UserRepository.php
  /Blog
    PostRepository.php
    CategoryRepository.php
  /Course
    ...
  /Forum
    SpamRepository.php
  ...

Cela permet de regrouper un peu mieux la logique mais rend la navigation dans le code plus difficile car il faut naviguer en parallèle dans plusieurs niveaux de dossiers à chaque fois que l'on veut travailler sur un morceau de l'application.

Une autre solution est de directement séparer par le contexte plutôt que par le rôle de la classe :

/Account
/Blog
  /Model
  /Subscriber
  /Repository
/Course
  /Model
  /Subscriber
  /Repository
/Forum
  /Model
  /Subscriber
  /Repository

Cette approche est plus en adéquation avec ma manière de travailler, car en général je travaille sur une fonctionnalité spécifique plutôt que sur un type de classe.

Malgré tout, cette approche ne peut être appliquée à la totalité de l'application car certaines parties peuvent être transversales et il faut moduler cette structure en fonction de la situation.

La nouvelle structure

Cette structure s'inspire de différents principes tel que le système de contexte, le Domain Driven Development ou encore l'architecture hexagonale sans forcément les respecter à la lettre.

L'idée est de séparer notre application avec 4 dossiers principaux :

  • Domain, contient les classes qui permettent de gérer la logique métier de l'application. Le domaine doit fonctionner de manière isolée et peut exposer ces méthodes au travers de Services ou via les Repository.
  • Infrastructure, définit les éléments d'infrastructure qui permettent au domaine de communiquer avec le système (système de fichiers, envoi d'emails, base de données...).
  • Http, contient les classes qui permettent d'interagir avec le système depuis des couches HTTP.
  • Command, contient les commandes qui permettent d'interagir avec le système depuis la CLI.

Dans cette approche, la couche HTTP ou Command n'est qu'une interface qui permet de communiquer avec notre système.

Comment les choses communiquent ensemble ?

Cette approche fonctionne bien en théorie, mais dans la réalité les choses sont plus complexes et les différents systèmes ont besoin de communiquer les uns avec les autres. Pour cette communication on se repose sur le système d'évènement et de subscriber du framework.

Lorsque le domaine effectue une opération, un évènement est émis pour pouvoir permettre aux autres systèmes de greffer leur logique.

Quelques exemples

Pour mieux comprendre cette séparation, nous allons prendre quelques exemples concrets

La recherche

Pour commencer, nous allons voir comment est découpé le fonctionnement de la recherche.

  • La couche HTTP permet de créer un tutoriel via un formulaire. Les données de ce formulaire peuvent être envoyées au domaine via un objet (soit directement avec l'entité, soit avec un objet spécifique style DTO).
  • Le service, dans le domaine, va s'occuper d'enregistrer la donnée et va émettre un évènement spécifique (type CreatedCourseEvent).
  • Dans l'infrastructure, plus spécifiquement Search, un Subscriber va écouter l'évènement et déclencher l'indexation du cours dans son système.

Le système de recherche expose un service qui permet de récupérer les résultats via la base de données. L'interface doit être la plus simple possible pour être implémentée facilement (on évitera tant que possible les interfaces avec de trop nombreuses méthodes car elles sont plus difficiles à remplacer).

interface SearchInterface
{
    /**
     * @param string[] $types
     */
    public function search(string $q, array $types = [], int $limit = 50, int $page = 1): SearchResult;
}

Ce service est ensuite utilisé par la couche HTTP

    /**
     * @Route("/recherche", name="search")
     */
    public function search(
        Request $request,
        SearchInterface $search,
        PaginatorInterface $paginator
    ): Response {
        $q = $request->query->get('q') ?: '';
        $page = (int) $request->get('page', 1);
        $results = $search->search($q, [], 10, $page);
        $paginatedResults = new Pagination($results->getTotal(), $results->getItems());

        return $this->render('pages/search.html.twig', [
            'q' => $q,
            'total' => $results->getTotal(),
            'results' => $paginator->paginate($paginableResults, $page),
        ]);
    }

Création d'un message sur le forum

Ce système est plus complexe que le précédent car il fait intervenir plus d'éléments mais le découpage reste le même. On part encore une fois de la couche HTTP qui va communiquer avec un service dans le domaine Forum.

public function createMessage(Topic $topic, User $user, string $content) {
    $message = (new Message())
        ->setCreatedAt(new \DateTime())
        ->setUpdatedAt(new \DateTime())
        ->setTopic($topic)
        ->setContent($content)
        ->setAuthor($user);
    $topic->setUpdatedAt(new \DateTime());
    $dispatcher->dispatch(new PreMessageCreatedEvent($message));
    $em->persist($message);
    $em->flush();
    $dispatcher->dispatch(new MessageCreatedEvent($message));
}

On émet ici 2 évènements pour avoir plus de contrôle sur le déclenchement des comportements. Plusieurs systèmes s'abonnent d'ailleurs à ces 2 évènements pour venir rajouter les différents comportements.

  • L'infrastructure Spam va s'abonner à l'évènement PreMessageCreatedEvent et vérifier si le message contient des mots inadaptés pour le marquer comme spam avant sa persistance.
  • L'infrastructure Mailing va s'abonner à l'évènement MessageCreatedEvent et va programmer l'envoi de l'email via un système asynchrone.
  • L'infrastructure Notification va s'abonner à l'évènement MessageCreatedEvent et créer une notification pour tous les participants du sujet et émettre aussi un évènement NotificationCreatedEvent.
  • L'infrastructure Mercure répond à l'évènement NotificationCreatedEvent en créant une notification instantanée qui sera poussée au client via un Server-sent events.

Si le message est un spam, le système de notification va rester silencieux et l'évènement MessageCreatedEvent sera re-émis lorsque le message sera validé par l'administrateur.

Les problèmes de cette organisation

Cette approche a fonctionné pour moi et a résolu les soucis que j'avais avec la structure de base mais elle n'est pas pour autant exempte de tous défauts.

Le premier problème est la découverte des services par le framework. En effet, les frameworks ont souvent tendance à utiliser la structure des dossiers pour charger certains types de classes ou pour identifier leur rôle (par exemple, dans certains frameworks, tous les Subscriber doivent être dans un dossier spécifique pour être détecté comme des écouteurs d'évènements).

Le second problème est posé par le système d'évènement qui peut rendre la logique plus complexe à dérouler. Si on reprend l'exemple de la création de message il est difficile d'identifier ce qui se passe réellement lorsqu'un message est créé et il faut parcourir l'ensemble des écouteurs pour dérouler la logique de notre système et dans quel ordre.

Enfin, si le projet monte en complexité une amélioration possible serait de morceler les domaines en fonction des scénarios d'utilisation.

/Domain
  /Account
    /DeleteAccount
    /UpdateAccountInformation
    /UpdateNotificationSetting