Le petit piège de ma migration .NET 11 : double to decimal
Publié le 14/09/2026
Par  Christophe MOMMER

Passer une application de .NET 10 à .NET 11 RC1 s'annonçait comme une formalité : on change le TargetFramework, on monte les paquets EF Core / ASP.NET Core, on restaure, on compile. Build vert, zéro avertissement. Puis on lance les tests unitaires : 30 rouges sur 3071, sans qu'une seule ligne de code métier ait bougé. Ce billet raconte ce cas, parce qu'il a trois qualités qui le rendent instructif : il ne produit aucune erreur de compilation, il touche à des données qui finissent en base, et il casse les tests eux-mêmes, ceux qui devaient justement nous protéger.

Les symptômes

Le premier test rouge concerne l'import d'un catalogue fournisseur au format Excel « .xls ». Le prix unitaire attendu est 79,695 $ (c'est ce qui est écrit dans la cellule) :

Expected tavern.UnitPriceUsd to be 79.695M, but found 79.69499999999999317878973670M (difference of -0.00000000000000682121026330).

Les 28 autres ont un point commun : ce sont tous des [Theory] xUnit dont l'un des paramètres est un decimal, et le rapport d'exécution affiche des valeurs attendues… surprenantes :

FlexibleDecimalParserTests.TryParse_FrenchComma_IsDecimalSeparator(input: "0,99", expected: 0,9899999999999999911182158030) [FAIL]

VatCalculatorTests.Divisor_IsOnePlusRateOverHundred(ratePercent: 20, expected: 1,1999999999999999555910790150) [FAIL]

PriceCalculatorTests.ComputePrice_StandardValues_RoundsUpToNext99(pru: 27,769999999999999573674358544, expected: 49,990000000000001989519660128) [FAIL]

ExchangeRateParserTests.TryParse_ReadsTheSeparatorAsTheDecimalPoint(value: "0,8612", expected: 0,8611999999999999655386773156) [FAIL]

Le test dit expected: 0.99… et xUnit affiche 0,9899999999999999911182158030. Personne n'a écrit ce nombre-là (enfin, pas moi en tout cas 😅)

Le diagnostic en dix lignes

Un double ne sait pas représenter 0,99 : le flottant IEEE 754 le plus proche vaut 0.98999999999999999111821580299874767661094665527343750. Jusqu'à .NET 10, la conversion double à decimal arrondissait à 15 chiffres significatifs, ce qui gommait ce bruit binaire et rendait 0.99m. .NET 11 corrige ce que l'équipe runtime considérait comme une imprécision (dotnet/runtime#72135) : la conversion rend désormais la valeur binaire exacte du double, sur les 28 chiffres qu'un decimal peut porter. La reproduction tient dans un programme « file-based » (dotnet run test.cs), une fois ciblé sur net10.0, une fois sur net11.0 :

 #:property TargetFramework=net11.0
 using System.Globalization;

 double[] xs = { 0.99, 79.695, 1.2, 1234.56, 2.67 };
 foreach (var x in xs)
     Console.WriteLine($"{x.ToString("R", CultureInfo.InvariantCulture),-10} " +
                       $"cast={(decimal)x,-32} Convert={Convert.ToDecimal(x),-32} new={new decimal(x)}");

Les trois chemins de conversion (le cast explicite, Convert.ToDecimal(double) et le constructeur new decimal(double)) changent ensemble. Il n'y a pas de « bon » chemin à préférer.

Pourquoi c'est juste, et pourquoi c'est un bug quand même

Mathématiquement, .NET 11 a raison : 79.69499999999999317878973670 est la valeur du double. Mais ce double n'est pas né d'un calcul, il a été lu : NPOI (cell.NumericCellValue) et ClosedXML (value.GetNumber()) rendent les cellules numériques d'un classeur Excel sous forme de double, parce qu'Excel stocke ses nombres ainsi. L'auteur du classeur a tapé 79.695. Excel le lui affiche 79.695. Il est légitime que l'application le lise 79.695, pas avec 26 chiffres de bruit qui finiraient en base, dans les exports, sur les écrans et dans les calculs de marge.

Autrement dit : quand un double est le véhicule d'un nombre décimal écrit par un humain, la conversion doit restituer ce qui a été écrit, pas ce que le flottant contient.

La correction

Le principe : un double ne se caste plus jamais en decimal. Il passe par un helper pur qui reproduit l'ancien comportement (15 chiffres significatifs) de manière explicite et documentée.

Deux options pour « retrouver ce qui a été écrit » :

  • le format "R" (représentation décimale la plus courte qui redonne le même double)
  • le format "G15" (15 chiffres significatifs)

Sur un nombre tapé par un humain, les deux donnent le même résultat. Ils divergent sur une cellule de formule : le cache d'une formule =0,1+0,2 vaut 0.30000000000000004 en double. "R" rend 0.30000000000000004 ; "G15" rend 0.3, ce qu'Excel affiche, et ce que faisait le runtime avant. Pour une migration, reproduire exactement l'ancien comportement est le choix le plus sûr : ce comportement avait été validé sur de vrais fichiers fournisseurs. Va pour "G15" !

using System.Globalization;

namespace BrickTrack.Core.Parsing;

/// <summary>
/// Conversion d'un <see cref="double"/> lu dans un document (cellule numérique d'un classeur, nombre
/// JSON…) vers le <see cref="decimal"/> que l'auteur du document a ÉCRIT.
///
/// Pourquoi ce n'est pas un simple cast : depuis .NET 11, <c>(decimal)d</c>, <c>Convert.ToDecimal</c>
/// et <c>new decimal(d)</c> rendent la valeur binaire EXACTE du double (dotnet/runtime#72135), et
/// plus l'arrondi à 15 chiffres significatifs des versions précédentes. Le prix 79.695 d'un
/// classeur « .xls » devient 79.69499999999999317878973670 — mathématiquement juste, faux pour le
/// métier : ce nombre n'a jamais existé dans le classeur, et il finirait en base et sur les écrans.
///
/// On garde donc les 15 chiffres significatifs : c'est la précision d'Excel (une cellule de
/// formule dont le cache vaut 0,30000000000000004 s'y AFFICHE 0,3), et c'est exactement ce que
/// le cast faisait avant — comportement validé sur les vrais fichiers fournisseurs.
/// Un double exact en binaire (12.5) revient inchangé.
/// </summary>
public static class DoubleToDecimal
{
    /// <summary>
    /// Même contrat d'échec que le cast qu'elle remplace : NaN, ±∞ et les valeurs au-delà de
    /// <see cref="decimal.MaxValue"/> lèvent <see cref="OverflowException"/>.
    /// </summary>
    public static decimal AsWritten(double value)
    {
        if (!double.IsFinite(value))
        {
            throw new OverflowException($"« {value} » n'a pas d'équivalent decimal.");
        }

        // NumberStyles.Float : « G15 » écrit les très grands/petits nombres en notation exponentielle
        // (1E+20, 1.234E-05), que decimal.Parse doit accepter.
        return decimal.Parse(value.ToString("G15", CultureInfo.InvariantCulture), NumberStyles.Float, CultureInfo.InvariantCulture);
    }
}

 Trois détails qui comptent :

  •  CultureInfo.InvariantCulture des deux côtés. Le texte intermédiaire est un canal privé entre ToString et Parse ; il ne doit dépendre ni de la culture du serveur ni de celle du thread
  •  NumberStyles.Float. "G15" bascule en notation exponentielle pour les très grands ou très petits nombres (1E+20, 1.234E-05) ; sans ce style, decimal.Parse refuserait l'exposant
  •  Le même contrat d'échec que le cast. (decimal)double.NaN levait OverflowException ; le helper aussi. Un appelant qui attrapait cette exception continue de l'attraper

 Et l'appel, dans l'extracteur de documents fournisseurs des deux lecteurs (XLSX via ClosedXML, XLS via NPOI) reçoivent le même traitement :

 // Avant
 private static SupplierGridCell ToCell(XLCellValue value, byte[]? image)
     => value.IsNumber
         ? FromNumber((decimal)value.GetNumber(), image)
         : new SupplierGridCell(Clean(value.ToString()), null, image);

 // …
 CellType.Numeric => FromNumber((decimal)cell.NumericCellValue, image),

 // Après
 private static SupplierGridCell ToCell(XLCellValue value, byte[]? image)
     => value.IsNumber
         ? FromNumber(DoubleToDecimal.AsWritten(value.GetNumber()), image)
         : new SupplierGridCell(Clean(value.ToString()), null, image);

 // …
 CellType.Numeric => FromNumber(DoubleToDecimal.AsWritten(cell.NumericCellValue), image),

 // Les deux lecteurs rendent un double : DoubleToDecimal (et non un cast) pour que 79.695 reste
 // 79.695 — depuis .NET 11 le cast rend la valeur binaire exacte, 79.69499999999999317878973670.
 private static SupplierGridCell FromNumber(decimal value, byte[]? image)
     => new(value.ToString("0.############", CultureInfo.InvariantCulture), value, image);

Les tests de non-régression

En TDD, le test s'écrit d'abord, et on le regarde échouer pour la bonne raison. Le premier jet du helper était donc un stub qui reproduisait l'ancien cast :

 public static class DoubleToDecimal
 {
     // STUB RED : l'ancien cast, pour voir le test échouer pour la bonne raison.
     public static decimal AsWritten(double value) => (decimal)value;
 }

Résultat : six lignes rouges, chacune avec la valeur binaire exacte en face de la valeur attendue (Expected 0.99M, but found 0.9899999999999999911182158030M). C'est bien le bug que l'on veut verrouiller, pas un problème de compilation ou de setup.

Ensuite seulement, l'implémentation. Voici la classe de test complète. Remarquez que les valeurs attendues sont portées par un TheoryData avec des littéraux m, on verra juste après pourquoi c'est indispensable.

using System.Globalization;
using BrickTrack.Core.Parsing;
using FluentAssertions;

namespace BrickTrack.Web.Tests.Services.Parsing;

/// <summary>
/// Spec PURE de la conversion d'un <c>double</c> lu dans un document vers le <c>decimal</c> que
/// son auteur a écrit. Régression de la montée en .NET 11 (2026-09-14) : le runtime rend
/// désormais la valeur binaire EXACTE d'un double (dotnet/runtime#72135), plus l'arrondi à
/// 15 chiffres significatifs. AVANT le fix, le prix 79.695 d'un classeur « .xls » Pantasy
/// arrivait en base comme 79.69499999999999317878973670 — un nombre que le fournisseur n'a
/// jamais écrit.
/// </summary>
public sealed class DoubleToDecimalTests
{
    // Les valeurs attendues sont des littéraux « m » via TheoryData : un double dans [InlineData]
    // subirait exactement la conversion que ce test veut vérifier.
    public static TheoryData<double, decimal> DocumentNumbers => new()
    {
        { 79.695, 79.695m },       // prix unitaire Pantasy (.xls), le cas d'origine
        { 0.99, 0.99m },           // le double le plus proche de 0.99 vaut 0.98999999999999999111…
        { 1.2, 1.2m },             // diviseur de TVA 20 %
        { 1234.56, 1234.56m },     // montant à 4 chiffres, 2 décimales
        { 0.058, 0.058m },         // poids « 58g » converti en kg
        { 12.5, 12.5m },           // EXACT en binaire : doit rester 12.5 (pas de sur-correction)
        { 0, 0m },
        { -27.77, -27.77m },       // le signe traverse tel quel
        { 0.1 + 0.2, 0.3m },       // cellule de FORMULE : Excel affiche 0,3, pas 0,30000000000000004
        { 1e20, 100000000000000000000m }, // notation exponentielle relue sans perte
        { 0.00001234, 0.00001234m },      // « 1.234E-05 » : l'exposant négatif aussi
    };

    [Theory]
    [MemberData(nameof(DocumentNumbers))]
    public void AsWritten_ReturnsTheDecimalTheAuthorWrote(double read, decimal expected)
        => DoubleToDecimal.AsWritten(read).Should().Be(expected);

    [Fact]
    public void AsWritten_KeepsFifteenSignificantDigits_LikeExcelAndThePreviousRuntime()
    {
        // Contrat de fond : 15 chiffres significatifs — la précision d'Excel, et ce que faisait le
        // cast avant .NET 11. Vérifié sur un balayage de prix à 2 décimales (le cas métier), tous non
        // représentables exactement en binaire.
        for (var cents = 1; cents < 10_000; cents += 7)
        {
            var price = cents / 100.0;
            DoubleToDecimal.AsWritten(price)
                .Should().Be(decimal.Parse(price.ToString("G15", CultureInfo.InvariantCulture), CultureInfo.InvariantCulture),
                    "le prix {0} doit revenir tel qu'affiché", price);
        }
    }

    [Theory]
    [InlineData(double.NaN)]
    [InlineData(double.PositiveInfinity)]
    [InlineData(double.NegativeInfinity)]
    [InlineData(1e29)] // au-delà de decimal.MaxValue (≈ 7,9e28)
    public void AsWritten_NonFiniteOrOutOfRange_ThrowsLikeTheCastDid(double value)
    {
        // Même contrat d'échec que « (decimal)value » : un appelant qui attrapait OverflowException
        // continue de l'attraper.
        var act = () => DoubleToDecimal.AsWritten(value);
        act.Should().Throw<OverflowException>();
    }
}

 Ce que chaque groupe verrouille :

  • DocumentNumbers : le cas d'origine (79,695), les valeurs non représentables typiques (0,99, 1,2, 1234,56, 0,058), le signe, et deux garde-fous contre la sur-correction,12.5 est exact en binaire et doit revenir tel quel, 0.1 + 0.2 doit redonner 0.3 (c'est la ligne qui distingue "G15" de "R" : avec "R", elle est rouge). Les deux dernières lignes forcent la notation exponentielle dans les deux sens, pour prouver que NumberStyles.Float fait son travail
  • Le balayage à 15 chiffres : ~1 400 prix à deux décimales, comparés à la référence "G15". C'est le contrat de fond, indépendant de la liste de cas particuliers
  • Le contrat d'échec : NaN, ±∞ et 1e29 (au-delà de decimal.MaxValue) lèvent OverflowException, comme le cast d'avant

 Et bien sûr, le test d'intégration de l'extracteur, celui qui lit un vrai fichier .xls de fournisseur et attend 79.695m, est repassé au vert sans qu'on le touche : c'était notre test rouge « naturel » depuis le début.  Le piège dans les tests eux-mêmes...

 Reste à comprendre les 28 autres tests. Ils ressemblent tous à ceci :

 [Theory]
 [InlineData("0.8612", 0.8612)]
 [InlineData("0,8612", 0.8612)]
 [InlineData("1", 1)]
 [InlineData(" 0.86 ", 0.86)]
 [InlineData("0,006512", 0.006512)]
 public void TryParse_ReadsTheSeparatorAsTheDecimalPoint(string value, decimal expected)
     => ExchangeRateParser.TryParse(value).Should().Be(expected);

 Un attribut C# n'accepte pas de constante decimal : 0.8612 dans [InlineData] est un double. C'est xUnit qui, à l'exécution, le convertit vers le type du paramètre avec la conversion du runtime. Sous .NET 10, expected valait 0.8612m ; sous .NET 11, il vaut 0.8611999999999999655386773156m, et l'égalité stricte de FluentAssertions échoue.

 Le code testé est irréprochable. C'est la donnée du test qui a changé de valeur. Première idée : passer l'attendu en chaîne, [InlineData("0,8612", "0.8612")], et laisser xUnit parser. Ça ne marche pas car xUnit v3 ne convertit pas string vers decimal :

 System.ArgumentException : Object of type 'System.String' cannot be converted to type 'System.Decimal'.

 La bonne réponse est l'idiome xUnit pour les types qu'un attribut ne sait pas porter : TheoryData + littéraux m.

// Littéraux « m » via TheoryData : un double dans [InlineData] serait converti par xUnit en sa
// valeur binaire exacte depuis .NET 11 (0.8612 → 0.86119999999999996553…).
public static TheoryData<string, decimal> Rates => new()
{
    { "0.8612", 0.8612m },
    { "0,8612", 0.8612m },
    { "1", 1m },
    { " 0.86 ", 0.86m },
    { "0,006512", 0.006512m },
};

[Theory]
[MemberData(nameof(Rates))]
public void TryParse_ReadsTheSeparatorAsTheDecimalPoint(string value, decimal expected)
    => ExchangeRateParser.TryParse(value).Should().Be(expected);

 Le corps du test ne change pas, chaque ligne reste visible dans l'explorateur de tests, et la valeur attendue est enfin exactement celle qu'on lit.

 Deux remarques pour finir le tour :

  •  Une théorie du même genre qui passe encore n'est pas forcément à convertir : si ses littéraux sont exacts en binaire (12.5, 0.25, 1299), la conversion exacte est… exacte. Et une future ligne fautive (0.99) échouera bruyamment dès sa première exécution => ce n'est pas un piège silencieux
  • Attention aux tests « symétriques » qui castent des deux côtés : ChangePercent((decimal)proposed, (decimal)reference).Should().Be((decimal)expected) avec des paramètres double. Ils restent verts, mais ils exercent désormais le code avec des valeurs à 28 chiffres que personne ne saisira jamais. Ils méritent le même traitement.

 Checklist pour votre propre migration

 1. Cherchez les conversions explicites dans le code de production : (decimal), (decimal?), Convert.ToDecimal, new decimal, decimal.CreateChecked/Saturating/Truncating. Écartez celles dont l'opérande est un entier (conversion exacte, rien ne change) ; ne gardez que celles dont l'opérande est un double ou un float

 2. Remontez à la source du double. S'il véhicule un nombre écrit par quelqu'un (cellule Excel via NPOI/ClosedXML, JsonElement.GetDouble(), double.Parse d'un texte, résultat d'une API tierce typée en double), passez par un helper à 15 chiffres. S'il est le résultat d'un calcul que vous voulez transporter fidèlement, la conversion exacte est peut-être ce que vous voulez, par contre décidez-le, ne le subissez pas.

 3. Passez vos [InlineData] au crible : tout littéral à virgule destiné à un paramètre decimal (ou casté en decimal dans le corps du test) devient un TheoryData avec un littéral m.

 4. Inscrivez la règle dans les conventions du dépôt, pour que le prochain test ne réintroduise pas le piège.

 5. Lancez toutes les suites, pas seulement les unitaires : la conversion exacte peut se manifester dans un test d'intégration qui lit un vrai fichier, comme ici.

Conclusion

Aucune erreur de compilation, aucun avertissement, un changement documenté comme une correction de précision et pourtant un prix fournisseur à 26 décimales qui serait parti en base sans les tests. La leçon dépasse .NET 11 : un double qui transporte une valeur saisie par un humain n'est pas un nombre, c'est un message, et le convertir demande de restituer ce qui a été dit, pas ce que le messager a dans le ventre.