Code maintenable avec Symfony : pourquoi et comment découpler sa logique métier
Pourquoi découpler la logique métier du framework ?
Dans le code des applications, il est fréquent de voir des contrôleurs qui accumulent de nombreuses responsabilités. Cette approche, bien que rapide à implémenter, pose plusieurs problèmes de maintenabilité et de testabilité. Explorons pourquoi et comment mieux organiser notre code.
Un exemple concret : le "Fat Controller"
Prenons l'exemple d'un contrôleur gérant l'inscription d'un utilisateur dans une application.
<?php
namespace App\Controller;
use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class UserController extends AbstractController
{
#[Route('/register', name: 'user_register', methods: ['POST'])]
public function register(
Request $request,
EntityManagerInterface $entityManager,
UserRepository $userRepository,
UserPasswordHasherInterface $passwordHasher,
ValidatorInterface $validator,
MailerInterface $mailer
): Response {
$data = json_decode($request->getContent(), true);
// Validation manuelle
if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
return $this->json(['error' => 'Email invalide'], 400);
}
if (empty($data['password']) || strlen($data['password']) < 8) {
return $this->json(['error' => 'Le mot de passe doit contenir au moins 8 caractères'], 400);
}
if (empty($data['firstName']) || empty($data['lastName'])) {
return $this->json(['error' => 'Nom et prénom requis'], 400);
}
// Vérification de l'unicité de l'email
$existingUser = $entityManager->getRepository(User::class)
->findOneBy(['email' => $data['email']]);
if ($existingUser) {
return $this->json(['error' => 'Cet email est déjà utilisé'], 400);
}
// Règles métier : génération du nom d'utilisateur, avec un suffixe unique
$baseUsername = strtolower($data['firstName']) . '.' . strtolower($data['lastName']);
$suffix = $userRepository->getNextAvailableSuffix($baseUsername);
$username = $suffix ? $baseUsername . $suffix : $baseUsername;
// Création de l'utilisateur
$user = new User();
$user->setEmail($data['email']);
$user->setFirstName($data['firstName']);
$user->setLastName($data['lastName']);
$user->setUsername($username);
$user->setPassword($passwordHasher->hashPassword($user, $data['password']));
$user->setCreatedAt(new \DateTime());
$user->setIsActive(true);
// Règle métier : attribution du rôle par défaut
$user->setRoles(['ROLE_USER']);
// Validation avec les contraintes de l'entité
$errors = $validator->validate($user);
if (count($errors) > 0) {
return $this->json(['error' => 'Données invalides'], 400);
}
// Sauvegarde en base
$entityManager->persist($user);
$entityManager->flush();
// Envoi de l'email de bienvenue
$email = (new Email())
->from('noreply@monapp.com')
->to($user->getEmail())
->subject('Bienvenue sur MonApp !')
->html(sprintf(
'<h1>Bienvenue %s !</h1><p>Votre compte a été créé avec succès. Votre nom d\'utilisateur est : %s</p>',
$user->getFirstName(),
$user->getUsername()
));
$mailer->send($email);
return $this->json([
'message' => 'Utilisateur créé avec succès',
'user' => [
'id' => $user->getId(),
'email' => $user->getEmail(),
'username' => $user->getUsername()
]
], 201);
}
}
Les problèmes de cette approche
Cette approche est régulièrement présente dans des projets legacy, puisqu'elle est couramment utilisée lorsqu'il n'y a pas de réflexion sur la lisibilité, la maintenance ou l'évolutivité de l'application.
Elle présente par contre plusieurs problèmes majeurs :
1. Mélange des responsabilités
Le contrôleur fait tout à la fois :
- de la validation de données
- de la logique métier (génération du nom d'utilisateur, attribution des rôles)
- de l'accès aux données (vérification d'unicité, sauvegarde en base)
- de la notification utilisateur (envoi d'email)
- la gestion de la réponse (http en json)
Cette accumulation de responsabilités rend le code difficile à lire, et également à maintenir puisqu'il est plus compliqué de situer les règles métier.
Ce problème ne va aller qu'en s'amplifiant au fil de la vie de l'application : le contrôleur ne va faire que grossir, récupérant toutes les modifications qui devront être apportées à cette action.
2. Testabilité compromise
Si l'on voulait créer un test unitaire pour cette méthode, on serait obligés de mocker de nombreuses dépendances (EntityManager, Mailer, etc.) Pour des tests d'intégration, on devrait forcément simuler une requête HTTP, ce qui est également lourd à mettre en place. Dans les faits, il est très pénible et donc rare de mettre en place ces deux types de tests sur une méthode comme celle-ci.
Quant aux tests fonctionnels, le fait d'avoir beaucoup de logique au sein d'une même méthode va augmenter drastiquement
la complexité cyclomatique, ce qui fait qu'il sera impossible de
tester tous les chemins.
Il est donc impossible de tester unitairement les règles métier sans embarquer tout l'écosystème Symfony et simuler des requêtes HTTP, ce qui est lourd à mettre en place lors de l'écriture des tests, et plus long à chaque exécution.
3. Règles métier diluées
Ici, les règles métier sont éparpillées à différents endroits dans le code :
- La génération du nom d'utilisateur (lignes 40-47)
- L'attribution du rôle par défaut (ligne 58)
- Les règles de validation (lignes 25-35)
À l'échelle d'une application, cela rend compliqué la visualisation, la réutilisation et la modification de ces différentes règles.
4. Difficultés de maintenance
Toute modification des règles métier va nécessiter de modifier le contrôleur, ce qui augmente les risques de régression. De plus, la mise à jour des composants peut également impacter la logique métier.
5. Violation du principe de responsabilité unique
Ce contrôleur viole clairement le principe SRP de SOLID (Single Responsibility Principle) : il a trop de raisons de changer (mise à jour d'un des nombreux composants utilisés ici, changement des règles métiers, ...) et trop de responsabilités.
La version refactorisée : séparation des responsabilités
Voyons maintenant comment refactoriser ce code, en séparant clairement les responsabilités. Nous allons pour ce faire créer de nouvelles classes :
- RegisterUserDTO : un objet qui va servir à la validation et au transport des données
- RegisterUserUseCase : la logique métier sera stockée dans ce service
- UserRepositoryInterface : contrat d'accès aux données, utilisé par le use-case
- UserRepository : l'implémentation du repository
Le DTO avec validation
Commençons par créer un DTO (Data Transfer Object, il s'agit d'un objet PHP simple, et non d'un service) qui encapsule les données d'entrée et leur validation :
<?php
namespace App\DTO;
use App\Entity\User;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[UniqueEntity(
fields: ['email'],
entityClass: User::class,
message: 'Cet email est déjà utilisé'
)]
class RegisterUserDTO
{
#[Assert\NotBlank(message: 'L\'email est requis')]
#[Assert\Email(message: 'L\'email n\'est pas valide')]
public string $email;
#[Assert\NotBlank(message: 'Le mot de passe est requis')]
#[Assert\Length(min: 8, minMessage: 'Le mot de passe doit contenir au moins 8 caractères')]
public string $password;
#[Assert\NotBlank(message: 'Le prénom est requis')]
public string $firstName;
#[Assert\NotBlank(message: 'Le nom est requis')]
public string $lastName;
public function __construct(array $data)
{
$this->email = $data['email'] ?? '';
$this->password = $data['password'] ?? '';
$this->firstName = $data['firstName'] ?? '';
$this->lastName = $data['lastName'] ?? '';
}
}
Le fait d'utiliser un DTO va permettre de :
- regrouper toute la validation au même endroit
- s'assurer de la présence et du typage des données
Le service métier (Use Case)
Créons maintenant le service qui contient la logique métier :
<?php
namespace App\UseCase;
use App\DTO\RegisterUserDTO;
use App\Entity\User;
use App\Repository\UserRepositoryInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class RegisterUserUseCase
{
public function __construct(
private UserRepositoryInterface $userRepository,
private UserPasswordHasherInterface $passwordHasher,
private MailerInterface $mailer
) {}
public function execute(RegisterUserDTO $dto): User
{
// Génération du nom d'utilisateur
$username = $this->generateUniqueUsername($dto->firstName, $dto->lastName);
// Création de l'utilisateur avec les règles métier
$user = new User();
$user->setEmail($dto->email);
$user->setFirstName($dto->firstName);
$user->setLastName($dto->lastName);
$user->setUsername($username);
$user->setPassword($this->passwordHasher->hashPassword($user, $dto->password));
$user->setCreatedAt(new \DateTime());
$user->setIsActive(true);
$user->setRoles(['ROLE_USER']); // Règle métier : rôle par défaut
// Sauvegarde
$this->userRepository->save($user);
// Envoi de l'email de bienvenue
$this->sendWelcomeEmail($user);
return $user;
}
private function generateUniqueUsername(string $firstName, string $lastName): string
{
$baseUsername = strtolower($firstName) . '.' . strtolower($lastName);
$suffix = $this->userRepository->getNextAvailableSuffix($baseUsername);
return $suffix ? $baseUsername . $suffix : $baseUsername;
}
private function sendWelcomeEmail(User $user): void
{
$email = (new Email())
->from('noreply@monapp.com')
->to($user->getEmail())
->subject('Bienvenue sur MonApp !')
->html(sprintf(
'<h1>Bienvenue %s !</h1><p>Votre compte a été créé avec succès. Votre nom d\'utilisateur est : %s</p>',
$user->getFirstName(),
$user->getUsername()
));
$this->mailer->send($email);
}
}
Par simplicité, un seul service regroupe ici la logique, mais on pourrait la répartir à travers plusieurs services dédiés.
L'interface du repository
Créons une interface pour abstraire l'accès aux données : l'idée ici est que le code ne dépende pas directement d'un service Doctrine, mais d'un contrat (interface) que l'on va déterminer. C'est le principe de ségrégation des interfaces de SOLID.
Ainsi, si Doctrine évolue et que les services disparaissent, sont renommés, ou changent leur fonctionnement, les interfaces resteront elles inchangées et donc les controllers et les use-cases n'auront eux pas à être modifiés du tout pour s'adapter à ces changements.
Pareillement, si on a besoin dans le futur de changer la base de données ou la couche d'accès, cela permet de le faire plus simplement.
<?php
namespace App\Repository;
use App\Entity\User;
interface UserRepositoryInterface
{
public function getNextAvailableSuffix(string $baseUsername): ?string;
public function save(User $user): void;
}
Le contrôleur refactorisé
Une fois ces éléments mis en place, notre contrôleur devient beaucoup plus simple :
<?php
namespace App\Controller;
use App\DTO\RegisterUserDTO;
use App\UseCase\RegisterUserUseCase;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class UserController extends AbstractController
{
#[Route('/register', name: 'user_register', methods: ['POST'])]
public function register(
#[MapRequestPayload] RegisterUserDTO $dto,
RegisterUserUseCase $registerUserUseCase
): Response {
try {
$user = $registerUserUseCase->execute($dto);
return $this->json([
'message' => 'Utilisateur créé avec succès',
'user' => [
'id' => $user->getId(),
'email' => $user->getEmail(),
'username' => $user->getUsername()
]
], 201);
} catch (\DomainException $e) {
return $this->json(['error' => $e->getMessage()], 400);
}
}
}
L'implémentation du repository
Pour compléter, voici l'implémentation concrète du repository :
<?php
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class UserRepository extends ServiceEntityRepository implements UserRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function getNextAvailableSuffix(string $baseUsername): ?string
{
// ...
}
public function save(User $user): void
{
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
}
Analyse : les bénéfices de cette approche
Cette refactorisation apporte de nombreux avantages :
1. Séparation claire des responsabilités
Chaque classe a maintenant une responsabilité bien définie :
2. Testabilité considérablement améliorée
La logique métier peut maintenant être plus facilement testée via des tests unitaires : les mocks sont simples à mettre en place, et prend en argument un DTO qui est rapide à instancier.
3. Découplage
La logique métier (ici RegisterUserUseCase) ne dépend plus directement de Symfony. Elle peut donc être réutilisée dans d'autres contextes (commandes CLI, HTTP en API et web, ...).
4. Règles métier centralisées et explicites
Les règles métier sont centralisées au même endroit, dans du code qui est peu dépendant du framework et des entrées/sorties (ici, requête/réponse).
5. Facilité de maintenance et d'évolution
Pendant la vie de l'application, chaque modification aura un impact limité sur un élément bien particulier:
- modifier les règles de validation : dans le DTO
- changer la logique métier : dans le use-case
- modifier l'accès aux données : dans le repository
6. Respect des principes SOLID
- SRP : chaque classe a une seule responsabilité
- OCP : ouvert à l'extension, fermé à la modification
- DIP : dépendance sur des abstractions (interfaces)
Cette approche nous permet d'avoir un code plus maintenable, testable et évolutif, tout en gardant une architecture simple et compréhensible.
Sources
Besoin d'accompagnement ?
Développeur senior PHP/Symfony et formateur professionnel, j'accompagne les entreprises dans la conception d'applications sur mesure et la formation de leurs équipes.