Aller au contenu principal

Partager du code entre cinq applications sans les coupler

Photo d'Emmanuel BALLERY, fondateur de x10
Emmanuel BALLERY
CTO freelance & Architecte logiciel
calendar_today 15/06/2026
schedule 13 min lecture
Une librairie de composants partagée alimente plusieurs applications indépendantes sans les attacher entre elles

Cinq applications Symfony pour le même grand compte, et les mêmes besoins qui reviennent dans chacune : déposer des fichiers sur un stockage objet, envoyer des SMS, négocier des tokens OAuth, sauvegarder une base, notifier une équipe. La première fois, on copie la classe d'un projet à l'autre. La deuxième fois aussi. La troisième fois, le copier-coller a déjà divergé : trois versions du même code, trois comportements subtilement différents, trois endroits où corriger le prochain bug.

La réponse évidente — « on n'a qu'à faire une librairie commune » — est aussi l'un des pièges les plus documentés du génie logiciel : la librairie interne qui couple tout ce qu'elle touche, impose ses dépendances à tout le monde et casse quelque chose à chaque montée de version. Ce retour d'expérience raconte l'inverse : une librairie PHP interne partagée entre cinq applications d'un grand opérateur télécom — celles de la mission au long cours déjà évoquée dans cette série —, maintenue depuis trois ans, vingt-sept versions publiées, et un seul changement cassant dans l'API du quotidien. Voici les quatre décisions qui rendent la mutualisation rentable au lieu de toxique.

Les deux façons de rater une librairie partagée

Avant le comment, le pourquoi. Une librairie interne échoue de deux façons opposées, et il faut les regarder en face toutes les deux.

Par divergence, d'abord : c'est l'échec du copier-coller. Chaque projet héberge sa copie des mêmes utilitaires, les corrections ne se propagent pas, et au bout de deux ans personne ne sait plus quelle version fait foi. Le coût est invisible au début — coller un fichier prend dix secondes — et s'accumule en silence, bug par bug.

Par couplage, ensuite : c'est l'échec de la librairie fourre-tout. Tout le monde dépend d'elle, elle dépend de tout, et chaque montée de version devient un projet de migration synchronisé entre toutes les équipes. Au premier breaking change mal géré, les consommateurs épinglent une vieille version « en attendant » — et la librairie commune devient un copier-coller qui ne dit pas son nom, avec la complexité d'un dépôt partagé en prime.

Une mutualisation réussie n'est donc pas une question d'outillage : c'est une question de contrat entre la librairie et ses consommateurs. Tout ce qui suit découle de cette idée.

Décision 1 — Les soft dependencies : ne payer que ce qu'on consomme

Premier réflexe en ouvrant le composer.json d'une librairie interne : compter les dépendances. Celle-ci en exige quatre, et aucune n'engage à rien :

{
    "require": {
        "php": ">=8.4",
        "ext-iconv": "*",
        "ext-mbstring": "*",
        "psr/log": "^3.0"
    }
}

Pourtant la librairie contient une dizaine de composants, dont certains s'appuient sur le client HTTP Symfony, le rate limiter, le SDK AWS ou Doctrine. L'astuce s'appelle soft dependencies : ces paquets sont en require-dev de la librairie — une vingtaine, pour exécuter sa propre suite de tests — mais ne sont pas imposés aux consommateurs. Chaque composant documente ce qu'il requiert, et c'est le projet consommateur qui fait le composer require correspondant, uniquement s'il active le composant.

Même logique pour la configuration : un fichier YAML par composant, livré avec la librairie, que le consommateur importe sélectivement :

# chaque application n'importe que les composants qu'elle utilise
imports:
    - ../vendor/acme/php-tools/config/token.yaml
    - ../vendor/acme/php-tools/config/sms.yaml
    # pas de s3.yaml : cette application n'en a pas besoin

Le résultat se mesure dans les cinq applications : aucune n'embarque le SDK AWS si elle ne touche pas au stockage objet, aucune ne configure de rate limiter si elle n'envoie pas de SMS. La librairie grossit sans alourdir personne — c'est la condition pour pouvoir y ajouter des composants sans culpabiliser. Le prix à payer est une discipline de documentation : chaque composant a sa page, qui liste ses soft dependencies, ses variables d'environnement et son import YAML. Une heure d'écriture par composant, qui remplace des heures de débogage chez chaque consommateur.

Décision 2 — Le pattern bridge : une interface, une implémentation, un fake

Le cœur de la librairie, ce sont ses bridges : des ponts vers les services externes du groupe — API SMS, stockage S3, webhooks de messagerie d'équipe, fournisseur de tokens OAuth, référentiels internes. Chaque bridge suit exactement la même structure :

src/Bridge/Sms/
├── SmsInterface.php   # le contrat consommateur : send(…)
├── Sms.php            # l'implémentation réelle (OAuth, rate limit, API)
└── FakeSms.php        # l'implémentation hors-ligne et déterministe

L'interface n'expose que ce dont les consommateurs ont besoin — pas les détails du transport. La configuration livrée avec le composant câble l'alias par défaut vers l'implémentation réelle, et chaque projet le renverse dans son environnement de test :

imports:
    # câblage par défaut fourni par la librairie : SmsInterface → Sms
    - ../vendor/acme/php-tools/config/sms.yaml

when@test:
    services:
        # en test : le fake, déterministe et hors-ligne
        Acme\Bridge\Sms\SmsInterface: '@Acme\Bridge\Sms\FakeSms'

Les lecteurs de l'article sur les tests e2e de l'image de production reconnaîtront ce montage : c'est précisément ce qui permet de démarrer l'image Docker de production en CI, sans réseau vers les API internes, et d'y dérouler une vraie suite Playwright. Le point important est que le fake fait partie de la librairie, pas des projets. Il est écrit une fois, testé avec l'implémentation réelle, et reproduit le contrat de validation : le FakeSms rejette un numéro invalide ou un message trop long exactement comme le vrai bridge — il s'arrête juste avant l'appel réseau. Un fake naïf qui dit oui à tout laisse passer en test ce que la production refusera ; un fake qui partage les validations du réel attrape ces bugs là où ils coûtent le moins cher.

Décision 3 — La rétrocompatibilité comme discipline par défaut

Trois ans de vie, vingt-sept versions taguées, cinq consommateurs en production. Le bilan des changements cassants tient en trois lignes : une version majeure pour le passage à PHP 8.4 — un prérequis de plateforme, pas un changement d'API —, une pour la réorganisation d'un composant, et une seule rupture dans l'API du quotidien : un renommage de classe utilitaire et un déplacement de méthode, accompagnés dans le changelog d'un guide de migration qui tient en deux remplacements.

# [4.0.0]

## Breaking changes

- `Acme\Utils\StrUtil` renamed to `Acme\Utils\StrUtils`.
  Migration : remplacer l'import et le préfixe aux points d'appel.

# [4.3.1]

## Added

- `SmsInterface::send()` — nouveau paramètre optionnel `?string $notifyUrl`
  (défaut `null` ; les appels existants ne changent pas).

L'extrait de la 4.3.1 montre la règle en action : additif d'abord. Un besoin nouveau — recevoir l'accusé de réception d'un SMS — s'ajoute comme paramètre optionnel avec valeur par défaut, pas comme une nouvelle signature obligatoire. Les cinq consommateurs continuent de fonctionner sans toucher une ligne ; celui qui a besoin de la fonctionnalité l'adopte quand il veut. Et lorsqu'un changement touche malgré tout un point d'extension — les projets qui fournissent leur propre implémentation de l'interface —, le changelog le dit explicitement, au lieu de laisser la CI du consommateur le découvrir.

Cette discipline a une conséquence visible dans les composer.json : aujourd'hui, deux applications consomment la version 4 et trois vivent encore très bien en version 3. Personne n'a été forcé de migrer. Chaque équipe monte de version quand son calendrier le permet, parce que monter de version est un non-événement — et c'est exactement pour ça qu'elles finissent par le faire. La confiance dans la librairie se construit release après release, et se détruirait en une seule.

Décision 4 — Un changelog qui remplace des réunions

Le dernier pilier n'est pas technique : c'est le changelog, tenu au format Keep a Changelog, où chaque version documente ce qui est ajouté, modifié ou cassé — du point de vue du consommateur, pas du mainteneur. « Nouveau paramètre optionnel, les appels existants ne changent pas » est une information actionnable ; « refacto du bridge SMS » n'en est pas une.

Dans un contexte multi-équipes, ce document fait office de canal de coordination : pas de réunion de synchronisation, pas d'annonce à faire passer, pas de tableau des versions à maintenir ailleurs. Une équipe qui envisage un bump lit le changelog entre sa version et la cible, et sait en cinq minutes ce qui l'attend — y compris, pour l'unique break d'API, le temps exact de migration. C'est la version écrite et asynchrone de la confiance : le mainteneur s'engage sur ce qu'il publie, les consommateurs vérifient au lieu de deviner.

Le coût asymétrique de la rétrocompatibilité

Tout cela a un coût, et il faut le nommer pour comprendre pourquoi il est rentable. Maintenir la compatibilité coûte au mainteneur : des paramètres optionnels au lieu de signatures propres, du code de transition, des dépréciations à gérer, un changelog à écrire. Casser coûte aux consommateurs : chaque rupture se paie autant de fois qu'il y a de projets, plus la coordination, plus le risque que l'un d'eux reporte la migration et s'enferme sur une version figée.

L'asymétrie saute aux yeux dès qu'on pose les ordres de grandeur : un effort localisé chez une personne qui connaît intimement le code, contre un effort multiplié par cinq chez des équipes qui ont d'autres priorités. Avec cinq consommateurs, une heure de précaution côté librairie économise des demi-journées cumulées côté projets — et le calcul penche un peu plus à chaque nouveau consommateur. C'est le même raisonnement qui fait préférer les migrations additives dans un rolling update : quand deux versions doivent cohabiter, la charge de la compatibilité revient à celui qui publie, pas à ceux qui consomment.

L'inverse mérite d'être dit aussi : une librairie interne qui casse souvent est pire que du copier-coller. Elle cumule les inconvénients — le couplage du code partagé, la divergence des versions épinglées — sans le seul avantage du copier-coller, qui est de ne rien promettre à personne.

Quand ne pas mutualiser

Un REX honnête doit aussi tracer la frontière. Tout ne va pas dans la librairie, et les critères sont assez nets après trois ans de pratique.

Le code métier n'y entre jamais. Les règles de gestion d'une application appartiennent à cette application ; les mutualiser créerait du couplage fonctionnel entre des produits qui évoluent à des rythmes différents. La librairie n'accueille que du transverse : des ponts vers des services partagés, des utilitaires sans état, de l'outillage d'exploitation — le genre de code dont la sauvegarde de base de données est l'exemple type, améliorée une fois (mysqldump --single-transaction --quick, pour ne plus verrouiller les écritures pendant le backup) et déployée partout par un simple bump de patch.

L'extraction attend la répétition. La règle de trois s'applique : un besoin rencontré une fois reste dans son projet ; deux fois, on note la duplication ; trois fois, on extrait. Extraire trop tôt, c'est généraliser à partir d'un seul cas — et se tromper sur l'API. Chaque composant de la librairie est né d'un besoin réel dans au moins deux applications.

Et chaque composant paie un ticket d'entrée : une interface, un fake déterministe, une page de documentation, un fichier de configuration, la liste de ses soft dependencies. Si un bout de code ne mérite pas cet investissement, c'est qu'il n'est pas mûr pour être partagé — et c'est une information utile, pas un échec.

Bonus : publier et consommer le paquet via GitLab

Reste la question logistique, qui revient à chaque fois qu'on parle de librairie interne : où héberger le paquet ? Inutile de payer un Packagist privé ou de maintenir un Satis — l'instance GitLab qui héberge déjà le code fait registry Composer. Côté librairie, la publication tient dans un job de CI déclenché à chaque tag :

publish:
  stage: publish
  only: [ tags ]
  script:
    # enregistre la version taguée sur le registry Composer du projet
    - >
      curl --fail-with-body --request POST
      --header "JOB-TOKEN: ${CI_JOB_TOKEN}"
      --data "tag=${CI_COMMIT_TAG}"
      "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/composer"

Un détail conceptuel qui surprend : ce curl ne téléverse rien. Le registry Composer de GitLab ne stocke pas d'artefact — « publier » consiste à enregistrer la référence du tag, et le paquet est le dépôt Git à ce tag, décrit par son composer.json. Pas de build, pas de zip, pas de stockage à gérer : taguer, c'est livrer. Côté consommateur, on déclare la source puis on requiert le paquet comme n'importe quel autre :

# déclarer le registry Composer du groupe GitLab comme source de paquets
composer config repositories.internal composer \
  "https://gitlab.example.com/api/v4/group/<group_id>/-/packages/composer/packages.json"

composer require 'acme/php-tools:^4.0'

Le seul point de friction réel est l'authentification, et elle se règle différemment selon le contexte. En local, un personal access token avec le scope read_api suffit. En CI, surtout pas de token personnel à faire circuler dans des variables : chaque job dispose déjà d'un CI_JOB_TOKEN éphémère, qui meurt avec lui :

# en local : personal access token (scope read_api)
composer config --global http-basic.gitlab.example.com __token__ "${TOKEN}"

# en CI : le job token éphémère fourni par GitLab
composer config http-basic.gitlab.example.com gitlab-ci-token "${CI_JOB_TOKEN}"

Et le piège qui coûte une demi-journée à qui ne le connaît pas : depuis GitLab 16, le job token est soumis à une allowlist. Le projet de la librairie doit autoriser explicitement chaque projet consommateur (Settings → CI/CD → Job token permissions), sinon le composer install du consommateur échoue — avec un 404, pas un 403, comme si le paquet n'existait pas. On cherche d'abord une faute de frappe dans l'URL, puis un problème de version… alors que c'est une case à cocher dans le projet d'en face.

Conclusion

Mutualiser sans coupler tient en quatre décisions : des soft dependencies pour que chacun ne paie que ce qu'il consomme, des bridges interface + fake pour que le code partagé reste testable partout, une rétrocompatibilité par défaut pour que monter de version soit un non-événement, et un changelog tenu sérieusement pour que la coordination soit écrite plutôt que réunionnée. Aucune de ces décisions n'est techniquement difficile ; toutes sont des engagements de discipline dans la durée.

Le bénéfice, lui, se lit en filigrane de cette série d'articles : la même librairie fournit les fakes qui rendent l'image de production testable en CI, l'outillage de sauvegarde de toutes les applications, et les ponts vers les services du groupe — écrits une fois, corrigés une fois, déployés cinq fois. Trois ans, vingt-sept versions, un seul break d'API. La mutualisation n'est pas un sujet d'outillage : c'est un contrat qu'on honore, version après version.

Photo d'Emmanuel BALLERY, fondateur de x10

À propos de l'auteur

Emmanuel BALLERY est le fondateur de x10 solutions. Expert en architecture logicielle et passionné par la qualité du code (Software Craftsmanship), il aide les entreprises à transformer leur dette technique en actifs durables.

Voir plus arrow_forward

Questions fréquentes

Pourquoi une librairie plutôt qu'un bundle Symfony ? expand_more
Les deux fonctionnent. Ici, le choix est une librairie simple avec des fichiers de configuration importés explicitement : moins de magie qu'un bundle, et chaque application voit noir sur blanc ce qu'elle active. L'essentiel n'est pas l'emballage mais la discipline : soft dependencies, interfaces, rétrocompatibilité.
À partir de combien de projets une librairie interne se justifie-t-elle ? expand_more
Dès deux consommateurs réels, le calcul peut devenir positif ; à trois, il l'est presque toujours. La règle de trois reste le bon garde-fou pour chaque composant : extraire à la troisième duplication, pas avant — généraliser à partir d'un seul cas produit de mauvaises API.
Comment empêcher la librairie de devenir un fourre-tout ? expand_more
Par un ticket d'entrée explicite : chaque composant doit fournir une interface, un fake déterministe, sa documentation, sa configuration et la liste de ses soft dependencies. Et par une frontière nette : le code métier reste dans les applications, seul le transverse est partagé.
Monorepo ou dépôt séparé avec registry privé ? expand_more
Ici, un dépôt dédié publié sur le registry Composer privé de l'instance GitLab : chaque application consomme la librairie comme n'importe quel paquet, avec une contrainte de version explicite. Le monorepo est une alternative valable quand les équipes et les cycles de déploiement sont déjà unifiés — ce qui n'était pas le cas ici.