L'évolution de .NET : intégration des capacités mobiles natives et de l'IA dans les applications web modernes
Milan Jovanović a récemment publié un argument fort contre la versionnage anticipée de l'API. Le point essentiel : la plupart des équipes se tournent vers v2 trop tôt car elles manquent d'une stratégie d'évolution de contrat. Le versionnage est un outil de compatibilité, pas une stratégie de conception.
L'argument résonne avec notre équipe d'ingénierie chez Iron Software. Nous livrons des bibliothèques .NET, ce qui signifie que la surface publique de nos produits est une API. Chaque signature de méthode, chaque propriété, chaque comportement par défaut est un contrat intégré dans des milliers de bases de code client. Un saut de version principale n'est pas une sortie. C'est un projet de migration pour tout le monde en aval.
Ce qui suit est le point de vue d'un développeur sur le morceau de Milan du point de vue d'un auteur de bibliothèque, et comment les mêmes règles de compatibilité s'appliquent que vous expédiiez une API REST ou un package NuGet.
Résumé
- Le versionnage n'est pas une stratégie de conception. C'est la solution de dernier recours lorsque la coexistence échoue.
- Les changements perturbateurs se cachent dans le comportement, pas seulement dans les URL ou les schémas.
- Les quatre règles de compatibilité : ne rien retirer, ne pas changer le traitement, ne pas resserrer la validation, garder les ajouts optionnels.
- Une nouvelle opération est presque toujours moins coûteuse qu'une nouvelle version.
- Une réelle dépréciation nécessite des signaux d'exécution et de la télémétrie, pas seulement des mises à jour de documentation.
Les règles HTTP s'appliquent aussi aux API de bibliothèque
Milan encadre la discussion autour d'une API REST pour /orders, mais les mêmes règles s'appliquent lorsque votre API est une classe C# publique livrée dans un package NuGet. Le mappage est direct :
| Changement d'API REST | Équivalent de bibliothèque NuGet |
|---|---|
| Renommer un champ JSON | Renommer une propriété publique |
| Supprimer un point de terminaison | Supprimer une méthode publique |
| Reserrer la validation de la demande | Ajouter un paramètre non-nullable |
| Changer le comportement de l'opération | Changer ce qu'une méthode fait sous le capot |
| Ajouter un champ requis | Ajouter un paramètre de constructeur requis |
Si vous avez déjà téléchargé une version majeure d'une bibliothèque .NET populaire et passé une demi-journée à corriger les APIs renommées, vous avez été victime d'une décision de v2 qui aurait probablement pu être traitée de manière additive.
Ce qui brise réellement les consommateurs
La liste de Milan est précise :
- Supprimer ou renommer des champs
- Changer la signification des données existantes
- Reserrer la validation des demandes
- Changer les formats de pagination ou d'erreur
- Supposer que les valeurs de type énumération sont fermées pour toujours
Le deuxième élément est celui qui prend le plus souvent les équipes au dépourvu : changer la signification des données existantes sans changer sa forme. Le JSON ressemble le même. La signature C# semble la même. Tout compile. Rien ne lance d'exception à l'exécution. Mais le champ signifie désormais quelque chose de différent, et chaque consommateur qui dépendait des anciens sémantiques a silencieusement tort.
L'exemple de Milan :
// Before
{ "total": 100 }
// After
{ "total": { "amount": 100, "currency": "USD" } }
Même nom de champ. Même point de terminaison. Chaque client qui analysait total en tant que nombre est maintenant cassé.
L'équivalent de la bibliothèque est de changer ce qu'une méthode renvoie ou comment elle interprète ses entrées. Une méthode Save() qui auparavant écrasait et qui maintenant ajoute. Un paramètre Trim dont la valeur par défaut passe de true à false. Une méthode qui lançait une exception sur entrée invalide et qui maintenant retourne une valeur par défaut silencieusement.
Les quatre règles de compatibilité
Milan résume les règles comme suit : ne rien retirer, ne pas modifier les règles de traitement, ne pas transformer les éléments optionnels en obligatoires, et tout ce que vous ajoutez doit être optionnel. Les quatre principes valent la peine d'être maintenus devant toute équipe responsable d'une API publique :
- Garder les champs et le comportement existants en place.
- Ne pas transformer les données de demande optionnelles en données requises.
- Ne pas changer ce qu'une opération existante fait.
- Rendre tout nouveau ajoutitif et optionnel par défaut.
Ceux-ci se traduisent directement dans la conception de la bibliothèque. "Ne rien retirer" signifie ne pas supprimer de membre public. "Ne pas modifier les règles de traitement" signifie que les méthodes existantes doivent se comporter comme elles l'ont fait lors de leur expédition. "Ne pas rendre optionnel obligatoire" signifie ne pas ajouter de paramètres requis à une méthode existante ; fournissez une surcharge à la place. "Ajoutitif et optionnel" signifie que la nouvelle fonctionnalité appartient à de nouvelles méthodes ou à des paramètres optionnels avec des valeurs par défaut sensées.
Comment cela se joue en pratique
La façon la plus claire d'illustrer ces règles est avec une décision d'API réelle, alors voici l'une des nôtres.
Il y a plusieurs versions, IronPDF avait besoin de prendre en charge un ensemble plus riche d'options de rendu pour la conversion HTML en PDF : tailles de papier personnalisées, marges personnalisées, émulation média CSS, modèles d'en-tête et de bas de page, et plus. L'approche directe aurait été de muter la méthode de rendu existante pour accepter les nouvelles options. Cette décision aurait cassé chaque client utilisant la forme simple de l'API.
Pour contextualiser, la bibliothèque s'installe via les canaux standard de package .NET :
# .NET CLI
dotnet add package IronPdf
# Package Manager Console
Install-Package IronPdf
# .NET CLI
dotnet add package IronPdf
# Package Manager Console
Install-Package IronPdf
Le package NuGet d'IronPdf a accumulé plus de 18 millions de téléchargements, ce qui fait partie de la raison pour laquelle la stabilité de l'API compte : chaque changement perturbateur se propage à travers autant d'intégrations.
L'approche que nous avons expédiée à la place :
// The original, three-year-old API. Still works. Still unchanged.
var renderer = new ChromePdfRenderer();
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");
// New rendering options live on an options object, not in the method signature.
var renderer = new ChromePdfRenderer();
renderer.RenderingOptions.PaperSize = PdfPaperSize.A4;
renderer.RenderingOptions.MarginTop = 20;
renderer.RenderingOptions.CssMediaType = PdfCssMediaType.Print;
renderer.RenderingOptions.HtmlHeader = new HtmlHeaderFooter { HtmlFragment = "..." };
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");
// The original, three-year-old API. Still works. Still unchanged.
var renderer = new ChromePdfRenderer();
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");
// New rendering options live on an options object, not in the method signature.
var renderer = new ChromePdfRenderer();
renderer.RenderingOptions.PaperSize = PdfPaperSize.A4;
renderer.RenderingOptions.MarginTop = 20;
renderer.RenderingOptions.CssMediaType = PdfCssMediaType.Print;
renderer.RenderingOptions.HtmlHeader = new HtmlHeaderFooter { HtmlFragment = "..." };
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");
' The original, three-year-old API. Still works. Still unchanged.
Dim renderer As New ChromePdfRenderer()
Dim pdf As PdfDocument = renderer.RenderHtmlAsPdf("<h1>Hello</h1>")
' New rendering options live on an options object, not in the method signature.
renderer = New ChromePdfRenderer()
renderer.RenderingOptions.PaperSize = PdfPaperSize.A4
renderer.RenderingOptions.MarginTop = 20
renderer.RenderingOptions.CssMediaType = PdfCssMediaType.Print
renderer.RenderingOptions.HtmlHeader = New HtmlHeaderFooter With {.HtmlFragment = "..."}
pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>")
Trois observations sur la décision :
- La signature originale de
RenderHtmlAsPdf(string html)est inchangée. Les clients qui ont mis à niveau n'ont pas eu à modifier une ligne de code. - Les nouvelles capacités vivent sur un objet options auquel les consommateurs optent. La méthode n'a pas de nouveaux paramètres requis.
- Les valeurs par défaut sur
RenderingOptionsproduisent un résultat équivalent à l'ancienne API. Le comportement est inchangé pour toute personne qui ne configure rien.
C'est les règles 1, 2 et 4 de la liste de Milan appliquées en même temps. Le produit a évolué. Le contrat non.
La tentation de livrer RenderHtmlAsPdfV2(string html, RenderingOptions options) était réelle. Cela aurait semblé plus net sur la page de référence de l'API. Cela aurait coûté à chaque client une migration. Nous avons choisi autrement.
Lecteurs tolérants
L'autre moitié de l'argument d'ajout de Milan est que les consommateurs ont une responsabilité également. Un client bien élevé devrait ignorer les champs qu'il ne comprend pas.
Dans .NET, System.Text.Json ignore par défaut les propriétés inconnues, ce qui est le bon choix par défaut. Le risque apparaît généralement à deux endroits :
- SDKs générés avec des schémas stricts qui rejettent des champs inattendus
- Tests de contrat qui affirment une égalité JSON exacte
Les deux transforment une garantie déclarée de "nous ignorons les champs inconnus" en déclencheur. Si votre CI échoue au moment où le serveur ajoute une nouvelle propriété optionnelle, vous n'avez pas de compatibilité ascendante. Vous avez un détecteur de régression déguisé en politique de compatibilité.
Le comportement fait partie du contrat
La section de Milan sur DELETE /orders/{id} passant discrètement de la suppression douce à la suppression dure est le traitement écrit le plus clair de ce problème que nous ayons vu.
L'URL est la même. Le corps de la demande est le même. La forme de la réponse est la même. Ce que l'opération fait sur le serveur est différent.
C'est la catégorie la plus dangereuse de changement perturbateur parce que rien dans la différence de schéma ne l'attrape. La spécification OpenAPI est identique. Le client généré compile. Les tests d'intégration passent. Et chaque consommateur qui a construit des outils autour de "les commandes supprimées sont récupérables" détruit silencieusement des données en production.
L'équivalent de la bibliothèque change ce qu'une méthode fait sans changer sa signature. Des exemples que nous avons explicitement évités :
- Une méthode
Save()qui auparavant vidait de façon synchrone et devient discrètement asynchrone en un " fire-and-forget ". - Une méthode OCR qui renvoyait des résultats bruts et qui commence à les post-traiter
- Un lecteur de codes-barres qui lançait une exception sur l'entrée illisible et commence à retourner une chaîne vide
Chacune de ces actions est une rupture de contrat déguisée en amélioration. La réponse correcte est la même que celle de Milan : ajouter une nouvelle méthode ou option, laisser l'ancien comportement inchangé, et déprécier l'ancien chemin seulement lorsque la télémétrie indique que c'est sans risque de le faire.
Reserrer la validation
Cette catégorie affecte éventuellement chaque équipe. Il existe deux variantes de la même erreur :
- Prendre un champ optionnel existant et le rendre requis
- Ajouter un nouveau champ et le marquer comme requis dès le premier jour
Les deux cassent les anciens clients. Le chemin d'accès de point de terminaison ne bouge pas, mais les demandes qui réussissaient auparavant échouent maintenant à l'exécution.
La version bibliothèque de cette erreur est d'ajouter un paramètre de constructeur requis ou de transformer un paramètre optionnel existant en obligatoire. Chaque appelant existant échoue à la compilation, ce qui est préférable à une défaillance à l'exécution, mais cela impose toujours un coût de migration à chaque consommateur.
Pistes plus sûres :
- Accepter les valeurs manquantes pendant une fenêtre de transition et inférer des valeurs par défaut lorsque c'est possible.
- Ajouter une nouvelle surcharge ou un constructeur qui exige une forme d'entrée plus riche.
- Introduire une nouvelle opération ou un constructeur pour le flux de travail plus strict.
La règle sous-jacente est cohérente : tout ce qui est ajouté au contrat doit être optionnel, et tout ce qui était auparavant optionnel doit rester optionnel. Si des exigences réellement plus strictes sont nécessaires, elles appartiennent à une nouvelle opération, pas à un resserrement de l'existant.
Une nouvelle opération est presque toujours moins coûteuse qu'une nouvelle version
C'est le principe le plus important à intérioriser.
Lorsqu'un cas d'utilisation a réellement évolué au-delà de ce qu'un point de terminaison existant supporte proprement, le réflexe commun est de surcharger le point de terminaison avec des drapeaux :
POST /orders?validateOnly=true&includeTaxEstimate=true&reserveInventory=true
Ou, de manière plus perturbatrice, déclarer le changement comme un problème de versionnage et commencer à travailler sur /v2/orders. Les deux sont généralement faux. L'approche plus propre est une nouvelle opération à côté de l'existante :
POST /orders
POST /orders/quote
POST /checkout-sessions
Chaque opération a un contrat propre, des autorisations distinctes, une validation indépendante, et son propre chemin d'évolution. Le point de terminaison d'origine reste simple. Le reste de l'API n'est pas entraîné dans un saut de version majeur.
Dans le contexte d'une bibliothèque, l'équivalent est d'ajouter une nouvelle méthode plutôt que de surcharger une méthode existante avec des paramètres optionnels jusqu'à ce qu'elle devienne illisible. ExtractText() reste le simple extracteur de texte. ExtractTextWithLayout() devient la variante plus riche. ExtractStructuredDocument() devient la plus riche. Trois méthodes avec des contrats clairs sont préférables à une méthode avec huit paramètres optionnels.
Déprécier délibérément
C'est la moitié de la gestion du changement d'API que la plupart des équipes ignorent, et c'est la moitié qui détermine si la stratégie fonctionne.
Une véritable dépréciation n'est pas une note dans un journal des changements. Il implique quatre étapes :
- Marquez le champ ou l'endpoint comme obsolète dans la description OpenAPI (ou avec l'attribut
[Obsolete]dans l'univers .NET). - Signaler l'obsolescence à l'exécution pour que le trafic en direct le mette en évidence.
- Lier à un guide de migration effectif.
- Mesurer l'utilisation avec la télémétrie pour déterminer quand le retrait est sûr.
Pour les API HTTP, le signalement à l'exécution est simple :
Deprecation: true
Sunset: Wed, 31 Dec 2026 23:59:59 GMT
Link: <https://docs.example.com/migrations/orders-total>; rel="deprecation"
Pour les bibliothèques .NET, l'équivalent est un [Obsolete("Use NewMethod instead. Cela sera supprimé dans la v2026.x", DiagnosticId = "IRON001")] attribute paired with a UrlFormat pointant vers une page de migration. L'avertissement du compilateur apparaît dans chaque sortie de construction des consommateurs, l'identifiant de diagnostic permet une suppression délibérée, et le lien offre aux consommateurs un chemin de migration documenté.
L'étape de la télémétrie est non négociable. Sans savoir quels clients dépendent encore de la méthode obsolète, le retrait devient hasardeux. Le résultat est soit un retrait prématuré qui casse les intégrations actives, soit un coût de maintien indéfini qui défait le but de l'obsolescence.
Quand la version est le bon choix
Milan n'est pas contre la versionnage, et nous non plus. Le versionnage est approprié lorsque :
- Les anciennes et nouvelles sémantiques ne peuvent véritablement pas coexister
- Le modèle de ressource a fondamentalement changé
- Les règles de compatibilité imposeraient un contrat que personne ne peut comprendre
Le but n'est pas d'éviter complètement le versionnage. Le but est de l'adopter lorsque la coexistence a échoué, et non parce que c'était la première idée sur la table.
Quand le versionnage est nécessaire, il doit être associé à un véritable processus d'obsolescence. Le travail difficile n'est pas de livrer v2. Le travail difficile est de faire abandonner v1 par les consommateurs.
La règle de décision
Le cadrage de Milan est le bon à appliquer :
- Puis-je ajouter au lieu de remplacer ?
- Les anciens et nouveaux contrats peuvent-ils coexister pendant une fenêtre de migration ?
- Puis-je introduire une nouvelle opération au lieu de modifier une ancienne ?
- Puis-je déclarer obsolète l'ancienne forme avec documentation, en-têtes et télémétrie ?
Si la réponse est oui aux quatre questions, une nouvelle version est probablement inutile. Si la réponse est non, et que les deux mondes ne peuvent véritablement pas coexister, versionner délibérément.
Concevez des contrats pour évoluer. Considérez les consommateurs comme des intégrations à longue durée de vie plutôt que comme le code d'aujourd'hui. Réservez le versionnage aux cas où la compatibilité est vraiment épuisée.
Pour l'article complet, y compris des exemples plus longs, lisez le post original de Milan.
Lors de la sélection d'une bibliothèque .NET sur laquelle dépendre, la question à poser est celle sur laquelle Milan a écrit son post : est-ce que cette bibliothèque ressemblera encore à l'API contre laquelle j'ai intégré dans trois ans ?
C'est la question à laquelle nous travaillons pour répondre à chaque version. Les appels simples de 2020 fonctionnent toujours. De nouvelles capacités les accompagnent, optionnelles et additionnelles. Pas de migrations forcées en version majeure.
Si cette approche de la conception de bibliothèques correspond à vos besoins, commencez un essai gratuit de 30 jours et consultez les références API par vous-même. Le guide de démarrage rapide de cinq minutes explique l'installation, l'activation de la licence, et un premier PDF rendu. Le package lui-même n'est qu'à une commande de tout projet .NET :
Pour les environnements où NuGet n'est pas le chemin préféré, le téléchargement direct fournit le DLL et l'installateur Windows.