Repository patterns — ServiceEntityRepository, criteria, specification
TL;DR —
ServiceEntityRepositoryautowire un repo lié à une entité. Mais l'antipattern courant est de coller toute la logique métier dans le repo. Sur projets riches : séparer read model (DTO/QueryBuilder/SQL) du write model (entités), utiliser Criteria pour des filtres composables, et Specification pour exprimer des règles métier réutilisables.
Mental model — ASCII diagram + analogy
Analogie : Repository = bibliothécaire. Tu lui demandes "tous les livres de 1990 écrits par X" ; il sait où chercher. Mais s'il devient cuisinier, plombier et chauffeur (logique métier dedans), il fait mal son métier.
┌─────────────────────────────────────────────────────────────────────┐
│ Application Layer │
│ Controller / CommandHandler / QueryHandler │
└──────────────────────────┬──────────────────────────────────────────┘
│
┌──────────────┴──────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ Write side │ │ Read side │
│ Repository<E> │ │ ReadModel/View │
│ (entities) │ │ (DTOs / arrays) │
└────────┬────────┘ └────────┬─────────┘
│ │
▼ ▼
Doctrine ORM DBAL or QB
(UnitOfWork) (no hydration)Code minimal — realistic snippet
1. ServiceEntityRepository minimal
<?php
namespace App\Repository;
use App\Entity\Book;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<Book> */
class BookRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Book::class);
}
public function save(Book $book, bool $flush = true): void
{
$this->getEntityManager()->persist($book);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}2. Criteria pattern (composable filtering)
<?php
use Doctrine\Common\Collections\Criteria;
$criteria = Criteria::create()
->where(Criteria::expr()->gte('publishedAt', new \DateTimeImmutable('-1 year')))
->andWhere(Criteria::expr()->in('tags', ['php', 'symfony']))
->orderBy(['publishedAt' => 'DESC'])
->setMaxResults(20);
// sur une collection en mémoire OU sur le repo (traduit en DQL)
$recent = $repo->matching($criteria);3. Specification pattern
<?php
namespace App\Specification;
use Doctrine\ORM\QueryBuilder;
interface BookSpecification
{
public function apply(QueryBuilder $qb, string $alias): void;
}
final readonly class PublishedAfter implements BookSpecification
{
public function __construct(private \DateTimeImmutable $date) {}
public function apply(QueryBuilder $qb, string $alias): void
{
$qb->andWhere("$alias.publishedAt >= :pubAfter")
->setParameter('pubAfter', $this->date);
}
}
final readonly class HasTag implements BookSpecification
{
public function __construct(private string $tag) {}
public function apply(QueryBuilder $qb, string $alias): void
{
$qb->join("$alias.tags", 't')
->andWhere('t.name = :tag')
->setParameter('tag', $this->tag);
}
}<?php
// Dans le repository
public function match(BookSpecification ...$specs): array
{
$qb = $this->createQueryBuilder('b');
foreach ($specs as $spec) {
$spec->apply($qb, 'b');
}
return $qb->getQuery()->getResult();
}
// Usage
$books = $repo->match(
new PublishedAfter(new \DateTimeImmutable('-6 months')),
new HasTag('symfony'),
);4. Read model dédié (CQRS-light)
<?php
namespace App\Query;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
final readonly class BookListReadModel
{
public function __construct(private Connection $db) {}
/** @return list<array{id:int,title:string,authorEmail:string}> */
public function paginate(int $offset, int $limit): array
{
// DBAL 4 : utiliser ParameterType, plus \PDO::PARAM_* (déprécié/retiré).
// Sans typage explicite, le driver bind LIMIT/OFFSET en string → erreur SQL sur PG/MySQL strict.
return $this->db->fetchAllAssociative(
'SELECT b.id, b.title, a.email AS authorEmail
FROM books b
JOIN authors a ON a.id = b.author_id
ORDER BY b.id DESC
LIMIT :limit OFFSET :offset',
['limit' => $limit, 'offset' => $offset],
['limit' => ParameterType::INTEGER, 'offset' => ParameterType::INTEGER],
);
}
}Pourquoi un ReadModel DBAL plutôt qu'un
QueryBuilderORM ? Une liste paginée n'a aucune raison d'instancier des entités managées : pas de dirty-checking, pas de cascade, pas d'identity map à peupler. UnSELECTprojeté en array est 5 à 20× plus rapide et borne la mémoire au nombre de colonnes × lignes, pas au graphe d'objets. Le ReadModel retourne unlist<array{...}>typé par PHPStan — c'est ton contrat de vue, découplé du schéma d'entités.
Patterns courants — 3–6 patterns
- Repo = collection orientée écriture :
save(),remove(),ofId(),findByX(). Pas de "format pour la vue". - ReadModel distinct pour les listes / dashboards. Retourne des arrays / DTO. Aucun side-effect Doctrine.
- Specification pour règles réutilisables ("client actif", "livre publié dans l'année"). Composable, testable unitairement.
- Criteria pour le filtrage dynamique (table avec colonnes filtrables). Plus simple que Specification quand la logique est triviale.
->ofId(): Tplutôt quefind()qui retourne?T—ofIdlèveNotFoundExceptionmétier.- Pagination dédiée :
KnpPaginatorBundleouPagerfanta. Le repo expose unQueryBuilder, pas un array.
Décision — Criteria vs Specification vs ReadModel vs findBy
Quatre outils, quatre niveaux d'expressivité et de couplage. Choisir le moins puissant qui fait le job (règle YAGNI inversée : l'over-engineering du read est le piège #1 des seniors qui découvrent DDD).
| Critère | findBy() / méthode nommée | Criteria | Specification | ReadModel (DBAL) |
|---|---|---|---|---|
| Expressivité | Faible (égalité, IN, tri) | Moyenne (expr composables) | Élevée (règle métier nommée) | Maximale (SQL brut) |
| Réutilisable cross-requête | Non | Partiellement | Oui (objet 1ʳᵉ classe) | Non |
| Testable en isolation | Non (besoin DB) | Difficile | Oui (assert sur DQL/QB) | Via intégration |
| Composable AND/OR/NOT | Non | OR limité | Oui (AndSpec, OrSpec) | À la main en SQL |
| Hydrate des entités | Oui | Oui | Oui | Non (arrays) |
| Coût d'indirection | Nul | Faible | Moyen (N classes) | Faible |
| Cas idéal | CRUD, lookup par id | Filtre dynamique trivial | Domaine riche, règles partagées | Listes, dashboards, exports |
Heuristique de staff engineer : commence par findBy/méthode nommée. Tu passes à Criteria quand le nombre de combinaisons de filtres explose (table avec 6 colonnes filtrables → 64 méthodes nommées, intenable). Tu passes à Specification quand la même règle ("client actif", "dossier en retard SLA") doit vivre à la fois côté query et côté validation/policy — la Spec devient l'unique source de vérité de la règle. Et tu sors un ReadModel dès qu'une liste dépasse quelques centaines de lignes ou qu'un dashboard agrège : tu ne veux ni l'identity map ni l'hydratation.
Le piège conceptuel : Specification ≠ QueryBuilder wrapper
Une vraie Specification (au sens Evans) répond à deux questions : isSatisfiedBy(T $candidate): bool (en mémoire) ET apply(QueryBuilder): void (en base). La version "QueryBuilder-only" qu'on voit partout (et dans ce fichier) est une dégradation pragmatique : elle perd la capacité de valider un objet déjà chargé. Si ta règle doit servir à la fois à filtrer une requête ET à décider peutÊtreClôturé() sur une entité en mémoire, implémente les deux méthodes — sinon tu dupliques la règle et elles divergeront.
<?php
interface Specification
{
public function isSatisfiedBy(object $candidate): bool; // domaine, en mémoire
public function apply(QueryBuilder $qb, string $alias): void; // infra, en base
}C'est la tension fondamentale : isSatisfiedBy vit dans le domaine pur, apply connaît Doctrine. Beaucoup d'équipes scindent en deux interfaces (DomainSpecification + QuerySpecification) reliées par un traducteur, pour ne pas polluer le domaine avec un QueryBuilder. C'est le bon réflexe en hexagonal strict.
Versions — Symfony 5.4 / 6.4 / 7.x
| Topic | 5.4 / ORM 2.x | 6.4+ / ORM 3.x |
|---|---|---|
ServiceEntityRepository | OK | OK, autowired par défaut |
Criteria::expr()->in('field', $arr) | OK | Idem, plus de strict types |
EntityRepository::createNativeNamedQuery() | Existe | Retiré (rare en pratique) |
findBy(['x' => $val], orderBy: [...]) | OK | Criteria recommandé pour expressions |
MatchExpr (full-text) | Via DQL extension | Idem |
- ORM 3 :
EntityRepositoryestfinal? Non, toujours extensible. Mais bcp de méthodes internes deviennentprivate. - DBAL 4 : remplacer
fetchAll()parfetchAllAssociative()dans les read models.
Pitfalls — 5–8 concrete traps
- Repo poubelle : 2000 lignes, 30 méthodes pour 1 entité = signal de design métier flou. Refactor vers ReadModel + Specification.
findOneBy()magique : facile, mais ne supporte pas relations / ordres complexes. Plafonne vite.- Repo qui dispatch des events : couplage explicite entre persistance et domaine → préférer Domain Event sur l'entité, dispatché par un listener
postFlush. - Hydratation par défaut sur grosses listes : 10k rows → 10k objets → 1Gb mémoire. Toujours
getArrayResult()/ DBAL pour les listes. Criteriasur collection inverse :$author->getBooks()->matching($c)fonctionne en EXTRA_LAZY (SQL). En LAZY, tout est chargé puis filtré en PHP.->getOneOrNullResult()vs->getResult(): confusion → cast d'array vide ennullqui plante.- Repo dépendant d'un autre repo : à éviter, signe que la logique doit monter en service.
- Méthodes statiques sur l'entité (
Book::findAll()) : impossible avec Doctrine, et anti-DI. Toujours via le repo injecté. - N+1 masqué par une Specification : une Spec qui ajoute un
joinmais pas deaddSelect/fetch-join laisse les relations en lazy → la sérialisation finale déclenche N requêtes. Côté liste, préfère unReadModelou un->setFetchMode/fetch joinexplicite. Active le profiler Doctrine en dev et échoue le test si le nombre de requêtes dépasse un seuil. spl_object_idréutilisé après GC : si tu construis une Spec, l'appliques, la libères, puis en construis une autre dans la même requête longue (worker Messenger), l'id peut être recyclé. En pratique sans risque ici (les specs vivent le temps de la requête), mais en contexte long-running, préfère un compteur injecté oubin2hex(random_bytes(4)).- Repo non-
finalétendu par un autre repo : l'héritage de repos crée un couplage fragile et casse l'autowiring par type. Compose (un repo qui utilise un ReadModel) plutôt qu'hérite. - Pagination par
OFFSETsur grosses tables :LIMIT x OFFSET 500000scanne et jette 500k lignes à chaque page. Sur volumétrie réelle, bascule en keyset pagination (WHERE id < :lastSeenId ORDER BY id DESC LIMIT n) — index-only, temps constant.
Observabilité — ce qu'un staff engineer instrumente
- Compte de requêtes par endpoint :
Doctrine\DBAL\Logging\Middlewareou ledata_collectorexposé en prod via un compteur Prometheus. Un endpoint qui passe de 3 à 40 requêtes après un déploiement = régression N+1 à alerter. - Slow query log côté DB + tag de l'origine applicative (commentaire SQL injecté :
/* RechercheDossierController */) pour relier une requête lente à son call-site. - Métrique de taille de résultat : un
findBysanssetMaxResultsqui renvoie soudain 100k lignes est une bombe mémoire silencieuse. Borne toujours, et logue quand on atteint la borne.
Testing — phpunit / KernelTestCase
<?php
namespace App\Tests\Repository;
use App\Repository\BookRepository;
use App\Specification\HasTag;
use App\Specification\PublishedAfter;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class BookRepositoryTest extends KernelTestCase
{
public function testSpecificationsCompose(): void
{
self::bootKernel();
/** @var BookRepository $repo */
$repo = static::getContainer()->get(BookRepository::class);
$results = $repo->match(
new PublishedAfter(new \DateTimeImmutable('-1 year')),
new HasTag('php'),
);
self::assertContainsOnlyInstancesOf(\App\Entity\Book::class, $results);
}
}Spécifications testables isolément :
public function testHasTagAddsJoin(): void
{
$qb = $this->em->createQueryBuilder()->select('b')->from(Book::class, 'b');
(new HasTag('symfony'))->apply($qb, 'b');
self::assertStringContainsString('JOIN b.tags t', $qb->getDQL());
}🎬 Cas d'usage concrets
Scénario 1 — SearchSpec dans un moteur de recherche cabinet juridique
Le DMS du cabinet d'avocats expose une recherche multi-critères sur les dossiers : par client, par matière, par associé responsable, par période d'ouverture, par tags personnalisés, par état (ouvert/clôturé/archivé). Six équipes ajoutent régulièrement de nouveaux critères sans coordination. L'équipe DMS a refactoré la recherche en pattern Specification : chaque critère devient une classe MatierEgale, AssocieResponsable, OuvertEntre, AvecTag implémentant SpecificationInterface::apply(QueryBuilder, string $alias). La méthode DossierRepository::matching(Specification $spec) orchestre l'application. Les compositions AndSpec, OrSpec, NotSpec permettent de construire des recherches complexes sans toucher au repository. Les tests unitaires de chaque spec valident la fragment DQL produit, et un test d'intégration valide leur composition. Résultat : le repository pèse 80 lignes au lieu de 1 200, et l'ajout d'un nouveau critère ne nécessite plus de revue par l'architecte.
Scénario 2 — Repository découplé du domaine dans le cœur de comptes bancaire
Une banque exploite un service Symfony de gestion de comptes selon une architecture hexagonale stricte. Le domaine Compte ne connaît pas Doctrine : il définit l'interface App\Domain\Compte\Repository\CompteRepository (méthodes getById(CompteId): Compte, findActifsParTitulaire(TitulaireId): iterable). L'implémentation App\Infrastructure\Persistence\Doctrine\DoctrineCompteRepository est injectée via un alias dans services.yaml. Cette discipline permet à l'équipe de basculer du EntityRepository Doctrine vers un repository CQRS read-side basé sur Elasticsearch pour les écrans de listing, sans toucher au code applicatif. Les tests unitaires du domaine utilisent un repository en mémoire InMemoryCompteRepository rapide et déterministe, tandis que les tests d'intégration valident le repository Doctrine contre une vraie base PostgreSQL. La règle d'or : aucune méthode dynamique findBy* n'est exposée, toutes les requêtes sont nommées par l'intention métier.
Scénario 3 — Specification matching dans un ATS recrutement
Une startup française d'ATS (Applicant Tracking System) propose à ses 320 clients (cabinets de recrutement, DRH) un moteur de matching entre offres et candidats fondé sur des critères pondérés : compétences requises, niveau d'expérience, localisation, salaire cible, langues. Le moteur utilise des Specifications composées : CompetencesMinimales, DistanceMaxKm, PretentionSalarialeCompatible. Chaque spec produit non seulement un fragment DQL mais aussi un score normalisé via une méthode score(Candidat $c): float, permettant un classement après filtrage. Les compositions WeightedAndSpec calculent un score pondéré global. Les recruteurs peuvent sauvegarder un "modèle de matching" qui sérialise la composition de Specs en JSON, rejouable à la volée quand de nouveaux candidats arrivent. Cette architecture a permis de monter la pertinence du top-10 de 41 % à 73 % sur six mois sans modifier le repository.
🛠️ Exemple end-to-end
Use case : recherche multi-critères de dossiers juridiques via Specification composable, exposée par un endpoint REST et testée unitairement.
<?php
// src/Domain/Dossier/Specification/Specification.php
declare(strict_types=1);
namespace App\Domain\Dossier\Specification;
use Doctrine\ORM\QueryBuilder;
interface Specification
{
public function apply(QueryBuilder $qb, string $alias): void;
}
// src/Domain/Dossier/Specification/MatiereEgale.php
namespace App\Domain\Dossier\Specification;
use Doctrine\ORM\QueryBuilder;
final readonly class MatiereEgale implements Specification
{
public function __construct(private string $matiere) {}
public function apply(QueryBuilder $qb, string $alias): void
{
// Param unique par instance : sans ça, deux MatiereEgale dans la même
// requête écraseraient le même placeholder :matiere. spl_object_id est
// stable pour la durée de vie de l'objet → pas de collision.
$param = 'matiere_' . spl_object_id($this);
$qb->andWhere(sprintf('%s.matiere = :%s', $alias, $param))
->setParameter($param, $this->matiere);
}
}
// src/Domain/Dossier/Specification/OuvertEntre.php
namespace App\Domain\Dossier\Specification;
use Doctrine\ORM\QueryBuilder;
final readonly class OuvertEntre implements Specification
{
public function __construct(
private \DateTimeImmutable $debut,
private \DateTimeImmutable $fin,
) {}
public function apply(QueryBuilder $qb, string $alias): void
{
$qb->andWhere(sprintf('%s.ouvertLe BETWEEN :debut AND :fin', $alias))
->setParameter('debut', $this->debut)
->setParameter('fin', $this->fin);
}
}
// src/Domain/Dossier/Specification/AndSpec.php
namespace App\Domain\Dossier\Specification;
use Doctrine\ORM\QueryBuilder;
final readonly class AndSpec implements Specification
{
/** @var Specification[] */
private array $specs;
public function __construct(Specification ...$specs)
{
$this->specs = $specs;
}
public function apply(QueryBuilder $qb, string $alias): void
{
foreach ($this->specs as $spec) {
$spec->apply($qb, $alias);
}
}
}
// src/Domain/Dossier/Repository/DossierRepository.php
namespace App\Domain\Dossier\Repository;
use App\Domain\Dossier\Entity\Dossier;
use App\Domain\Dossier\Specification\Specification;
interface DossierRepository
{
/** @return iterable<Dossier> */
public function matching(Specification $spec, int $limit = 50): iterable;
}
// src/Infrastructure/Persistence/Doctrine/DoctrineDossierRepository.php
namespace App\Infrastructure\Persistence\Doctrine;
use App\Domain\Dossier\Entity\Dossier;
use App\Domain\Dossier\Repository\DossierRepository;
use App\Domain\Dossier\Specification\Specification;
use Doctrine\ORM\EntityManagerInterface;
final readonly class DoctrineDossierRepository implements DossierRepository
{
public function __construct(private EntityManagerInterface $em) {}
public function matching(Specification $spec, int $limit = 50): iterable
{
$qb = $this->em->createQueryBuilder()
->select('d')->from(Dossier::class, 'd')
->setMaxResults($limit);
$spec->apply($qb, 'd');
return $qb->getQuery()->toIterable();
}
}
// src/UI/Http/Controller/RechercheDossierController.php
namespace App\UI\Http\Controller;
use App\Domain\Dossier\Repository\DossierRepository;
use App\Domain\Dossier\Specification\AndSpec;
use App\Domain\Dossier\Specification\MatiereEgale;
use App\Domain\Dossier\Specification\OuvertEntre;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class RechercheDossierController extends AbstractController
{
public function __construct(private readonly DossierRepository $dossiers) {}
#[Route('/api/dossiers', methods: ['GET'])]
public function __invoke(Request $request): JsonResponse
{
$specs = [];
if ($matiere = $request->query->getString('matiere')) {
$specs[] = new MatiereEgale($matiere);
}
if ($debut = $request->query->getString('debut')) {
$specs[] = new OuvertEntre(
new \DateTimeImmutable($debut),
new \DateTimeImmutable($request->query->getString('fin') ?: 'now'),
);
}
$resultats = iterator_to_array($this->dossiers->matching(new AndSpec(...$specs)));
return $this->json(['items' => $resultats]);
}
}Quand utiliser / éviter
Specification / CQRS-light : utiliser quand
- Filtres combinables, UI dynamique.
- Domaine riche, règles partagées entre commande/query.
- Read perf critique (dashboards, exports).
Éviter (KISS) quand
- Petit projet CRUD :
ServiceEntityRepository+ 5 méthodes suffit. - App quasi-jetable, prototype.
- Équipe junior : Specification ajoute une indirection à digérer.
Ne pas abstraire Doctrine derrière une interface "RepositoryInterface" maison
- Vous perdez DQL, Criteria, lock modes, hydratation native.
- Le test ne sera pas plus rapide (vous mockerez de toute façon une couche).
- Doctrine est déjà une abstraction du SQL. Double abstraction = couplage caché.
🏋️ Exercices
Progressifs : implémentation → production-grade → casse-puis-répare. Chaque exo est testable en KernelTestCase.
Exo 1 — OrSpec & NotSpec composables (implémentation)
Objectif : compléter l'algèbre de Specifications avec OrSpec et NotSpec pour pouvoir écrire new AndSpec(new MatiereEgale('droit social'), new NotSpec(new EstArchive())).
Indice/Solution : OrSpec ne peut pas juste enchaîner des andWhere — il faut collecter chaque fragment dans un sous-QueryBuilder ou via $qb->expr()->orX(...). Astuce robuste : chaque sous-spec écrit dans son propre Expr\Andx, puis tu orX() les expressions. NotSpec enveloppe l'expression de l'enfant dans $qb->expr()->not(...). Le piège : les setParameter des enfants restent globaux au QB, donc le param-naming unique (Exo via spl_object_id) est obligatoire ici.
Exo 2 — Specification double-face isSatisfiedBy + apply (implémentation)
Objectif : transformer PublishedAfter en une Specification qui sait à la fois filtrer une requête ET valider un objet déjà en mémoire (isSatisfiedBy(Book $b): bool), et prouver par test que les deux faces sont cohérentes.
Indice/Solution : ajoute isSatisfiedBy(object $c): bool { return $c->getPublishedAt() >= $this->date; }. Test de cohérence : crée 10 entités, applique la Spec en base, puis filtre la collection complète avec isSatisfiedBy et assertEquals les deux ensembles d'ids. C'est ce property-based check qui empêche la divergence des deux faces.
Exo 3 — ReadModel keyset-paginé typé (production-grade)
Objectif : remplacer la pagination OFFSET du BookListReadModel par une keyset pagination stable, exposant un curseur opaque, et garantir un temps de réponse constant sur 1M de lignes.
Indice/Solution : signature paginate(?string $cursor, int $limit): array{items: list<...>, nextCursor: ?string}. Décode le curseur en (publishedAt, id), requête WHERE (published_at, id) < (:pa, :id) ORDER BY published_at DESC, id DESC LIMIT :limit. Index composite (published_at, id) obligatoire. Le curseur = base64(json_encode([$lastPublishedAt, $lastId])). Bench avec EXPLAIN : aucun rows proportionnel à l'offset.
Exo 4 — Garde anti-N+1 dans les tests (casse-puis-répare)
Objectif : écrire un test qui échoue parce qu'une Spec avec join provoque un N+1 à la sérialisation, puis le corriger par un fetch-join et faire passer le test.
Indice/Solution : utilise le DebugStack/SQL logger Doctrine, compte les requêtes avant/après itération + accès aux relations. Assert assertLessThan(3, $queryCount). La version cassée (Spec HasTag + accès $book->getTags()) déclenche N requêtes. Le fix : ->addSelect('t') dans le join (fetch-join) ou un setFetchMode(EAGER) ciblé. Vérifie aussi que le fetch-join ne casse pas setMaxResults (Doctrine paginera via une sous-requête de DISTINCT ids — d'où l'objet Paginator).
Exo 5 — Repository hexagonal + double implémentation (architecture)
Objectif : définir DossierRepository (interface domaine, zéro Doctrine), une impl DoctrineDossierRepository et une impl InMemoryDossierRepository, puis faire passer la même suite de tests contre les deux via un abstract test case.
Indice/Solution : abstract class DossierRepositoryContractTest avec un abstract protected function createRepository(): DossierRepository. Deux classes filles. L'in-memory applique la Spec via isSatisfiedBy (cf. Exo 2) sur un array — d'où l'intérêt de la Spec double-face : elle est l'unique source de vérité partagée entre les deux backends. Si seul apply() existe, l'in-memory ne peut pas exister sans dupliquer la règle.
Exo 6 — Specification scorée + sérialisable (break-then-fix avancé)
Objectif : reproduire l'ATS du scénario 3 : Specification qui produit un score(Candidat): float en plus du filtre, composée en WeightedAndSpec, sérialisable en JSON et rejouable. Casser ensuite la déterminisme par un spl_object_id dans le param-naming sérialisé, puis réparer.
Indice/Solution : la sérialisation casse si les noms de params dépendent de spl_object_id (non reproductible entre processus). Fix : nommage déterministe basé sur un index de position dans l'arbre de Specs (matiere_0, matiere_1...), attribué lors d'un parcours pré-ordre au moment de l'apply. Le JSON sérialise {"type":"WeightedAnd","specs":[{"type":"DistanceMaxKm","km":30,"weight":0.4}, ...]}, rechargé par une factory SpecificationFactory::fromArray().
🎤 En entretien
Q : Pourquoi ne pas cacher Doctrine derrière une RepositoryInterface maison « pour pouvoir changer d'ORM » ? R : Parce que tu ne changeras jamais d'ORM, et que l'abstraction te fait perdre DQL, Criteria, lock modes (PESSIMISTIC_WRITE), hydratation native et le Paginator — tu réécrirais une moins bonne version de Doctrine. La bonne raison d'avoir une interface domaine, ce n'est pas la portabilité technique, c'est l'inversion de dépendance : que le domaine définisse ses besoins métier (findActifsParTitulaire) sans connaître l'infra. Nuance de senior : interface oui, mais nommée par l'intention métier, pas par les verbes Doctrine.
Q : Repository ou ReadModel pour afficher une liste paginée de 50 dossiers avec le nom du client et le nombre de pièces jointes ? R : ReadModel (DBAL/QueryBuilder en arrays), sans hésiter. C'est une projection de lecture, pas une opération de domaine : aucune entité ne sera modifiée, donc l'identity map, le dirty-checking et l'hydratation d'objets sont du gaspillage pur. Le COUNT de pièces jointes se fait en SQL (LEFT JOIN ... GROUP BY), pas en chargeant les collections. Signal de séniorité : savoir que CQRS « light » = juste séparer le chemin de lecture du chemin d'écriture, sans event sourcing ni bus.
Q : Une Specification qui n'a qu'une méthode apply(QueryBuilder) est-elle vraiment le pattern Specification ? R : Non, c'en est une dégradation pragmatique. Le pattern d'Evans définit isSatisfiedBy(candidate): bool — la capacité à évaluer une règle en mémoire, indépendamment de la persistance. La version QueryBuilder-only est utile et répandue, mais elle ne peut pas servir à valider un objet déjà chargé ni à alimenter un repo in-memory pour les tests. Le vrai pattern porte les deux faces (ou les scinde en deux interfaces reliées par un traducteur en hexagonal strict).
Q : Ton endpoint de recherche est passé de 80ms à 4s après un déploiement. Méthodologie ? R : D'abord mesurer, pas deviner : profiler Doctrine (nombre de requêtes + temps), comparer avant/après. Hypothèse #1 = N+1 introduit par une relation passée en lazy ou une Spec qui join sans fetch. Hypothèse #2 = perte d'index (un WHERE sur colonne non indexée, ou un OFFSET qui a grossi). Je regarde l'EXPLAIN de la requête lente, je vérifie le plan, j'ajoute le fetch-join ou l'index, et je verrouille la régression par un test de comptage de requêtes pour qu'elle ne revienne pas. La discipline > l'intuition.
Liens
- Doctrine ServiceEntityRepository — https://symfony.com/bundles/DoctrineBundle/current/entity-repository.html
- Doctrine Criteria — https://www.doctrine-project.org/projects/doctrine-orm/en/current/reference/working-with-associations.html#filtering-collections
- Specification pattern — Eric Evans, "Domain-Driven Design".
- Matthias Verraes — "Final Specifications".
- "Repository is dead, long live the queries" — Mathias Noback.