Date, heure et fuseaux horaires dans les applications métier — des pièges de DateTime au principe de stockage en UTC et à la conception des tests
· Mis à jour le: · Go Komura · C#, .NET, .NET Framework, Windows, Fuseaux horaires, Traitement des dates et heures, Tests, Exploitation, Conseil technique
« On a déplacé le serveur vers une VM cloud, et tous les horaires des bordereaux ont dérivé de neuf heures. » « Seuls les appareils distribués à notre site à l’étranger affichent la date du rapport journalier décalée d’un jour en arrière. » « Il y a des indices que le traitement d’agrégation de nuit s’est exécuté deux fois, un jour donné. » Les demandes liées à la date, à l’heure et aux fuseaux horaires arrivent presque toujours sous cette forme : « au moment où l’environnement a changé ». Pas une seule ligne de code n’avait été touchée.
On croit volontiers que « notre application est réservée à un usage strictement japonais, donc les fuseaux horaires ne nous concernent pas », mais dans les faits, une grande partie de ces consultations survient justement sur ce type d’application censément réservée au marché intérieur. Les VM et conteneurs cloud sont souvent provisionnés avec UTC comme réglage système, et les bibliothèques ou API web de SaaS étrangers renvoient des horodatages en UTC ou avec un décalage explicite. L’application elle-même peut croire qu’elle vit entièrement à l’heure du Japon, mais dès qu’on franchit une frontière, on est déjà dans un monde en UTC. Tant que les valeurs circulent sans jamais préciser « à quel référentiel ce moment se rapporte-t-il », l’écart reste invisible jusqu’au jour d’un déplacement de serveur ou d’une migration vers le cloud, où il se manifeste d’un coup sous la forme d’un décalage de neuf heures.
Cet article part du principe d’une application métier .NET et parcourt la propriété Kind de DateTime et ses pièges de conversion implicite, les cas où préférer DateTimeOffset, le principe « stocker et transmettre en UTC ou avec un décalage explicite, ne convertir en heure locale qu’à l’affichage », TimeZoneInfo et l’heure d’été, la frontière avec la base de données, et enfin la conception des tests avec TimeProvider. Nous terminerons en condensant les points que nous vérifions à chaque revue de conception dans une liste de contrôle.
1. L’essentiel d’abord
- La cause profonde de la quasi-totalité des incidents de date/heure est unique : une valeur ne portant aucune information sur « à quel référentiel ce moment se rapporte-t-il » franchit une frontière (base de données, API, fichier). Le symptôme n’apparaît pas quand le code change, mais quand l’environnement change.
DateTimeporte un attribut appelé Kind (Utc / Local / Unspecified), dont la valeur par défaut est Unspecified.ToLocalTimetraite Unspecified comme de l’UTC, tandis queToUniversalTimele traite comme de l’heure locale — cette interprétation implicite asymétrique est la source classique du « décalage de neuf heures ».1- Pour du nouveau code, le choix par défaut doit être
DateTimeOffset. La documentation officielle indique d’ailleurs explicitement qu’il faut « l’envisager comme le type date/heure par défaut du développement d’applications ».2 - Le principe est « stocker et transmettre en UTC ou avec un décalage explicite ; ne convertir en heure locale qu’à l’affichage ». Lorsqu’on transforme une valeur en chaîne, il faut l’écrire avec le format d’aller-retour “o”, conforme à ISO 8601, et la relire avec
DateTimeStyles.RoundtripKind.3 - La conversion de fuseau horaire se fait avec
TimeZoneInfo. Depuis .NET 6, les ID IANA (Asia/Tokyo) et les ID Windows (Tokyo Standard Time) sont tous deux utilisables, et des API de conversion entre les deux existent aussi.4 La résolution des ID IANA sous Windows dépend toutefois de la bibliothèque ICU, et échoue sur d’anciennes versions de Windows Server ou en mode de globalisation invariant (chapitre 4).5 - Même si le Japon n’a pas d’heure d’été, dès qu’un appareil d’un site à l’étranger, une intégration avec un SaaS étranger, ou un serveur configuré en UTC entre en jeu, on tombe directement sur les « instants inexistants » et « instants ambigus » du DST.6
- Cessez d’écrire
DateTime.Nowen dur. InjectezTimeProvider(norme depuis .NET 8 ; utilisez Microsoft.Bcl.TimeProvider sur les cibles plus anciennes) pour que les tests puissent faire bouger l’horloge librement.7
2. Le Kind de DateTime — la signification des trois valeurs et l’incident de la conversion implicite
En plus de sa valeur de date/heure (les Ticks), DateTime porte exactement un attribut supplémentaire appelé Kind. Il a trois valeurs possibles, et la valeur par défaut est Unspecified.1
| Kind | Signification | Source typique |
|---|---|---|
| Utc | Heure mesurée par rapport à UTC | DateTime.UtcNow, le résultat de ToUniversalTime() |
| Local | Heure mesurée par rapport au fuseau horaire local de la machine d’exécution | DateTime.Now, le résultat de ToLocalTime() |
| Unspecified | Référentiel inconnu (valeur par défaut) | new DateTime(...), DateTime.Parse (dans la plupart des cas), les valeurs lues depuis une base de données |
Ce qui compte, c’est qu’un code écrit normalement finit par produire des valeurs Unspecified un peu partout. Les valeurs construites avec un constructeur, celles issues d’un parsing de chaîne, et celles lues depuis une base de données sont toutes, par défaut, « de référentiel inconnu ». Ce n’est pas un problème en soi. Le problème, c’est que les méthodes de conversion supposent silencieusement un référentiel pour ces valeurs Unspecified.1
| Appel | Kind=Utc | Kind=Local | Kind=Unspecified |
|---|---|---|---|
ToUniversalTime() |
renvoyée telle quelle | convertie en UTC | traitée comme de l’heure locale et convertie en UTC |
ToLocalTime() |
convertie en heure locale | renvoyée telle quelle | traitée comme de l’UTC et convertie en heure locale |
Le point clé est qu’une même valeur Unspecified peut être interprétée comme « locale » ou comme « UTC » selon la méthode appelée. En code, cela donne ceci :
// Valeur lue depuis la base de données. Sur de nombreux chemins, elle finit avec Kind = Unspecified
var fromDb = new DateTime(2026, 7, 3, 9, 0, 0);
// Si la machine d'exécution est réglée sur JST (UTC+9) :
Console.WriteLine(fromDb.ToLocalTime()); // 18:00 -- traité comme UTC, donc +9 heures
Console.WriteLine(fromDb.ToUniversalTime()); // 00:00 -- traité comme heure locale, donc -9 heures
C’est exactement sous cette forme que l’incident survient. Si une valeur stockée en base à l’heure du Japon, 09:00 (Unspecified), passe par un ToLocalTime() « juste au cas où » avant l’affichage, elle est traitée comme de l’UTC et devient 18:00. À l’inverse, si une conversion censée « normaliser en UTC avant l’enregistrement » se retrouve dupliquée quelque part sur le chemin, neuf heures sont soustraites deux fois. Ce qui rend les choses encore plus délicates, c’est que ce comportement dépend du fuseau horaire configuré sur la machine d’exécution. Sur un poste de développement (JST), le décalage est de neuf heures ; sur un serveur configuré en UTC, il est de zéro heure — ce qui produit le classique « ça ne se reproduit pas chez moi ». Le « décalage de neuf heures après un déplacement de serveur » mentionné en introduction correspond, le plus souvent, exactement à ce schéma.
2.1 La différence avec DateTimeOffset, et quand utiliser lequel
DateTimeOffset porte toujours un décalage UTC (par exemple +09:00) en plus de la date/heure, si bien que la valeur seule identifie de façon unique un instant précis n’importe où dans le monde. Pour les usages qui consistent à « enregistrer un instant » — entrées de journal, horodatages de transactions, enregistrements d’événements système —, la documentation officielle indique explicitement qu’il faut envisager DateTimeOffset comme le type date/heure par défaut.2 Comme il ne reste aucune place pour l’interprétation implicite de Kind, la plupart des incidents traités dans cet article deviennent structurellement impossibles.
Ce n’est toutefois pas une solution miracle. Gardez à l’esprit que ce que porte DateTimeOffset est un décalage, pas un fuseau horaire. +09:00 ne dit pas si l’on est au Japon ou en Corée, et ne porte aucune règle d’ajustement pour l’heure d’été.2 Si vous devez reproduire « ce qu’afficherait l’horloge murale d’un lieu donné », il faut toujours le combiner avec TimeZoneInfo, présenté plus loin. Voici un tableau résumant quand utiliser quoi.
| Type | Information portée | Adapté pour | Remarques |
|---|---|---|---|
DateTimeOffset |
Date/heure + décalage UTC | Enregistrer le moment où quelque chose s’est produit, journaux, frontières d’API | Choix par défaut pour du nouveau code2 |
DateTime (exploité en Kind=Utc) |
Date/heure uniquement | Calculs internes, compatibilité avec l’existant | Vous gérez Kind entièrement vous-même |
DateOnly / TimeOnly |
Date uniquement / heure uniquement | Dates métier, heures d’ouverture, heures de clôture | Non disponible sur .NET Framework2 |
TimeSpan |
Une durée | Temps écoulé, différence entre deux instants | |
TimeZoneInfo |
Une définition de fuseau horaire (règles d’ajustement incluses) | Conversion, détermination du DST | Chapitre 4 |
Réécrire tous les DateTime existants en DateTimeOffset n’est souvent pas réaliste, si bien que le compromis que nous adoptons couramment sur nos missions de modernisation est le suivant : « unifier l’interne et le stockage sur DateTime avec Kind=Utc, et utiliser DateTimeOffset ou une chaîne au format “o” aux frontières (API, sérialisation) ». Dans les deux cas, le principe du chapitre suivant reste le socle sur lequel tout repose.
3. Le principe — stocker et transmettre en UTC ou avec un décalage ; ne convertir en heure locale qu’à l’affichage
Le principe de conception pour le traitement des dates et heures tient en trois lignes.
- Capturez le moment où quelque chose s’est produit avec
DateTime.UtcNowouDateTimeOffset.UtcNow, et transportez-le en UTC (ou avec un décalage) tout au long du chemin. - Lorsque vous franchissez une frontière (base de données, API, fichier, registre), explicitez le format et le référentiel dans les spécifications.
- Convertissez en heure locale une seule fois — juste avant l’affichage à l’écran ou l’impression d’un bordereau.
Une conception qui stocke l’heure locale fait dépendre la « signification » d’une valeur d’un état externe : le réglage du système d’exploitation du serveur. Tant que ce serveur tourne sur site, configuré pour le Japon, le problème reste invisible — mais une migration vers une VM cloud, une configuration de reprise après sinistre dans une région étrangère, ou un écart de configuration entre le développement et la production changera cette signification, et n’importe lequel de ces cas suffit. Avec un stockage en UTC, la signification d’une valeur est la même dans tous les environnements. La conversion pour l’affichage se fait côté consommateur (selon le réglage de l’utilisateur, ou un fuseau horaire de site stocké dans le référentiel utilisateurs), si bien que consulter les mêmes données depuis Tokyo ou depuis Berlin affiche à chaque fois l’heure d’horloge murale correcte pour ce lieu.
3.1 Utilisez le format ISO 8601 / « o » pour les représentations en chaîne aux frontières
Lorsqu’une frontière est franchie sous forme de chaîne (JSON, CSV, journaux, fichiers de configuration), utilisez le format d’aller-retour “o”, conforme à ISO 8601. « o » préserve dans la chaîne le Kind de DateTime et le décalage de DateTimeOffset, et un parsing avec DateTimeStyles.RoundtripKind restaure la valeur d’origine.3
using System.Globalization;
// Écriture : 2026-07-03T13:30:00.0000000+09:00
DateTimeOffset now = DateTimeOffset.Now;
string s = now.ToString("o", CultureInfo.InvariantCulture);
// Relecture : restaurée avec le décalage préservé
var restored = DateTimeOffset.Parse(
s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
Associez toujours cela à CultureInfo.InvariantCulture. Laisser la culture à sa valeur par défaut peut changer la notation de l’année sur un appareil fonctionnant sous une culture non grégorienne, comme le calendrier de l’ère impériale japonaise. Ce qu’il ne faut jamais faire, c’est stocker ou transmettre des données avec un format qui ne porte aucune information de référentiel, comme "yyyy/MM/dd HH:mm". Celui qui reçoit cette chaîne n’a d’autre choix que de deviner « par rapport à quoi cette valeur est-elle mesurée », et les suppositions cessent d’être justes dès que l’environnement change. Notez aussi que la représentation par défaut des dates/heures de System.Text.Json est elle-même basée sur ISO 8601 : à une frontière JSON, s’en tenir simplement à ce comportement par défaut est donc le choix le plus sûr.
L’idée que « les données franchissant une frontière doivent avoir leur format explicité dans les spécifications » ne se limite pas aux dates et heures. Nous tenons exactement le même raisonnement à propos de l’encodage des caractères et des fins de ligne dans « Encodage des caractères et fins de ligne sous Windows ». Les implicites aux frontières se cassent toujours de la même façon, quel que soit le type de données concerné.
3.2 Distinguer l’horodatage de la « date métier »
Voici une distinction supplémentaire, nécessaire pour expliquer l’exemple donné en introduction où « seule la date du rapport journalier du site à l’étranger revient d’un jour en arrière ». Un horodatage (un instant unique dans le monde entier) et une date métier (une étiquette comme « le rapport journalier du 3 juillet ») sont deux choses différentes. Si vous conservez une date métier sous la forme d’« un DateTime à minuit » et que vous la faites passer par une conversion en UTC, minuit le 3 juillet en UTC+9 devient 15h00 le 2 juillet en UTC — et à l’instant où vous n’extrayez que la partie date, elle bascule d’un jour en arrière. C’est le mécanisme classique derrière ce bogue.
Conservez la date métier sous forme de DateOnly (ou, sur .NET Framework, sous forme d’une chaîne au format yyyy-MM-dd ou d’une simple valeur année/mois/jour), et décidez, dans les spécifications, dans quel fuseau horaire la date est découpée. Tant que les spécifications précisent quelque chose comme « la date du rapport journalier se base sur l’heure locale du site » ou « la clôture se base sur le JST du siège », l’implémentation se résume à convertir UtcNow vers le fuseau horaire concerné, puis à découper la date. Si les spécifications ne le disent pas, c’est le réglage de la machine de celui qui implémente qui fait office de spécification, par défaut.
4. La conversion de fuseau horaire — TimeZoneInfo et les systèmes d’identifiants
La conversion de fuseau horaire se fait avec ConvertTimeFromUtc / ConvertTimeToUtc / ConvertTime de TimeZoneInfo. Ce à quoi il faut prêter attention, c’est que ces API vérifient la cohérence entre le Kind du DateTime et le fuseau horaire source de la conversion. Par exemple, passer une valeur Kind=Utc en précisant « la source est Tokyo » lève une ArgumentException.8 Autrement dit, un code laxiste dans la gestion de Kind ne peut même pas appeler correctement les API de conversion de fuseau horaire. Cela renvoie directement au chapitre 2.
4.1 Les identifiants de fuseau horaire Windows et les identifiants IANA
Il existe deux systèmes d’identifiants pour désigner un fuseau horaire.
| ID Windows | ID IANA | |
|---|---|---|
| Exemple (Japon) | Tokyo Standard Time |
Asia/Tokyo |
| Exemple (Allemagne) | W. Europe Standard Time |
Europe/Berlin |
| Géré par | Windows (registre) | La base de données tz de l’IANA |
| Utilisé principalement par | Les API Windows, .NET (Framework) | Linux, les SaaS étrangers, les API web, les autres langages |
À l’époque du .NET Framework, seuls les ID Windows étaient utilisables, ce qui provoquait le problème récurrent de table de conversion : « l’API web envoie Asia/Tokyo, mais je ne peux pas le passer à FindSystemTimeZoneById ». Depuis .NET 6, TimeZoneInfo.FindSystemTimeZoneById accepte les deux systèmes d’ID, et si l’ID fourni n’est pas enregistré localement, il le convertit et le résout automatiquement. TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId ont également été ajoutées, pour les cas où l’on souhaite convertir explicitement.4
// .NET 6+ : un ID IANA passe tel quel (l'ID Windows "Tokyo Standard Time" donne le même résultat)
var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
DateTime utc = DateTime.UtcNow;
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, tokyo)); // Heure d'horloge murale de Tokyo
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, berlin)); // Heure d'horloge murale de Berlin
// Conversion entre systèmes d'ID (.NET 6+)
if (TimeZoneInfo.TryConvertWindowsIdToIanaId("Tokyo Standard Time", out var ianaId))
Console.WriteLine(ianaId); // Asia/Tokyo
En pratique, nous recommandons de standardiser sur les ID IANA les identifiants de fuseau horaire conservés dans votre référentiel de sites ou vos fichiers de configuration. Les ID IANA sont ce qui est universellement compris à travers les SaaS étrangers, les conteneurs Linux et les autres langages, et sur .NET 6 et versions ultérieures, ils peuvent en général être acceptés tels quels. Si une partie de votre système tourne encore sur .NET Framework, ne convertissez vers un ID Windows qu’à cette frontière précise. Notez aussi que FindSystemTimeZoneById lève une TimeZoneNotFoundException si l’ID est introuvable : rejetez donc l’erreur au plus tôt, que ce soit sur l’écran d’enregistrement des ID de site ou lors de la validation au démarrage.
Il y a une précondition importante à connaître. La résolution des ID IANA sous Windows dépend de la bibliothèque ICU. Les applications fonctionnant en mode NLS ou en mode de globalisation invariant (InvariantGlobalization=true) ne peuvent pas résoudre les ID IANA, et TryConvertIanaIdToWindowsId échoue également.5 La même contrainte s’applique aussi lorsqu’on exécute .NET 6 sur un OS ancien dont ICU n’est pas intégré au système (Windows Server 2019, Windows 10 build 1809 et antérieures, etc.), sauf à embarquer une ICU locale à l’application (ceci a été changé à partir de .NET 7, si bien qu’ICU est aussi utilisée sur ces versions d’OS9). Autrement dit, il arrive réellement que « Asia/Tokyo se résolve sans problème sur mon poste de développement (Windows 11), mais lève une TimeZoneNotFoundException » précisément sur un Windows Server 2019 chez le client. Si vous adoptez les ID IANA pour votre référentiel, associez cette décision à trois précautions : (1) vérifiez que la résolution IANA fonctionne réellement sur l’OS et la version de .NET que vous ciblez, (2) n’activez pas à la légère InvariantGlobalization dans un conteneur juste pour réduire la taille de l’image, et (3) prévoyez en assurance, dans la validation au démarrage, un repli qui retombe sur un ID Windows via TryConvertIanaIdToWindowsId et retente.
5. L’heure d’été (DST) — des situations qu’on rencontre même dans une application réservée au Japon
Comme le Japon n’a actuellement pas d’heure d’été, il est tentant de supposer que « le DST ne nous concerne pas ». Mais vous le rencontrerez à coup sûr si l’un des cas suivants s’applique :
- L’application tourne sur des appareils utilisés dans des sites à l’étranger ou par des collaborateurs en déplacement à l’étranger (le fuseau horaire local de l’appareil observe le DST)
- Vous vous interfacez avec un SaaS ou une API web étrangers et recevez des horodatages ou des plannings basés sur l’heure locale
- Des traitements d’agrégation ou par lot s’exécutent sur des serveurs ou VM situés dans une région étrangère
- Vous avez une clôture basée sur l’heure locale d’un site à l’étranger (par exemple « chaque site clôture à minuit heure locale »)
Dans les fuseaux horaires qui observent le DST, le jour du changement fait apparaître deux types d’instants anormaux. Les instants inexistants (au printemps, la plage sautée au moment où l’horloge avance — en Allemagne, 02:00-03:00 fin mars) et les instants ambigus (en automne, la plage qui apparaît deux fois parce que l’horloge recule). En .NET, on peut les détecter avec TimeZoneInfo.IsInvalidTime / IsAmbiguousTime.6 Et les API de conversion telles que ConvertTimeToUtc lèvent une ArgumentException lorsqu’on leur passe un instant inexistant, et interprètent un instant ambigu comme de l’heure normale.8
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
// Le 29/03/2026 est le jour de début du DST en Allemagne. L'heure locale 02:00-03:00 n'existe pas
var t = new DateTime(2026, 3, 29, 2, 30, 0); // Kind = Unspecified
Console.WriteLine(berlin.IsInvalidTime(t)); // True
// TimeZoneInfo.ConvertTimeToUtc(t, berlin) lève une ArgumentException
S’il existe un chemin d’entrée qui « reçoit une chaîne d’heure locale et la convertit en UTC avant l’enregistrement », cette exception reste tapie sous la forme d’un bogue qui ne survient qu’un seul jour par an. Vérifier IsInvalidTime au stade de la validation de saisie, et préciser explicitement dans les spécifications qu’un instant ambigu doit être « traité comme de l’heure normale », est un compromis réaliste.
5.1 L’exécution planifiée et le DST — le problème du « s’exécute deux fois / ne s’exécute pas »
L’exécution planifiée est un autre endroit classique où l’on se fait avoir. Une tâche qui s’exécute chaque jour à 02:30 heure locale n’a tout simplement pas cette heure disponible le jour où le DST commence, et l’a deux fois le jour où il se termine. Selon la façon dont le planificateur est implémenté, le comportement varie — « sautée », « déclenchée deux fois », « déclenchée avec un décalage d’une heure » —, ce qui signifie qu’un traitement d’agrégation écrit en supposant « s’exécute exactement une fois par jour » finit par compter en double ou par manquer des données. La parade combine trois mesures :
- Basez la planification sur UTC (ou un fuseau horaire sans DST). Pour les traitements par lot où se déclencher à une heure locale précise n’est pas réellement une exigence, cela seul fait disparaître le problème.
- Rendez le traitement idempotent. En ajoutant un marqueur « déjà exécuté » — passer si l’agrégation pour la date visée existe déjà —, un déclenchement en double cesse d’être dommageable.
- Indexez l’agrégation par date métier (section 3.2). Ne recalculez pas la date à partir de l’heure de déclenchement.
Nous détaillons la conception de l’exécution planifiée avec le Planificateur de tâches (prévenir les lancements en double, isoler les échecs) dans « Le Planificateur de tâches n’exécute pas la tâche, ou se termine avec 0x1 », et la conception consistant à maintenir un minuteur au sein d’un service résident dans « Créer et exploiter un service Windows ». Quelle que soit l’approche retenue, la relation entre le DST et la planification doit être consignée dans les spécifications de la même manière.
6. La frontière avec la base de données — SQL Server / SQLite / ORM
De toutes les frontières, la base de données est celle où surviennent le plus d’incidents. La plupart des types date/heure de bases de données ne préservent pas l’information « par rapport à quel référentiel cela est-il mesuré », si bien que le Kind et le décalage sont perdus dès l’instant où une valeur est enregistrée.
6.1 Les types date/heure de SQL Server
| Type | Plage / précision | Information de référentiel | Recommandation pour un nouvel usage |
|---|---|---|---|
datetime |
À partir de 1753, précision d’environ 1/300 seconde | Aucune | À éviter (la documentation officielle indique explicitement de ne pas l’utiliser pour de nouveaux développements)10 |
datetime2 |
À partir de l’année 0001, jusqu’à 100 nanosecondes de précision | Aucune | Le choix par défaut pour une colonne stockée en UTC10 |
datetimeoffset |
Équivalent à datetime2 + décalage |
Préserve le décalage | Pour les colonnes où reproduire l’heure locale est une exigence10 |
datetime est un type historique à l’arrondi grossier et à la plage étroite, et la documentation officielle indique explicitement que « pour les nouveaux travaux, il faut éviter datetime et utiliser datetime2 / datetimeoffset à la place ».10 Il n’est pas nécessaire de forcer la migration des colonnes datetime d’un schéma existant, mais il n’y a aucune raison de le choisir pour une nouvelle table non plus.
Le choix entre stocker l’UTC dans datetime2 ou utiliser datetimeoffset se décide selon « faut-il pouvoir reproduire ultérieurement le décalage au moment de la saisie ? ». Si des exigences d’audit ou de conformité imposent de conserver « quelle heure il était en heure locale pour l’utilisateur », utilisez datetimeoffset ; s’il suffit de pouvoir identifier l’instant, l’UTC dans datetime2 est suffisant. Notez que, tout comme pour DateTimeOffset, ce que porte datetimeoffset n’est que le décalage, pas le fuseau horaire (les règles d’ajustement) lui-même. S’il vous faut aussi la zone, conservez l’ID IANA dans une colonne séparée.
6.2 SQLite n’a pas de type date/heure
SQLite n’a aucun type de stockage pour les dates et heures, et Microsoft.Data.Sqlite stocke DateTime / DateTimeOffset en TEXT.11 Un format TEXT de style ISO 8601 fait que l’ordre de tri des chaînes équivaut à l’ordre chronologique, « à condition que le format et le fuseau horaire soient cohérents » — mais inversement, dès qu’UTC et l’heure locale se mélangent dans une même colonne, le tri comme les recherches par plage se cassent silencieusement. Si vous utilisez SQLite, la seule option réaliste consiste à fixer, comme convention applicative, « cette colonne est en UTC, voici son format ». Nous avons détaillé les aspects pratiques de SQLite — connexions et transactions comprises — dans « Utiliser SQLite dans une application métier en C# ».
6.3 Attention avec EF Core / Dapper — Kind disparaît à la lecture
Lire un DateTime depuis un type qui ne porte pas d’information de référentiel (datetime2, TEXT de SQLite, etc.) produit naturellement un Kind à Unspecified. C’est exactement ainsi que naît l’incident où « on avait standardisé le stockage en UTC, mais quelque part une valeur relue passe par ToUniversalTime(), produisant une double conversion ». La solution consiste à restaurer Kind à la frontière. Avec EF Core, vous pouvez le déclarer une fois pour toutes, en bloc, via un value converter.
// EF Core : déclarer une fois pour toutes au niveau du modèle que « cette colonne est en UTC »
modelBuilder.Entity<Order>()
.Property(o => o.CreatedAtUtc)
.HasConversion(
// Écriture : normalise en UTC à la frontière toute valeur qui n'est pas déjà en UTC
// (y compris une valeur DateTime.Now qui se serait glissée là). Notez qu'Unspecified
// est traité comme une heure locale lors de la conversion
v => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(),
// Lecture : restaure Kind
v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
Considérez la normalisation côté écriture comme un filet de sécurité de dernier recours, rien de plus. Comme la conversion d’une valeur Unspecified dépend du fuseau horaire configuré sur la machine d’exécution, il faut malgré tout trouver et corriger tout code qui fait passer DateTime.Now ou des valeurs Unspecified directement dans le chemin de stockage. Considérez cela comme une défense à deux niveaux : le filet de sécurité, plus la convention. Avec Dapper ou de l’ADO.NET brut, regroupez l’appel à DateTime.SpecifyKind juste après le mapping dans une seule couche de conversion. Une convention de nommage aide aussi : graver le référentiel dans le nom de la colonne et de la propriété (CreatedAtUtc, updated_at_utc) augmente considérablement les chances qu’un relecteur remarque « appeler ToUniversalTime sur cette valeur semble suspect », car les noms sont lus bien plus souvent que la documentation.
7. Synchronisation de l’horloge et tests — w32time et TimeProvider
7.1 Remettez en question l’hypothèse que l’horloge de la machine est juste
Tout ce qui précède supposait que « l’horloge de la machine elle-même est correcte » — mais ce qui maintient cette horloge à l’heure, c’est le service Windows Time (w32time). w32time se synchronise via NTP avec une source de temps réseau, et dans un environnement Active Directory, il se synchronise le long de la hiérarchie du domaine. C’est le socle de tout ce qui est sensible à la dérive de l’horloge, l’authentification Kerberos y compris.12
Cela a deux implications pour la conception applicative. Premièrement, ne traitez pas l’horloge du PC client comme faisant autorité pour la logique métier. Un appareil dont la synchronisation s’est arrêtée peut facilement dériver de plusieurs minutes ; l’ordre des événements et les décisions d’heure de clôture doivent donc se baser sur l’heure côté serveur, l’heure côté client n’étant qu’une information indicative. Deuxièmement, lorsqu’on vous rapporte que « l’heure semble fausse », vérifiez l’état de synchronisation de l’appareil avec w32tm /query /status avant même de commencer à investiguer l’application. Cela prend une minute et permet d’écarter toute une catégorie de causes avant de se plonger dans une chasse au bogue applicatif.
7.2 Cessez d’écrire DateTime.Now en dur — TimeProvider
Le plus gros obstacle aux tests du traitement des dates et heures est DateTime.Now écrit en dur un peu partout dans le code. Si vous voulez tester « la détection de la clôture de fin de mois », « la remise à zéro de séquences au passage d’une année », ou « la planification autour d’un jour de changement d’heure d’été », mais que vous ne pouvez pas figer l’heure courante, vous êtes bloqué jusqu’à ce que ce jour précis arrive réellement.
.NET 8 a introduit l’abstraction temporelle standard TimeProvider. Elle permet de substituer GetUtcNow() / GetLocalNow() / LocalTimeZone / la création de minuteurs à travers une seule abstraction. Le même type est aussi disponible sur .NET Framework 4.6.2 et versions ultérieures, ainsi que sur .NET Standard 2.0, via le package NuGet Microsoft.Bcl.TimeProvider, ce qui permet de l’introduire même dans des bases de code anciennes. Une implémentation de test, FakeTimeProvider, est fournie par le package Microsoft.Extensions.TimeProvider.Testing.7
public sealed class DailyReportService
{
private readonly TimeProvider _clock;
private readonly TimeZoneInfo _siteTimeZone;
public DailyReportService(TimeProvider clock, TimeZoneInfo siteTimeZone)
{
_clock = clock;
_siteTimeZone = siteTimeZone;
}
// Implémentation qui explicite que la date métier est « découpée selon le fuseau horaire du site » (section 3.2)
public string GetReportDateKey()
{
var localNow = TimeZoneInfo.ConvertTime(_clock.GetUtcNow(), _siteTimeZone);
return localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
}
En production, on passe TimeProvider.System ; dans les tests, on utilise FakeTimeProvider pour figer et avancer l’heure librement.
[Fact]
public void LaDateMetierBasculeCorrectementAuChangementDAnnee()
{
// Démarre figée à 23h30 JST la veille du Nouvel An
var clock = new FakeTimeProvider(
new DateTimeOffset(2026, 12, 31, 23, 30, 0, TimeSpan.FromHours(9)));
var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
var svc = new DailyReportService(clock, tokyo);
Assert.Equal("2026-12-31", svc.GetReportDateKey());
clock.Advance(TimeSpan.FromHours(1)); // Reproduit le changement d'année instantanément
Assert.Equal("2027-01-01", svc.GetReportDateKey());
}
FakeTimeProvider peut aussi remplacer le fuseau horaire local (SetLocalTimeZone) en plus de figer et d’avancer manuellement l’heure, ce qui permet de reproduire sur une machine CI configurée pour le Japon un bogue tel que « la date revient d’un jour en arrière sur un appareil configuré pour l’Allemagne ». Si vous êtes sur un projet .NET Framework ancien où même ajouter un package NuGet est difficile, une interface IClock maison, ne comportant que DateTimeOffset UtcNow { get; }, produit le même effet. Ce qui compte n’est pas la sophistication de l’abstraction, mais le fait de traiter « l’heure courante » comme une dépendance injectable.
D’après notre expérience, ces cinq instants sont le strict minimum à couvrir avec des cas de test : le passage du Nouvel An (changement d’année), la fin de mois (le 31, le 30, et février), le 29 février d’une année bissextile, les jours de changement d’heure d’été du fuseau horaire cible (printemps et automne), et les alentours de minuit (la frontière de la date métier). Chacun de ces cas est un terrain de chasse classique pour les bogues qui ne se déclenchent « que ce jour-là », et avec FakeTimeProvider, on peut tous les tester en quelques millisecondes.
8. Liste de contrôle et tableau de décision
Le traitement des dates et heures est, tout comme les UUID, une brique d’infrastructure fondamentale qu’on a tendance à laisser de côté avec un « bah, ça a l’air de marcher » (une dynamique très proche de celle décrite dans « Les UUID ne se collisionnent-ils vraiment jamais ? »). Fixer les points examinés lors de la conception initiale et de la revue de code réduit fortement la part de jugement individuel.
Liste de contrôle pour une nouvelle conception :
- La capture de l’instant courant est-elle standardisée sur
DateTimeOffset.UtcNow/TimeProvider.GetUtcNow()? - Le référentiel de stockage/transmission (UTC ou avec décalage) et le format (“o” / ISO 8601) sont-ils explicitement documentés dans les spécifications ?
- Les types de colonnes de base de données et leur référentiel ont-ils été décidés (pour SQL Server,
datetime2(UTC) oudatetimeoffset; graverUtcdans le nom de la colonne) ? - Une distinction est-elle faite entre horodatages et dates métier, et le fuseau horaire utilisé pour découper la date a-t-il été décidé ?
- Un schéma de gestion des ID de fuseau horaire (ID IANA recommandés) et la gestion des erreurs pour un ID invalide ont-ils été décidés ?
- Une politique de DST pour l’exécution planifiée (planification basée sur UTC, plus idempotence) a-t-elle été décidée ?
TimeProvider/IClockest-il injectable, et existe-t-il des cas de test pour les frontières temporelles ?
Code à repérer par grep lors d’une revue :
| Si vous trouvez ce code, soyez suspicieux | Ce qui peut arriver | Comment corriger |
|---|---|---|
DateTime.Now |
Dérive après un déplacement de serveur. Non testable | UtcNow + conversion seulement à l’affichage. Injecter TimeProvider |
ToLocalTime() / ToUniversalTime() |
Interprétation implicite d’Unspecified (chapitre 2) | Fixer Kind à la frontière ; ne convertir que juste avant l’affichage |
DateTime.Parse(s) (sans styles précisés) |
Dépend de la culture/du fuseau horaire de l’environnement d’exécution | ParseExact + InvariantCulture + RoundtripKind |
Stockage/transmission via ToString("yyyy/MM/dd HH:mm") |
L’information de référentiel est perdue | Format “o” + InvariantCulture |
Une valeur new DateTime(...) utilisée directement dans des comparaisons ou du stockage |
Des valeurs Unspecified s’infiltrent | Appliquer SpecifyKind, ou passer à DateTimeOffset |
Une nouvelle colonne datetime dans SQL Server |
Problèmes de précision, de plage et de pérennité | datetime2 / datetimeoffset10 |
La majeure partie de cette liste de contrôle peut être décidée dès le premier jour d’un nouveau développement. À l’inverse, la corriger une fois le système en production se transforme en un travail d’archéologie — déduire, pour des données déjà enregistrées, quelle période a été stockée avec quel référentiel, puis migrer — ce qui change le coût d’un ordre de grandeur.
9. Conclusion
Les incidents de date et de fuseau horaire présentent des symptômes disparates — « décalage de neuf heures », « la date revient d’un jour en arrière », « le traitement par lot s’est exécuté deux fois » — mais la cause est systématiquement la même : une valeur sans information de référentiel qui franchit une frontière. La solution l’est aussi. Capturez en UTC ; stockez et transmettez en UTC ou avec un décalage, avec ISO 8601 (“o”) ; ne convertissez en heure locale qu’à l’affichage. À chaque frontière, explicitez le format et le référentiel dans les spécifications, et gravez le référentiel dans les noms de colonnes de base de données. Gérez les fuseaux horaires avec TimeZoneInfo et les ID IANA, et absorbez les instants inexistants et ambigus du DST par la validation des entrées et une exécution planifiée idempotente. Enfin, injectez TimeProvider pour pouvoir reproduire en test aussi bien le passage d’une année que les jours de changement d’heure d’été. Faites tout cela, et vous passerez du statut de celui qu’on appelle après le changement d’environnement à celui qui signale le problème avant qu’il ne survienne.
Nous prenons en charge les investigations des causes de dérives horaires liées à des déplacements de serveur ou des migrations vers le cloud, les revues de conception du traitement des dates et heures, ainsi que l’accompagnement à la modernisation pour la prise en charge de sites à l’étranger (fuseaux horaires, heure d’été). Si vos données déjà stockées mélangent déjà plusieurs référentiels et que vous ne savez pas comment démêler cela, nous pouvons aussi vous aider pour l’inventaire et le plan de migration.
Articles connexes
- Le Planificateur de tâches n’exécute pas la tâche, ou se termine avec 0x1 — isoler la cause et concevoir une exploitation fiable
- Utiliser SQLite dans une application métier en C# — mode WAL, verrouillage, parades à la corruption, et quand préférer EF Core
- Créer et exploiter un service Windows — du choix face au Planificateur de tâches jusqu’à transformer un BackgroundService en service
- Encodage des caractères et fins de ligne sous Windows - les bases du mojibake et de CRLF/LF
Domaines de conseil associés
KomuraSoft LLC (合同会社小村ソフト) prend en charge la revue de conception du traitement des dates et fuseaux horaires dans les applications métier, l’investigation des causes de dérives horaires ou de dates liées à un déplacement de serveur ou une migration vers le cloud, ainsi que la modernisation du traitement des dates et heures pour la prise en charge de sites à l’étranger et des actifs existants.
- Conseil technique et revue de conception
- Développement d’applications Windows
- Réutilisation des actifs existants et accompagnement à la migration
- Contact
Références
-
Microsoft Learn, DateTime.Kind Property. Sur le fait que Kind vaut Unspecified par défaut, et sur la façon dont Kind influence le résultat de ToLocalTime / ToUniversalTime (Unspecified étant traité comme de l’UTC par ToLocalTime et comme de l’heure locale par ToUniversalTime). ↩ ↩2 ↩3
-
Microsoft Learn, Choose between DateTime, DateOnly, DateTimeOffset, TimeSpan, TimeOnly, and TimeZoneInfo. Sur le fait que DateTimeOffset est le type date/heure par défaut recommandé pour le développement d’applications, que DateTimeOffset ne porte qu’un décalage et n’est pas lié à un fuseau horaire, et que DateOnly / TimeOnly ne sont pas disponibles sur .NET Framework. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Standard date and time format strings. Sur le fait que le format d’aller-retour “o” est conforme à ISO 8601 et préserve dans la chaîne le Kind de DateTime et le décalage de DateTimeOffset, et sur l’aller-retour via un parsing avec DateTimeStyles.RoundtripKind. ↩ ↩2
-
Microsoft Learn, What’s new in .NET 6. Sur le fait que TimeZoneInfo.FindSystemTimeZoneById accepte à la fois les ID de fuseau horaire IANA et Windows avec conversion automatique dans .NET 6, et sur l’ajout de TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId. ↩ ↩2
-
Microsoft Learn, .NET globalization and ICU. Sur le fait que la résolution des ID de fuseau horaire IANA sur Windows et les API d’interconversion dépendent d’ICU, sur leur indisponibilité en mode NLS / mode de globalisation invariant, et sur l’utilisation d’une ICU locale à l’application sur les versions d’OS sans ICU. ↩ ↩2
-
Microsoft Learn, TimeZoneInfo.IsInvalidTime(DateTime) Method. Sur la définition et la détection de l’« instant inexistant » produit par le passage à l’heure d’été, et son pendant IsAmbiguousTime (détection des instants ambigus). ↩ ↩2
-
Microsoft Learn, What is TimeProvider?. Sur le fait que TimeProvider est intégré en standard depuis .NET 8, disponible sur .NET Framework 4.6.2+ / .NET Standard 2.0 via le package Microsoft.Bcl.TimeProvider, sur son abstraction GetUtcNow / GetLocalNow / LocalTimeZone / création de minuteurs, et sur le FakeTimeProvider orienté test fourni par le package Microsoft.Extensions.TimeProvider.Testing. ↩ ↩2
-
Microsoft Learn, TimeZoneInfo.ConvertTime Method. Sur l’exigence de cohérence entre DateTime.Kind et le fuseau horaire source (une incohérence lève une ArgumentException), sur le fait que les instants ambigus sont interprétés comme de l’heure normale, et sur le fait que les instants inexistants lèvent une ArgumentException. ↩ ↩2
-
Microsoft Learn, Globalization APIs use ICU libraries on Windows Server 2019. Sur le fait que .NET 7 et versions ultérieures utilisent les bibliothèques ICU même sur Windows Server 2019 et des versions d’OS similaires qui n’intègrent pas ICU, alors que les versions antérieures nécessitaient de déployer manuellement une ICU locale à l’application. ↩
-
Microsoft Learn, datetime (Transact-SQL). Sur le fait qu’il est recommandé, pour les nouveaux travaux, d’éviter datetime au profit de time / date / datetime2 / datetimeoffset, et sur le fait que datetime2 / datetimeoffset offrent une précision plus élevée, datetimeoffset prenant en charge un décalage de fuseau horaire. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Data types (Microsoft.Data.Sqlite). Sur le fait que SQLite n’a que quatre types de stockage primitifs, et que Microsoft.Data.Sqlite stocke DateTime / DateTimeOffset en TEXT. ↩
-
Microsoft Learn, Windows Time Service (W32Time). Sur le fait que le service Windows Time synchronise l’horloge des ordinateurs sur le réseau via NTP, sur la hiérarchie de synchronisation au sein d’un domaine Active Directory, et sur la dépendance de l’authentification Kerberos à la synchronisation temporelle. ↩
Articles associés
Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.
Le support du haut DPI dans WPF — pourquoi l'affichage reste flou et baveux malgré une application « censée résister au DPI », et comment y remédier
WPF met en page en DIP (1/96 de pouce) et est System DPI Aware dès le départ, mais déplacer une fenêtre vers un moniteur au DPI différent...
La prise en charge du haut DPI dans WinForms — pourquoi l'interface devient floue ou se casse sur les moniteurs 4K, et solutions concrètes
Cet article explique pourquoi les applications WinForms deviennent floues ou voient leur mise en page se casser sur les moniteurs 4K, à p...
Utiliser SQLite en C# dans une application métier — mode WAL, contrôle d'accès exclusif, prévention de la corruption, et quand choisir EF Core
Un tour d'horizon pratique de l'intégration de SQLite dans une application métier avec Microsoft.Data.Sqlite : le mode WAL, SQLITE_BUSY e...
Comment créer et exploiter un service Windows ── du choix entre Planificateur de tâches et service à la transformation d'un BackgroundService en service Windows
Faut-il transformer un traitement résident en service Windows, ou le Planificateur de tâches suffit-il ? Ce guide organise, du point de v...
Comment comprendre l'isolation des sessions Windows — Session 0, RDP et exécution simultanée de plusieurs utilisateurs
Cet article démêle le concept de « session » Windows, un sujet qui déroute régulièrement les développeurs d'applications Windows. Il expl...
Sujets associés
Ces pages replacent le sujet dans un contexte plus large de services et de décisions.
Thèmes techniques Windows
Portail des sujets sur le développement Windows, l'analyse des incidents et la valorisation des actifs existants.
Services liés à ce sujet
Cet article est directement lié aux services suivants.
Développement d'applications Windows
Applications métier, intégration d'équipements et outils de communication, des besoins au développement.
Questions fréquentes
Questions souvent posées lors d’une consultation sur le sujet de cet article.
- Pourquoi les horaires dérivent-ils de neuf heures après un déplacement de serveur ?
- La cause profonde est qu'une valeur ne portant aucune information sur « à quel référentiel ce moment se rapporte-t-il » franchit une frontière telle qu'une base de données, une API ou un fichier. En .NET, un DateTime a par défaut un Kind à Unspecified, et ToLocalTime() le traite silencieusement comme UTC (+9 heures) tandis que ToUniversalTime() le traite comme une heure locale (-9 heures). Comme ce comportement dépend du fuseau horaire configuré sur la machine d'exécution, le problème reste invisible sur un poste de développement réglé sur l'heure du Japon, et se manifeste d'un coup le jour où l'application est déplacée vers une VM cloud configurée en UTC.
- Faut-il utiliser DateTime ou DateTimeOffset ?
- Pour du nouveau code, le choix par défaut est DateTimeOffset. Comme il porte toujours un décalage par rapport à UTC, la valeur seule détermine de façon unique un instant précis dans le monde, ce qui rend structurellement impossibles les incidents liés à l'interprétation implicite de Kind. La documentation officielle indique d'ailleurs explicitement qu'il faut envisager DateTimeOffset comme le type date/heure par défaut du développement d'applications. Attention toutefois : un décalage n'est pas un fuseau horaire en soi, donc si des règles d'ajustement pour l'heure d'été sont nécessaires, il faut le combiner avec TimeZoneInfo. Lorsqu'un grand nombre de DateTime existants sont déjà en place, un compromis réaliste consiste à unifier l'interne et le stockage sur des DateTime avec Kind=Utc, et à ne réserver DateTimeOffset qu'aux frontières.
- Quelle est la bonne façon de stocker les dates et heures dans une base de données ?
- Stockez et transmettez en UTC (ou avec un décalage explicite), et ne convertissez en heure locale qu'à l'affichage. Sur SQL Server, utilisez datetime2 pour une colonne stockée en UTC, ou datetimeoffset lorsque des exigences d'audit imposent de reproduire le décalage de l'instant de saisie — le type historique datetime est officiellement déconseillé pour les nouveaux développements. SQLite n'a aucun type date/heure et stocke les valeurs en TEXT : il faut donc fixer le format et le référentiel UTC comme une convention applicative. Graver le référentiel dans le nom des colonnes et des propriétés, par exemple CreatedAtUtc, et restaurer Kind à la lecture (par exemple avec un value converter EF Core), permet d'éviter les doubles conversions.
- Comment écrire des tests pour le traitement des dates et heures ?
- Cessez d'écrire DateTime.Now en dur dans le code et injectez plutôt TimeProvider, la norme depuis .NET 8, pour pouvoir substituer l'heure courante. Le même type reste utilisable sur .NET Framework 4.6.2 et versions ultérieures grâce au package Microsoft.Bcl.TimeProvider. Dans les tests, FakeTimeProvider permet de figer l'heure, de l'avancer, et même de remplacer le fuseau horaire local, ce qui permet de reproduire en intégration continue des bogues liés au passage d'une année à l'autre ou aux jours de changement d'heure d'été. Les cinq instants qu'il faut tester au minimum sont : le passage du nouvel an, la fin de mois, le 29 février d'une année bissextile, les jours de changement d'heure d'été, et les alentours de minuit.
Profil de l’auteur
Page de présentation de l’auteur de l’article.
Go Komura
Représentant de KomuraSoft LLC
Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.
Liens publics