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.
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.
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 :
Dans cette approche, la couche HTTP ou Command n'est qu'une interface qui permet de communiquer avec notre système.
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.
Pour mieux comprendre cette séparation, nous allons prendre quelques exemples concrets
Pour commencer, nous allons voir comment est découpé le fonctionnement de la recherche.
CreatedCourseEvent
). 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),
]);
}
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.
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. Mailing
va s'abonner à l'évènement MessageCreatedEvent
et va programmer l'envoi de l'email via un système asynchrone.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
.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.
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