Blog
Symfony et React : définir les responsabilités et les dépendances dès le départ
Répartir les responsabilités entre Symfony et React, choisir des patrons utiles et protéger les frontières par les tests, sans multiplier les couches inutiles.
Partir du changement qui devrait rester local
Prenons une application fictive de suivi des réparations dans une maison de quartier. Un membre du personnel signale un équipement endommagé, un technicien décrit son intervention, puis un coordinateur clôture la demande. Certaines catégories imposent une vérification par une autre personne. Après la clôture, un service externe peut actualiser l’annonce affichée pour la salle.
La première version n’a besoin ni de microservices ni d’un catalogue de patrons de conception. Elle doit en revanche préciser qui autorise la clôture, qui l’enregistre et qui connaît l’API du prestataire d’affichage. Si un changement de prestataire oblige ensuite à modifier le contrôleur, la règle de clôture et React, la dépendance s’est déjà trop étendue.
Cette opération servira de fil conducteur. Son contrat est fictif et ne décrit aucun déploiement GiSoft. Les exemples supposent PHP 8.2 ou ultérieur, dans un contexte Symfony 7.4 et React 19. Les types propres au projet qui ne sont pas présentés sont signalés près du code.
Donner une place précise à chaque responsabilité
React gère l’interaction : afficher la demande, solliciter sa clôture et expliquer le résultat. Symfony authentifie l’appelant, contrôle ses droits et impose la transition d’état. L’opération applicative coordonne ces décisions avec la persistance. Un adaptateur connaît la représentation d’une annonce chez le prestataire.
Cette carte distingue les appels des relations d’implémentation. Elle ne prétend pas représenter une séquence obligatoire de listeners du framework :
React repairs -- JSON --> RepairController --> CloseRepair
CloseRepair utilise ClosurePolicy et RepairTickets
DoctrineRepairTickets implémente RepairTickets
Le handler de notification utilise RoomBulletin
RemoteRoomBulletin implémente RoomBulletinL’application définit les capacités dont elle a besoin. L’infrastructure les implémente ; le conteneur Symfony choisit les implémentations. À l’exécution, les appels atteignent toujours Doctrine et le prestataire, sans que leurs API deviennent nécessairement des dépendances de la règle métier. Une indépendance totale vis-à-vis du framework a aussi un coût. Il faut la rechercher là où elle facilite réellement les changements ou les tests.
Passer de l’action HTTP à une opération applicative
Un premier contrôleur qui charge une demande et modifie une description reste facile à comprendre. La difficulté apparaît lorsqu’il décide également si une vérification est requise, clôture le dossier, appelle le SDK d’affichage et sérialise l’entité. Un futur import CLI devra alors dupliquer la règle ou utiliser du code organisé autour d’une requête HTTP.
Extraire CloseRepair suffit à corriger cette situation. Le routage, le décodage et la validation des entrées ainsi que la traduction du résultat en réponse HTTP restent dans le contrôleur. L’acteur vient du contexte authentifié, jamais d’un identifiant utilisateur non vérifié dans le JSON. L’opération reçoit l’identifiant de la demande et sa révision validée. Un contrôleur peut conserver plusieurs lignes utiles.
Cet extrait définit des contrats du projet, pas des API Symfony ou Doctrine. RepairTicket, Actor, ClosurePermission et les exceptions sont des types du projet laissés de côté. ClosurePermission vérifie le droit de clôture et l’accès au lieu concerné ; requireAwaitingClosure() rejette un état courant incompatible.
interface RepairTickets
{
public function get(string $id): RepairTicket;
public function closeIfCurrent(
string $id, int $revision, string $actorId,
): bool;
}
final readonly class CloseRepair
{
public function __construct(
private RepairTickets $tickets,
private ClosurePolicy $policy,
private ClosurePermission $permission,
) {}
public function __invoke(Actor $actor, string $id, int $revision): void
{
$ticket = $this->tickets->get($id);
$this->permission->requireClosure($actor, $ticket);
$ticket->requireAwaitingClosure();
if (($reason = $this->policy->rejection($ticket->closureFacts())) !== null) {
throw new ClosureRejected($reason);
}
if (!$this->tickets->closeIfCurrent($id, $revision, $actor->id)) {
throw new StaleRepair();
}
}
}get() doit signaler explicitement une demande introuvable. Le contrat de closeIfCurrent() est exigeant : comparer atomiquement la révision attendue et l’état autorisant la clôture, puis enregistrer celle-ci, la nouvelle révision et l’acteur dans l’historique. Toute modification des faits utilisés par la décision doit participer à ce mécanisme de versionnement. Sinon, la vérification peut devenir obsolète avant l’écriture. L’adaptateur peut employer un verrouillage optimiste ou une écriture conditionnelle équivalente ; l’interface seule ne garantit rien de tout cela.
L’implémentation doit aussi fixer les limites de transaction et traduire les refus attendus en réponses HTTP sûres. Le code de persistance est volontairement omis. Le service de droits et la policy restent des classes concrètes : une dépendance injectée ne mérite pas automatiquement sa propre interface.
Une règle de clôture commune à HTTP et à la CLI
Dans cette application, le compte rendu est obligatoire. Une catégorie soumise à vérification exige aussi un vérificateur différent de la personne qui a effectué la réparation. Cette policy décide sans HTTP, Doctrine ni horloge :
final readonly class ClosureFacts
{
public function __construct(
public string $workNote,
public bool $needsReview,
public string $performedBy,
public ?string $reviewedBy,
) {}
}
final class ClosurePolicy
{
public function rejection(ClosureFacts $facts): ?string
{
if (trim($facts->workNote) === '') {
return 'work_note_missing';
}
if ($facts->needsReview && (
trim($facts->reviewedBy ?? '') === ''
|| $facts->reviewedBy === $facts->performedBy
)) {
return 'independent_review_required';
}
return null;
}
}Les faits proviennent des données enregistrées de confiance et des règles de catégorie. Le navigateur ne peut pas déclarer lui-même une vérification terminée. Les identifiants non vides du personnel et la validité des vérifications sont des invariants du modèle. Modifier le travail vérifié invalide sa vérification et incrémente la révision. Le droit de clôturer reste un contrôle distinct.
Une policy séparée se justifie ici parce que la CLI utilise la même décision et que plusieurs cas doivent être couverts. Une méthode d’entité ou une petite méthode de service applicatif serait également défendable. Créer une Specification pour chaque if multiplierait les fichiers sans éclaircir la règle.
Avec Pest 3, cette décision en PHP pur se teste sans démarrer Symfony. Les classes ci-dessus doivent être accessibles par l’autoloader du projet de tests ; aucun builder de fixtures ni helper caché n’est supposé.
it('exige un compte rendu et la vérification indépendante requise', function (
string $note, bool $review, ?string $reviewer, ?string $expected,
): void {
$facts = new ClosureFacts($note, $review, 'worker-7', $reviewer);
expect((new ClosurePolicy())->rejection($facts))->toBe($expected);
})->with([
['', false, null, 'work_note_missing'],
['hinge adjusted', true, null, 'independent_review_required'],
['hinge adjusted', true, 'worker-7', 'independent_review_required'],
['hinge adjusted', true, 'worker-9', null],
['hinge adjusted', false, null, null],
]);Les cas autorisés comptent autant que les refus. Ce test ne démontre ni le verrouillage en base ni l’intégration des droits avec HTTP.
Séparer persistance et données publiques quand cela sert le contrat
Un Repository peut exprimer les opérations de chargement et de clôture. L’interface est utile ici parce que l’application attend une capacité de persistance définie, avec un contrat de concurrence. Elle n’est pas obligatoire pour chaque entité ou chaque recherche. Injecter EntityManager partout, ou renvoyer un QueryBuilder que les appelants doivent compléter, disperse les décisions de persistance.
Pour la liste des réparations d’une salle, un Query object peut encapsuler une requête complexe réutilisée ; un read repository peut regrouper les lectures associées. Une projection/read model sélectionne des champs limités : identifiant, nom de salle et statut, par exemple. Elle n’a pas à charger toutes les notes, pièces jointes et fiches du personnel. Une simple recherche ne réclame pas ces trois abstractions. Des limites de nombre de requêtes ou de taille de réponse sont utiles lorsqu’elles protègent un risque de régression identifié.
Les modèles n’ont pas tous le même rôle. L’entité de persistance décrit l’état stocké et peut aussi porter du comportement métier. Le DTO de requête représente une entrée non fiable. Un DTO de réponse ou un tableau explicite définit la sortie publique. Le read model sert une requête ; le view model React sert un écran. Un value object convient lorsqu’un concept, comme le code d’une salle, possède des invariants significatifs. Une fonctionnalité n’a pas besoin de sept copies des mêmes champs.
Les entités Doctrine peuvent circuler à l’intérieur de l’application si ce compromis est assumé. Leur graphe sérialisé complet ne doit toutefois pas devenir l’API au seul motif que le serializer sait le parcourir. Une courte fonction de mapping suffit souvent ; une classe dédiée se justifie lorsque la transformation le mérite.
Traduire le vocabulaire du prestataire à la frontière
Les patrons de frontière et d’intégration répondent à des problèmes de tailles différentes. Un port, généralement une interface appartenant au projet, nomme une capacité attendue. Un Adapter la traduit en appels au prestataire et convertit leur résultat. Ici, ClosureNotice est un DTO du projet, omis, limité aux données nécessaires à l’annonce :
interface RoomBulletin
{
public function recordClosure(
ClosureNotice $notice,
string $operationId,
): void;
}Une Facade aide si publier une annonce exige plusieurs opérations techniques. Elle apporte peu si elle renomme simplement une méthode. Une anti-corruption layer (ACL) se justifie lorsque les modèles divergent : chez le prestataire, « closed » peut désigner une annonce retirée, alors que nous parlons d’une réparation terminée. Convertir quelques champs ne réclame généralement pas une couche entière.
Les DTO/View Models limitent les données qui traversent une frontière de transport ou de présentation. Copier une réponse du prestataire dans un DTO aux champs identiques n’isole pas automatiquement leur sens. Pour une petite dépendance déjà bien localisée, une fonction d’adaptation peut suffire. Le paramètre operationId exprime une exigence de déduplication ; il ne rend pas n’importe quel prestataire idempotent.
React s’appuie sur le contrat de sa fonctionnalité
L’API de l’exemple reçoit POST /api/repairs/{id}/close avec une révision. Son contrat documenté prévoit 204 en cas de succès, 409 pour un conflit de révision ou d’état et 422 pour le non-respect d’une règle de clôture. Ce sont des choix de projet. L’absence d’authentification et le refus d’accès ont leurs réponses convenues, ici généralement 401 et 403 pour cette API JSON, ainsi que des tests distincts pour les appelants anonymes, refusés et autorisés.
La fonction closeRepair, propre à la fonctionnalité, gère HTTP : l’identifiant de session ou le jeton bearer prévu, la protection CSRF adaptée, la validation de la réponse et la traduction des codes d’erreur publics sûrs. Elle renvoie le petit résultat ci-dessous. Elle n’expose pas les noms d’exceptions PHP et n’affiche pas aveuglément un texte arbitraire du serveur. Les déclarations TypeScript ne valident pas le JSON externe à l’exécution.
Le composant gère l’attente, le refus et la réussite. L’extrait constitue son fichier ; l’adaptateur réseau est omis. Le type de fonction est notre contrat, pas un helper du framework.
import { useState } from 'react';
export type CloseResult =
| { kind: 'closed' }
| { kind: 'blocked'; message: string };
export type CloseRepair = (id: string, revision: number) => Promise<CloseResult>;
type Props = { id: string; revision: number; closeRepair: CloseRepair };
export function CloseRepairButton({ id, revision, closeRepair }: Props) {
const [phase, setPhase] = useState<'idle' | 'pending' | 'closed'>('idle');
const [notice, setNotice] = useState('');
async function submit() {
if (phase !== 'idle') return;
setPhase('pending');
setNotice('');
try {
const result = await closeRepair(id, revision);
setPhase(result.kind === 'closed' ? 'closed' : 'idle');
if (result.kind === 'blocked') setNotice(result.message);
} catch {
setPhase('idle');
setNotice('Clôture non confirmée. Vérifiez la demande avant de réessayer.');
}
}
return (
<>
<button type="button" disabled={phase !== 'idle'} onClick={submit}>
{phase === 'closed' ? 'Demande clôturée' : 'Clôturer la demande'}
</button>
{notice && <p role="alert">{notice}</p>}
</>
);
}Dans Next.js, ce composant s’importe dans un sous-arbre client. Un parent marqué 'use client' peut lui fournir le callback de l’adaptateur navigateur de la fonctionnalité. Un callback ordinaire ne se transmet pas depuis un Server Component à travers cette frontière. Les requêtes côté serveur nécessitent également une conception explicite du transfert des identifiants ; elles ne récupèrent pas automatiquement ceux du navigateur.
Désactiver le bouton améliore l’interaction, sans imposer les droits, la gestion de concurrence ou l’idempotence du backend. React peut signaler immédiatement un compte rendu vide ; Symfony doit encore le refuser. Après un résultat réseau incertain, il faut vérifier ou recharger la demande avant de réessayer. Relancer automatiquement une mutation exige un contrat de sûreté spécifique.
Des modules modestes aux dépendances visibles
Voici une organisation possible. Elle décrit la fonctionnalité fictive, sans proposer de réorganiser ce dépôt :
src/Repairs/
Application/ CloseRepair, RepairTickets
Domain/ ClosurePolicy, RepairTicket
Infrastructure/ DoctrineRepairTickets, RemoteRoomBulletin
UI/ RepairController
features/repairs/
api/ closeRepair
model/ CloseResult
ui/ CloseRepairButton
shared/ui/ ButtonReact n’a pas à reproduire les couches du backend. Le mapping d’un endpoint reste avec sa fonctionnalité. Une primitive HTTP commune devient utile lorsque plusieurs fonctionnalités partagent réellement le même comportement de transport. Un ApiService global chargé de tous les endpoints finit lui aussi par devenir trop volumineux.
Le module des salles peut exposer un petit contrat applicatif pour vérifier l’accès à un lieu. Le module des réparations ne devrait ni importer son repository privé ni modifier son entité sans passer par ce contrat. Un appel direct entre services applicatifs est tout à fait acceptable ; un événement n’est pas obligatoire. Une dépendance circulaire révèle souvent une responsabilité mal répartie, plutôt qu’une interface manquante.
Les paramètres du constructeur rendent les dépendances visibles. Aller chercher les services dans le conteneur les masque. Shared, Common, Utils et Helpers ont aussi besoin d’un périmètre : n’y placer délibérément que des concepts stables et réellement communs. Deux fonctions similaires peuvent coûter moins cher qu’une abstraction prématurée liant deux fonctionnalités dont les besoins divergent encore.
Choisir sobrement les patrons de décision et de création
Policy, Strategy, Specification et transitions d’état répondent à des questions distinctes. Notre Policy décide si la clôture est permise. Une Strategy pourrait choisir entre des algorithmes réellement interchangeables d’affectation des techniciens. Avec un seul algorithme, un service ordinaire suffit. Une Specification nomme une condition réutilisable et composable ; une vérification locale a rarement besoin de ce mécanisme.
Les transitions simples peuvent rester explicites dans l’entité ou l’opération. Une machine à états devient utile lorsque de nombreux états, transitions et conditions nécessitent un modèle commun. Trois transitions évidentes ne suffisent pas à la justifier. Ces décisions appartiennent au backend, même si React en donne un aperçu pratique.
Named constructor, Factory et Builder concernent la création. RepairTicket::reported(...) peut rendre l’état initial et ses invariants explicites. Une Factory aide si la construction nécessite des données de référence ou un choix significatif d’implémentation. Un DTO ordinaire ne réclame ni factory ni service. Un Builder est particulièrement utile dans les tests comportant des vérifications et notes facultatives, à condition de montrer l’état décisif plutôt que de le dissimuler derrière des valeurs par défaut généreuses.
Chaque abstraction ajoute un concept, du câblage et des fichiers à parcourir. Ce coût se justifie lorsqu’il clarifie une décision ou une règle de création.
Orchestrer sans construire un framework de commandes
Un service applicatif/use case contient la séquence déjà présentée : charger, contrôler l’accès, évaluer la règle, enregistrer. Il n’a pas besoin de connaître Request, React ou un type de réponse du prestataire. Une simple opération CRUD peut rester bien plus petite.
Une Command peut représenter l’intention de clôturer une demande ; une Query, la lecture des réparations ouvertes d’une salle. Séparer lectures et écritures aide parfois à comprendre le code, sans imposer un objet et un bus à chaque appel. La séparation commande/requête n’exige ni CQRS complet, ni modèles de lecture stockés séparément, ni mises à jour asynchrones.
CQRS mérite examen lorsque les besoins de lecture et d’écriture divergent suffisamment pour justifier des modèles distincts et, le cas échéant, leur synchronisation. Un message handler traite une tâche volontairement confiée à la messagerie. Ce n’est pas une enveloppe obligatoire autour de chaque service applicatif.
Les mécanismes transverses doivent laisser les décisions visibles
Un Decorator peut mesurer l’adaptateur d’affichage ou mettre une lecture en cache sous le même contrat. Un Middleware peut appliquer un comportement commun sur un chemin de transport ou de messagerie. Ils conviennent aux besoins répétés de journalisation, métriques et traçage ; autour d’un appel simple et unique, ils peuvent surtout compliquer la lecture.
Un event subscriber/listener convient à une réaction technique dont l’ordre est compris. Cacher le contrôle des droits ou la décision de clôture dans un listener Doctrine rend l’opération difficile à suivre. Les décisions synchrones essentielles doivent rester visibles. Un Pipeline organise de véritables étapes ordonnées ; une courte méthode n’a pas besoin d’un registre d’étapes.
Le cache et les retries changent eux aussi le comportement. Les clés et l’invalidation doivent respecter les droits et les exigences de fraîcheur. Un mécanisme de relance doit savoir quelles erreurs et opérations peuvent être rejouées. Les qualifier de « transverses » ne supprime pas cette responsabilité.
Définir la clôture avant d’ajouter une file
La demande est clôturée quand le changement d’état autorisé est validé en base. L’actualisation de l’affichage est secondaire dans cet exemple. Symfony Messenger peut l’exécuter ensuite ; déplacer le contrôle des droits dans un handler différé changerait en revanche le contrat. Une file déplace le travail et introduit des questions de livraison, sans améliorer automatiquement la conception.
Si perdre une annonce est inacceptable, une Outbox peut enregistrer l’intention d’envoi dans la même transaction que la clôture. Un worker la transmet ensuite. Des livraisons multiples restent possibles. Une clé d’idempotence identifie la même notification logique à travers les tentatives ; éviter les doublons demande une coordination locale atomique ou un support adapté du prestataire, pas seulement un champ operationId.
Une Retry policy limite les tentatives sur les erreurs transitoires, sans rejouer des refus de droits permanents. Un Circuit breaker peut suspendre temporairement les appels vers un prestataire durablement défaillant. Un fallback doit correspondre à un résultat accepté, comme « annonce en attente », et non simuler une livraison réussie. Ces mécanismes ont un coût de stockage et d’exploitation. Les exigences de livraison doivent guider leur adoption, pas un modèle de projet prérempli.
Un patron de conception se choisit avec son coût
Cette grille aide à examiner une abstraction proposée. La solution plus simple reste valable lorsqu’elle protège la même responsabilité.
Problème Candidat Option plus simple
Modèle prestataire Port + Adapter / ACL Mapping local
Décision commune Policy / Specification Méthode locale
Algorithmes variés Strategy Un service
Liste complexe Query + projection Recherche simple
Mesures répétées Decorator / Middleware Mesure directe
Règles de création Factory ConstructeurCe tableau n’est pas une liste d’achats. Pour une première clôture, une policy concrète, un service applicatif, un contrat de persistance et une fonction API propre à la fonctionnalité peuvent suffire. L’adaptateur vient avec l’intégration. Inutile de préparer des interfaces pour chaque classe, des factories pour chaque DTO, des événements métier pour chaque modification ou Messenger/CQRS pour un formulaire synchrone.
Tester la frontière où le défaut peut réellement apparaître
Les vérifications statiques donnent un retour rapide : PHPStan et TypeScript détectent des problèmes de types et des hypothèses erronées sur null. Des règles d’import ou tests d’architecture peuvent faire respecter les dépendances admises, par exemple exclure les SDK du code applicatif ou les imports réservés au serveur des modules clients. Ils nécessitent des règles explicites et un outillage adapté ; ils ne devinent pas l’architecture et ne prouvent pas le comportement métier.
Le test de policy couvre les décisions à faible coût. Un test d’intégration doit exercer DoctrineRepairTickets sur une base de test isolée : filtrage réel, conflit de révision et rollback requis. Mocker QueryBuilder ne démontre pas ces propriétés. Les tests d’adaptateur emploient des réponses contrôlées du prestataire pour vérifier la traduction et les erreurs, sans appeler le service réel dans la CI courante.
Un test fonctionnel d’API Symfony couvre routage, décodage, droits, validation, résultat public et certains effets de bord. Il faut vérifier la réussite d’un coordinateur autorisé autant que les refus. L’article GiSoft « Comment tester les API Symfony avec Pest » développe les cas de test HTTP en détail.
Pour React, tester le comportement observable. Cet exemple Vitest/React Testing Library suppose un environnement DOM et @testing-library/jest-dom/vitest dans la configuration des tests. Il utilise le composant précédent et une promesse laissée volontairement en attente, sans temporisation ni requête réseau :
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, it, vi } from 'vitest';
import { CloseRepairButton, type CloseResult } from './CloseRepairButton';
it('désactive la clôture tant que la requête est en cours', async () => {
const user = userEvent.setup();
let finish!: (result: CloseResult) => void;
const response = new Promise<CloseResult>((resolve) => { finish = resolve; });
const closeRepair = vi.fn().mockReturnValue(response);
render(<CloseRepairButton id="repair-42" revision={8} closeRepair={closeRepair} />);
const button = screen.getByRole('button', { name: 'Clôturer la demande' });
await user.click(button);
expect(button).toBeDisabled();
expect(closeRepair).toHaveBeenCalledWith('repair-42', 8);
await act(async () => { finish({ kind: 'closed' }); });
expect(screen.getByRole('button', { name: 'Demande clôturée' })).toBeDisabled();
});Ajouter un cas de refus qui préserve le travail de l’utilisateur et permet une correction. Un test de contrat ou d’adaptateur vérifie les statuts, la structure JSON, les valeurs nullables, les dates et les codes d’erreur sûrs face au véritable contrat backend. Une conversion de type TypeScript ne remplace pas ce test.
Un parcours navigateur peut traverser le signalement, la vérification et la clôture. Les cas limites de la policy restent dans les tests unitaires. Grands snapshots, fixtures partagées, heure réelle et helpers opaques alourdissent la maintenance sans nécessairement couvrir un risque supplémentaire. Utiliser des données déterministes et un état isolé ; lancer tôt les vérifications rapides, puis les tests d’intégration, d’API et les parcours navigateur pertinents. L’ordre exact de la CI dépend du projet.
Frontière / risque Outil de conception Preuve adaptée
Décision de clôture Policy Test unitaire
Révision et stockage Contrat de persistance Intégration DB
Modèle prestataire Port + Adapter Contrat adaptateur
Droits HTTP Frontière de sécurité Test fonctionnel API
Attente / erreur UI Composant du module Test de composant
Parcours complet Plusieurs frontières Test navigateur cibléCommencer par une fonctionnalité complète
Implémenter le signalement et la clôture à travers les vraies frontières HTTP et de persistance avant de créer une grande arborescence. Consigner où vit la règle, ce que promet l’API, le sens d’une modification concurrente et l’effet d’un échec de notification sur le résultat. Revoir ces choix lorsqu’un second cas d’usage fait apparaître un besoin réel de réutilisation.
Les détails d’optimisation Doctrine, de diagnostic des firewalls, de relivraison Messenger et les catalogues de tests d’API restent dans leurs articles dédiés. Ici, le critère de conception est concret : changer le prestataire d’affichage doit rester une modification de l’adaptateur ; changer la règle de clôture doit avoir un emplacement évident et un test ciblé. Ajouter de la structure lorsqu’elle rend l’un de ces changements plus sûr.
Références techniques
Les détails dépendant des versions ont été vérifiés dans la documentation ci-dessous ; le scénario de réparation et les exemples ont été élaborés pour cet article.
- Doctrine ORM 3.6, transactions et concurrence : https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/transactions-and-concurrency.html
- React, frontières des modules clients : https://react.dev/reference/rsc/use-client
- Symfony 7.4, livraison des messages et nouvelles tentatives : https://symfony.com/doc/7.4/messenger.html
