La majorité des APIs que l’on rencontre en production ne sont pas catastrophiques. Elles tiennent la charge, elles répondent, elles remplissent leur rôle.
Certaines (beaucoup) sont lentes.
Pas au point de bloquer un produit immédiatement, mais suffisamment pour que les lenteurs s’accumulent : le front compense, du cache est ajouté, les coûts montent, et la latence devient une sorte de norme implicite.
Dans la plupart des cas, ce n’est pas un problème d’infrastructure, mais un problème de conception.
Le moment où l’on regarde réellement
Prenons un cas assez classique : un endpoint de listing paginé avec enrichissement.
Temps moyen observé : ~400–500 ms.
Rien d’anormal côté infrastructure :
- CPU peu sollicité
- réseau stable
- aucune saturation évidente
À ce stade, le réflexe est souvent d’ajouter du cache. Ce qui améliore légèrement (voire énormément) la situation, mais sans en traiter la cause.
En tout 1er lieu, il est indispensable de logger quelques métriques :
$start = hrtime(true);
$orders = $this->service->getOrders($criteria);
$afterDomain = hrtime(true);
$response = $this->presenter->toArray($orders);
$afterSerialization = hrtime(true);
logger()->info('api_breakdown', [
'domain_ms' => ($afterDomain - $start) / 1e6,
'serialization_ms' => ($afterSerialization - $afterDomain) / 1e6,
]);
On obtient typiquement :
domain_ms: 120
serialization_ms: 280
total: ~450ms
À ce stade, on a une intuition, mais pas encore de diagnostic fiable.
Le "N+1 query problem"
Le N+1 “classique” est connu. Dans notre contexte il peut cependant se cacher dans les détails :
return $this->json($orders, context: [
'groups' => ['order:list']
]);
Avec une configuration du type :
#[Groups(['order:list'])]
public function getUser(): User
{
return $this->user;
}
Puis :
#[Groups(['order:list'])]
public function getCompany(): Company
{
return $this->company;
}
Et ainsi de suite.
Le code applicatif reste propre.
Mais la traversée de graphe déclenche :
- lazy loading Doctrine
- requêtes multiples
- hydration implicite
- sérialisation récursive
Le point critique est que ce comportement est diffus. Il n’existe pas de point unique dans le code où il est explicitement visible.
Reprendre le contrôle
Le correctif ne consiste pas simplement à “ajouter des join”.
Il consiste à reprendre la main sur ce qui est réellement nécessaire.
$query = $em->createQuery("
SELECT NEW App\\ReadModel\\OrderListItem(
o.id,
u.id,
u.name,
c.name,
SUM(i.price)
)
FROM Order o
JOIN o.user u
JOIN u.company c
JOIN o.items i
WHERE o.status = :status
GROUP BY o.id, u.id, c.id
");
$query->setParameter('status', OrderStatus::PAID);
Dans cette approche :
- aucune entité complète n’est hydratée
- aucune relation n’est traversée implicitement
- le résultat est directement exploitable
Le coût réel des objets
Une grande partie du coût ne vient pas de la base, mais de la manipulation d’objets.
Hydrater un graphe riche pour n’en exploiter qu’une fraction est extrêmement coûteux :
- allocation mémoire
- CPU
- garbage collection
- sérialisation
Un cas réel observé :
- ~150 entités
- une dizaine de relations par entité
- hydration complète
Après refonte vers des read models ciblés :
RAM: -60%
Temps total: -70%
Le gain ne vient pas d’une optimisation fine, mais de la suppression de travail inutile.
La sérialisation comme facteur dominant
La sérialisation est souvent perçue comme un coût marginal. En pratique, elle devient rapidement dominante.
Un comportement intéressant est son caractère non linéaire :
100 items → 40ms
200 items → 140ms
400 items → 480ms
Ce phénomène s’explique par :
- la récursion
- la réflexion
- les métadonnées
- les normalizers imbriqués
Dans certains cas, une approche plus directe devient pertinente :
return new JsonResponse(
$connection->fetchAllAssociative($sql)
);
Cela réduit drastiquement :
- le nombre d’objets
- les transformations
- la variabilité des temps de réponse
(mais il faut aimer, et accepter de perdre un peu de confort pour gagner en maîtrise)
L’accumulation des couches
Une architecture “propre” mal maîtrisée peut introduire un coût important.
Un chemin classique :
Controller → Handler → Service → Mapper → DTO → Presenter → Serializer
Pour produire une réponse simple.
Chaque couche est justifiable individuellement.
L’ensemble peut devenir excessif.
Sur les chemins critiques, un compromis est souvent nécessaire :
- conserver la séparation métier
- simplifier les lectures
Le rôle des métriques
Sans métriques, les décisions sont basées sur des hypothèses.
Un minimum d’observabilité change radicalement la situation :
logger()->info('perf', [
'sql_count' => $sqlLogger->getQueryCount(),
'sql_time_ms' => $sqlLogger->getTotalTime(),
'total_ms' => $total,
]);
Cela permet :
- d’identifier les endpoints problématiques
- de comprendre la répartition du temps
- d’éviter les optimisations inutiles
Conclusion
Une API lente n’est presque jamais le résultat d’un problème unique.
C’est une accumulation :
- accès aux données inefficace
- sur-hydratation
- sérialisation coûteuse
- empilement de couches
- absence de mesure
Une API performante n’est pas une API optimisée après coup.
C’est une API :
- explicite
- mesurée
- maîtrisée
TL;DR
- Le N+1 est toujours présent, mais plus diffus
- Les objets coûtent plus cher que les données
- La sérialisation peut devenir dominante
- Les abstractions ont un coût réel
- Les métriques sont indispensables