Beaucoup d'applications d'entreprise dépendent d'une API qu'elles ne maîtrisent pas : un annuaire interne, un référentiel métier, un système de ticketing maison. Chez le grand opérateur télécom de cette série d'articles, c'était une API d'annuaire et de tickets : chaque écran affichant un organigramme, une fiche agent ou la liste des services interrogeait ce service distant. Lent, parfois indisponible, et facturé en latence sur chaque page.
Le réflexe est connu : mettre un cache devant. Le piège l'est moins. Un cache posé sans discernement sert des tickets fermés comme ouverts, masque les créations récentes, et transforme un problème de lenteur en problème d'incohérence — bien plus difficile à diagnostiquer. Tout l'art tient dans une question qu'on se pose trop tard : qu'est-ce qui peut être caché, et qu'est-ce qui ne doit jamais l'être ?
D'abord une façade : traduire, pas exposer
Avant même de parler de cache, une règle : aucune partie de l'application ne doit connaître la forme des réponses de l'API externe. Le bridge est une façade — une couche anti-corruption — dont le seul rôle est de traduire les modèles JSON distants en objets internes propres.
Plutôt que de mapper les champs à la main, on délègue ce travail au
Serializer de Symfony : le DenormalizerInterface
transforme les tableaux JSON bruts en objets internes. Les divergences de
nommage entre l'API et le domaine se déclarent une seule fois, en attribut sur le
modèle, avec #[SerializedName] :
// le bridge ne mappe rien à la main : il denormalise
$users = $this->denormalizer->denormalize($data, User::class . '[]');
final class User
{
public function __construct(
public int $id,
#[SerializedName('cuid')] // le nom distant, absorbé sur le modèle
public string $uid,
#[SerializedName('first_name')]
public string $firstName,
#[SerializedName('last_name')]
public string $lastName,
public ?string $email = null,
public ?string $department = null,
) {
}
}
L'intérêt dépasse l'économie de code. En s'appuyant sur le Serializer, le bridge
partage le même schéma de (dé)sérialisation que le reste de
l'application, et toute la traduction se concentre sur le modèle. Parce que le
code métier manipule des User internes, jamais des tableaux JSON
venus d'ailleurs, le jour où l'API renomme un champ on touche un attribut, pas du
code. Et c'est cette frontière qui rend le cache possible sans le
disperser : tout passe par quelques méthodes, donc le cache se pose à un seul
endroit.
Quoi cacher, et surtout quoi ne jamais cacher
Voici la décision qui structure tout. Les données de l'API ne sont pas égales devant le cache :
- L'annuaire — agents, services, organigramme — change de façon marginale, à l'échelle de la journée. Le relire à chaque page est un gâchis : il doit être caché.
- Les tickets et leurs événements changent à la seconde : un statut qui bascule, un commentaire qui tombe. Les cacher, c'est mentir à l'utilisateur. Ils ne doivent jamais l'être.
- Les écritures — créer un ticket — sont par nature hors cache : on ne met pas en cache un effet de bord.
Concrètement, cette frontière se lit dans le code : les lectures de référence passent par un helper qui cache, les lectures vivantes appellent l'API directement.
// annuaire : donnée de référence, on passe par le cache
public function findUsers(array $query = []): Users
{
$data = $this->fromCache('/api/users', $query);
return new Users($this->denormalizer->denormalize($data, User::class . '[]'));
}
// tickets : donnée vivante, jamais de cache — appel direct à chaque fois
public function findTasks(array $query = []): Tasks
{
$data = $this->fetch('/api/tasks', $query);
return new Tasks($this->denormalizer->denormalize($data, Task::class . '[]'));
}
Ce n'est pas une optimisation qu'on ajoute après coup, c'est une décision de conception qu'on prend en écrivant chaque méthode : cette donnée a-t-elle le droit d'être vieille d'une minute ? La réponse range la méthode d'un côté ou de l'autre de la frontière, définitivement.
Une clé de cache stable : normaliser avant de hacher
Reste un piège discret. La clé de cache doit identifier une requête, mais deux
requêtes identiques peuvent s'écrire différemment :
['page' => 1, 'limit' => 30] et
['limit' => 30, 'page' => 1] désignent le même appel. Sans
précaution, elles produisent deux clés différentes — donc deux entrées de cache,
deux appels à l'API, et la moitié du bénéfice envolée. La parade est de
normaliser avant de hacher :
private function fromCache(string $uri, array $query): array
{
$key = $this->cacheKey($uri, $query);
return $this->cache->get($key, fn(): array => $this->fetch($uri, $query));
}
private function cacheKey(string $uri, array $query): string
{
ksort($query); // l'ordre des paramètres ne doit pas changer la clé
return 'directory_' . md5($uri) . '_' . md5(JsonUtils::encode($query));
}
Le ksort() rend la clé indépendante de l'ordre des paramètres ; le
double md5 garde une clé courte et sûre, sans caractères interdits
par le stockage de cache. Détail qui compte : on passe par le contrat
CacheInterface::get() plutôt que par un has()/set()
manuel, car il apporte gratuitement la protection contre le cache stampede
— quand une clé expire, un seul processus recalcule pendant que les autres
servent l'ancienne valeur, au lieu de marteler l'API tous en même temps.
Quand l'API renâcle : 404, 401 ou panne
Une dépendance externe finit toujours par mal répondre, et tous les échecs ne se
valent pas. Chaque appel s'authentifie avec un jeton OAuth2 porté en
auth_bearer, mis en cache exactement comme dans
l'article précédent
(négocié une fois, expiré soixante secondes avant terme). La brique
fetch() porte cette authentification ; au-dessus, la méthode
publique enveloppe l'appel d'un filet qui distingue trois cas :
// fetch : le GET authentifié, brique commune aux appels cachés et directs
private function fetch(string $uri, array $query): array
{
return $this->client
->request('GET', $uri, [
'auth_bearer' => $this->tokenFactory->getToken(),
'query' => $query,
])
->toArray();
}
// le filet enveloppe le cache : un appel en échec n'est jamais mis en cache
public function findUsers(array $query = []): Users
{
try {
$data = $this->fromCache('/api/users', $query);
return new Users($this->denormalizer->denormalize($data, User::class . '[]'));
} catch (Throwable $e) {
if ($e instanceof ClientExceptionInterface) {
$status = $e->getResponse()->getStatusCode();
// 404 : la ressource n'existe pas — ce n'est pas une erreur
if (404 === $status) {
return new Users([]);
}
// 401/403 : le token ne vaut plus rien — on le purge
if (401 === $status || 403 === $status) {
$this->tokenFactory->reset();
}
}
// réseau, 5xx : on trace la cause et on dégrade
$this->logger->error('appel au référentiel en échec', [
'uri' => '/api/users',
'exception' => $e->getMessage(),
]);
return new Users([]);
}
}
Le 404 n'est pas un échec : une fiche absente est une réponse
valable, pas une exception à propager. Le 401/403 trahit un
token révoqué ou expiré côté serveur malgré la marge ; on purge le cache de
jetons pour forcer une renégociation au prochain appel — le pendant, côté
consommateur de cache, du retry détaillé dans l'article sur le transport
Mailer. Tout le reste — coupure réseau, 5xx — est journalisé puis absorbé : on
renvoie le vide. Un détail structurel rend l'ensemble sûr : le filet entoure le
cache, pas l'inverse — une réponse en échec n'atteint jamais
cache->get(), si bien que le cache ne mémorise que des succès.
Ce choix mérite d'être explicite, car il a un coût : un résultat vide qui remonte oblige chaque appelant à gérer l'absence de donnée. C'est précisément ce qu'on veut — un organigramme momentanément vide vaut mieux qu'une application qui tombe parce qu'un service tiers tousse. Le couple cache + dégradation forme d'ailleurs un filet à deux niveaux : tant que le cache est chaud, une panne de l'API reste même invisible.
Garder le cache frais : TTL ou invalidation
Reste la question que tout cache finit par poser : quand devient-il périmé ?
Pour l'annuaire distant, la réponse est un TTL — une durée de
vie. On ne possède pas la donnée, on ne sait pas quand elle change à la source,
alors on accepte qu'elle soit vieille d'une heure tout au plus et on laisse
l'entrée expirer d'elle-même. Cette durée se déclare dans la configuration du pool, et
c'est l'occasion de montrer d'où vient le $this->cache injecté dans
le bridge :
framework:
cache:
default_redis_provider: '%env(REDIS_DSN)%'
pools:
# le pool du bridge annuaire : mémoire du process (L1), puis Redis partagé (L2)
cache.directory:
default_lifetime: 3600
adapters:
- cache.adapter.array
- cache.adapter.redis
when@test:
framework:
cache:
pools:
cache.directory:
adapters:
- cache.adapter.null # aucun cache en test : comportement déterministe
Deux choses valent d'être notées. Le pool empile deux adaptateurs : un cache
mémoire local au process, devant un Redis partagé entre toutes les instances —
une requête déjà vue par un autre worker est servie sans toucher l'API. Et en
test, le même pool bascule sur l'adaptateur null : aucune entrée
n'est conservée, chaque test repart d'un cache vide, sans surprise.
Mais le TTL est un pis-aller : il fait le pari qu'une donnée périmée pendant une heure est tolérable. Dès qu'on possède la source — non plus une API distante, mais sa propre base —, on peut faire bien mieux qu'attendre l'expiration. Sur une autre application, une plateforme SaaS PIM, le cache garde des calculs coûteux sur des données que la plateforme maîtrise ; laisser un TTL afficher un attribut périmé après une modification serait un bug visible. La parade est d'invalider exactement quand la donnée change, avec un listener Doctrine :
#[AsDoctrineListener(Events::preUpdate)]
#[AsDoctrineListener(Events::postFlush)]
final class CacheListener
{
private array $dirty = [];
public function preUpdate(PreUpdateEventArgs $args): void
{
// pendant le flush, on note seulement QUELS types ont changé
if ($args->getObject() instanceof Attribute) {
$this->dirty[] = CacheTypeEnum::ATTRIBUTE;
}
}
public function postFlush(): void
{
// une fois le commit fait, on reconstruit chaque cache touché — une seule fois
foreach (array_unique($this->dirty) as $type) {
$this->doctrineCache->get($type)?->warmup();
}
$this->dirty = [];
}
}
Le découpage en deux temps est le cœur du pattern. Les événements
preUpdate (et leurs cousins prePersist,
preRemove) ne font qu'accumuler les types touchés
pendant la transaction ; c'est seulement en postFlush, une fois les
données réellement écrites, que le cache est reconstruit. On évite ainsi deux
pièges : reconstruire un cache à partir de données pas encore committées, et le
reconstruire dix fois pour dix entités modifiées dans la même transaction. Un
seul rebuild, après coup, sur les seuls types concernés.
Une nuance pour les contextes long-running — un worker Messenger, un
serveur FrankenPHP — où le listener survit d'un message au suivant : son tableau
$dirty est bien vidé en postFlush, mais si une
transaction échoue avant de l'atteindre, le résidu fuiterait sur le message
d'après. Faire implémenter ResetInterface au listener — une méthode
reset() qui remet $dirty à vide — laisse Symfony nettoyer
cet état à chaque cycle et referme la fuite. Inutile en PHP-FPM, où chaque requête
repart d'un état neuf ; salutaire dès qu'un process est réutilisé.
Restent les cas où il faut reprendre la main à la main — un incident, un déploiement, un cache qu'on soupçonne corrompu. Symfony fournit les commandes, auxquelles s'ajoute un warmup métier pour reconstruire les caches applicatifs d'un coup :
# vider un pool précis, sans toucher au reste du cache applicatif
php bin/console cache:pool:clear cache.directory
# purger uniquement les entrées expirées (à passer en cron)
php bin/console cache:pool:prune
# reconstruire les caches métier après un incident ou un déploiement
php bin/console app:cache:warmup
Bonus : tester sans jamais appeler l'API
Toute cette mécanique s'appuie sur une dépendance réseau — la bête noire des tests. La parade est la même qui a servi à poser le cache proprement : l'application ne dépend pas de la classe concrète, mais d'une interface. En production, l'implémentation réelle parle à l'API ; en test, une fausse implémentation renvoie des données déterministes, câblée à sa place dans la configuration de l'environnement de test :
// câblé uniquement en env de test (config/services.php)
final class FakeDirectory implements DirectoryInterface
{
public function findUser(string $uid): ?User
{
// données fixes, aucune requête réseau : les tests e2e sont déterministes
return new User(1, $uid, 'Ada', 'Lovelace', 'ada@example.com', 'Recherche');
}
// … les autres méthodes renvoient de même des objets prévisibles
}
Le bénéfice dépasse la vitesse. Les tests de bout en bout deviennent reproductibles — pas de flakiness dû à une API distante lente ou capricieuse — et ils peuvent tourner hors-ligne, en CI, sans le moindre accès au réseau interne. La même interface qui isole le métier de l'API en production l'isole du réseau en test : une frontière, deux bénéfices.
Conclusion
Mettre une API externe en cache n'est pas un problème de cache, c'est un problème
de classification. Le travail technique — un helper, une clé
normalisée, un try/catch — est mineur ; la décision qui compte est
de trier chaque donnée entre ce qui peut vieillir et ce qui doit rester vivant,
puis de choisir comment la rafraîchir — un TTL quand on subit la source, une
invalidation par événement quand on la maîtrise. Une façade qui centralise les
appels rend ces décisions applicables à un seul endroit, et une interface rend le
tout testable hors-ligne.
La règle se résume en une phrase : cacher la référence, jamais le vivant ; et toujours prévoir le jour où l'API ne répond pas. C'est moins spectaculaire qu'une grappe Redis savamment réglée, mais c'est ce qui distingue un cache qui accélère d'un cache qui ment.