java.time : LocalDate, ZonedDateTime et Duration
1. Aperçu
Gérer les dates et les heures est source de bugs courants : les factures décalées d’une journée, les réunions dans le mauvais fuseau horaire, les jobs qui s’exécutent deux fois lors du changement d’heure d’été. Le package java.time (Java 8+) résout la plupart de ces problèmes, à condition de choisir le bon type de données.
Cet article se concentre sur les trois types les plus courants : LocalDate, ZonedDateTime et Duration. Après la lecture, vous saurez quand utiliser quel type et comment gérer les conversions de fuseaux horaires ainsi que l’heure d’été (DST). Vous saurez aussi comment écrire des tests qui ne dépendent pas du moment d’exécution.
2. Pourquoi utiliser java.time
java.util.Date et Calendar ont de nombreux problèmes. Ils sont mutables, donc non thread-safe. Les mois comptent à partir de 0, et le nom Date est trompeur car il représente en réalité un instant. java.time corrige ces points :
- Tous les objets sont immutables et thread-safe. Les méthodes comme
plusDays()retournent toujours un nouvel objet. - Chaque concept a son propre type :
| Type | Signification | Exemple |
|---|---|---|
LocalDate |
Jour, sans heure, sans fuseau horaire | Date de naissance, date d’expiration |
LocalDateTime |
Jour + heure, sans fuseau horaire | Heure “à l’horloge” non liée à une zone |
ZonedDateTime |
Jour + heure + ZoneId |
Réunion à 9h à Hô Chi Minh-Ville |
Instant |
Un point sur l’axe du temps (UTC) | Timestamp en base de données, logs |
Duration |
Intervalle de temps en secondes/nanosecondes | Timeout de 30 secondes |
Period |
Intervalle de temps en années/mois/jours | Abonnement de 3 mois |
Aucune dépendance supplémentaire n’est nécessaire car tout est inclus dans le JDK. Pour exécuter les exemples de test, vous avez besoin de JUnit 5 et AssertJ (déjà inclus si vous utilisez spring-boot-starter-test) :
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
3. LocalDate : travailler avec les jours
3.1. Créer et analyser
LocalDate dueDate = LocalDate.of(2024, 5, 15);
LocalDate parsed = LocalDate.parse("2024-02-29");
DateTimeFormatter vnFormat = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate fromForm = LocalDate.parse("15/05/2024", vnFormat);
System.out.println(dueDate);
System.out.println(fromForm.equals(dueDate));
System.out.println(dueDate.format(vnFormat));
2024-05-15
true
15/05/2024
LocalDate.of() prend les mois de 1 à 12. Vous pouvez aussi passer Month.MAY pour plus de clarté. parse() sans formatter utilisera la norme ISO-8601 (yyyy-MM-dd). Avec un format différent, créez un DateTimeFormatter et déclarez-le comme constante static final, car le formatter est aussi thread-safe.
Attention : N’écrivez pas
YYYYà la place deyyyy.YYYYest l’année basée sur la semaine. Le 30/12/2024 formaté avecYYYYdeviendra l’année 2025. Cette erreur se manifeste généralement en fin d’année.
3.2. Addition, soustraction et comportement en fin de mois
LocalDate orderDate = LocalDate.of(2024, 1, 31);
System.out.println(orderDate.plusDays(10));
System.out.println(orderDate.plusMonths(1));
System.out.println(orderDate.plusMonths(1).plusMonths(1));
System.out.println(orderDate.plusMonths(2));
2024-02-10
2024-02-29
2024-03-29
2024-03-31
Février 2024 n’a pas 31 jours, donc plusMonths(1) recule vers le dernier jour valide, 29. Par conséquent, ajouter deux fois un mois est différent d’ajouter deux mois d’un coup. Si vous calculez des cycles de paiement mensuels, calculez toujours à partir de la date de base, par exemple start.plusMonths(n). Ne l’additionnez pas successivement à partir du résultat précédent.
3.3. TemporalAdjusters
Des questions comme “dernier jour du mois” ou “lundi prochain” ont des ajusteurs prêts à l’emploi :
LocalDate today = LocalDate.of(2024, 5, 15); // mercredi
LocalDate endOfMonth = today.with(TemporalAdjusters.lastDayOfMonth());
LocalDate nextMonday = today.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
LocalDate firstOfNextMonth = today.with(TemporalAdjusters.firstDayOfNextMonth());
System.out.println(endOfMonth + " " + nextMonday + " " + firstOfNextMonth);
2024-05-31 2024-05-20 2024-06-01
3.4. Distance entre deux jours : Period et ChronoUnit
LocalDate start = LocalDate.of(2024, 1, 15);
LocalDate end = LocalDate.of(2024, 3, 10);
Period period = Period.between(start, end);
long days = ChronoUnit.DAYS.between(start, end);
System.out.println(period);
System.out.println(period.getDays());
System.out.println(days);
P1M24D
24
55
C’est une erreur très courante. period.getDays() retourne seulement la partie jours (24) de “1 mois 24 jours”, pas le nombre total de jours. Pour le nombre total de jours, utilisez ChronoUnit.DAYS.between().
4. ZonedDateTime : date/heure liée à un fuseau horaire
4.1. Créer et convertir les fuseaux horaires
ZoneId hcm = ZoneId.of("Asia/Ho_Chi_Minh");
ZoneId newYork = ZoneId.of("America/New_York");
ZonedDateTime meetingHcm = ZonedDateTime.of(
LocalDateTime.of(2024, 6, 1, 9, 0), hcm);
ZonedDateTime sameMoment = meetingHcm.withZoneSameInstant(newYork);
ZonedDateTime sameClock = meetingHcm.withZoneSameLocal(newYork);
System.out.println(meetingHcm);
System.out.println(sameMoment);
System.out.println(sameClock);
2024-06-01T09:00+07:00[Asia/Ho_Chi_Minh]
2024-06-01T09:00-04:00[America/New_York]
Attendez, ce n’est pas la sortie complète. La sortie correcte est :
2024-06-01T09:00+07:00[Asia/Ho_Chi_Minh]
2024-05-31T22:00-04:00[America/New_York]
2024-06-01T09:00-04:00[America/New_York]
Ces deux méthodes diffèrent sur un point :
withZoneSameInstant()conserve le même instant. 9h du matin à Hô Chi Minh-Ville est 22h le soir la veille à New York (utilisant l’heure d’été, UTC-4). C’est la méthode dont vous avez besoin dans 95% des cas, par exemple pour afficher une réunion à quelqu’un dans un fuseau horaire différent.withZoneSameLocal()conserve les mêmes chiffres sur l’horloge et change le fuseau horaire, d’où l’instant différent. Cette méthode n’est utilisée que lorsque vous réattribuez intentionnellement le fuseau horaire, par exemple lorsque les données importées ont été affectées au mauvais fuseau.
Utilisez toujours un identifiant de la forme Région/Ville comme Europe/Berlin. Évitez les abréviations comme EST ou ICT car elles sont ambiguës. Un décalage fixe comme +07:00 ne sait rien sur les règles de l’heure d’été.
4.2. Heure d’été : créneaux horaires qui “n’existent pas”
Le 31/03/2024 en Allemagne, l’horloge saute de 02:00 à 03:00. Donc 02:30 ce jour-là n’existe pas :
ZoneId berlin = ZoneId.of("Europe/Berlin");
ZonedDateTime inGap = ZonedDateTime.of(
LocalDateTime.of(2024, 3, 31, 2, 30), berlin);
System.out.println(inGap);
2024-03-31T03:30+02:00[Europe/Berlin]
java.time ne lève pas d’exception mais pousse l’heure vers l’avant exactement de la longueur du créneau (1 heure). Dans la direction opposée, quand l’heure d’été se termine, une heure sur l’horloge apparaît deux fois. Par défaut, java.time choisit le décalage antérieur. Vous pouvez le changer avec withLaterOffsetAtOverlap().
4.3. Instant : ce qu’il faut sauvegarder en base de données
Quand vous devez enregistrer “quand un événement s’est produit”, convertissez en Instant (ou en type TIMESTAMP WITH TIME ZONE en base) :
Instant createdAt = meetingHcm.toInstant();
System.out.println(createdAt);
System.out.println(createdAt.atZone(newYork));
2024-06-01T02:00:00Z
2024-05-31T22:00-04:00[America/New_York]
La règle générale est de sauvegarder Instant et ne convertir en ZonedDateTime que lors de l’affichage pour l’utilisateur dans son fuseau horaire.
5. Duration : mesurer les intervalles de temps avec précision
5.1. Créer et lire Duration
Duration timeout = Duration.ofSeconds(30);
Duration delivery = Duration.ofMinutes(135);
System.out.println(delivery);
System.out.println(delivery.toMinutes());
System.out.printf("%d heures %d minutes%n",
delivery.toHoursPart(), delivery.toMinutesPart());
Instant start = Instant.parse("2024-05-15T08:00:00Z");
Instant end = Instant.parse("2024-05-15T08:00:42.5Z");
System.out.println(Duration.between(start, end).toMillis());
PT2H15M
135
2 heures 15 minutes
42500
toMinutes() retourne le nombre total de minutes. Tandis que toHoursPart() et toMinutesPart() (Java 9+) séparent en parties pour l’affichage. C’est le même type de piège que Period.getDays() à la section 3.4.
5.2. Duration et Period : “1 jour” n’est pas toujours 24 heures
C’est la différence la plus importante de cet article :
ZoneId berlin = ZoneId.of("Europe/Berlin");
ZonedDateTime beforeDst = ZonedDateTime.of(
LocalDateTime.of(2024, 3, 30, 12, 0), berlin);
ZonedDateTime calendarDay = beforeDst.plusDays(1);
ZonedDateTime exact24h = beforeDst.plus(Duration.ofDays(1));
System.out.println(calendarDay);
System.out.println(exact24h);
System.out.println(Duration.between(beforeDst, calendarDay));
2024-03-31T12:00+02:00[Europe/Berlin]
2024-03-31T13:00+02:00[Europe/Berlin]
PT23H
Le résultat ci-dessus montre trois choses :
plusDays(1)ajoute selon le calendrier, donc maintient 12:00 le jour suivant. En réalité, il n’y a que 23 heures entre les deux instants.plus(Duration.ofDays(1))ajoute exactement 24 heures, donc tombe à 13:00.Duration.between()confirme que l’intervalle réel est de 23 heures.
La règle pour choisir le type est très simple. Si l’utilisateur pense en termes de calendrier (“rappelez-moi chaque jour à 9h”, “abonnement d’1 mois”), utilisez Period ou plusDays/plusMonths. Si le système mesure le temps qui s’écoule (timeout, SLA 24 heures, rate limit), utilisez Duration.
Attention : Vous ne pouvez pas ajouter
DurationàLocalDate, carLocalDate.plus(Duration.ofHours(5))lèveUnsupportedTemporalTypeException.LocalDaten’a pas d’heures, donc les unités plus petites qu’un jour n’ont pas de sens.
6. Écrire du code facile à tester avec Clock
Appeler LocalDate.now() directement dans la logique métier rend les tests dépendants du jour d’exécution. La bonne approche est d’injecter Clock :
public class SubscriptionService {
private static final int TRIAL_DAYS = 14;
private final Clock clock;
public SubscriptionService(Clock clock) {
this.clock = clock;
}
public LocalDate trialEndDate() {
return LocalDate.now(clock).plusDays(TRIAL_DAYS);
}
public boolean isExpired(LocalDate expiryDate) {
return LocalDate.now(clock).isAfter(expiryDate);
}
public Duration timeUntil(Instant deadline) {
return Duration.between(Instant.now(clock), deadline);
}
}
Chaque méthode now() dans java.time a une surcharge qui accepte Clock. Dans Spring Boot 3.x, déclarez un bean :
@Configuration
public class TimeConfig {
@Bean
public Clock clock() {
return Clock.system(ZoneId.of("Asia/Ho_Chi_Minh"));
}
}
Utilisez Clock.system(...) avec une zone explicite au lieu de systemDefaultZone(). De cette façon, le résultat ne change pas si le serveur fonctionne en UTC et la machine de dev en heure du Viêt Nam.
Dans les tests, fixez l’heure avec Clock.fixed() :
class SubscriptionServiceTest {
private static final ZoneId HCM = ZoneId.of("Asia/Ho_Chi_Minh");
// 03:00 UTC = 10:00 du matin le 15/05/2024 à Hô Chi Minh-Ville
private final Clock fixedClock =
Clock.fixed(Instant.parse("2024-05-15T03:00:00Z"), HCM);
private final SubscriptionService service = new SubscriptionService(fixedClock);
@Test
void trialEndsAfterFourteenDays() {
assertThat(service.trialEndDate()).isEqualTo(LocalDate.of(2024, 5, 29));
}
@Test
void expiredOnlyAfterExpiryDate() {
assertThat(service.isExpired(LocalDate.of(2024, 5, 15))).isFalse();
assertThat(service.isExpired(LocalDate.of(2024, 5, 14))).isTrue();
}
@Test
void calculatesRemainingTime() {
Instant deadline = Instant.parse("2024-05-15T05:30:00Z");
assertThat(service.timeUntil(deadline)).isEqualTo(Duration.ofMinutes(150));
}
@Test
void calendarDayAcrossDstIsTwentyThreeHours() {
ZonedDateTime start = ZonedDateTime.of(
LocalDateTime.of(2024, 3, 30, 12, 0), ZoneId.of("Europe/Berlin"));
assertThat(Duration.between(start, start.plusDays(1))).isEqualTo(Duration.ofHours(23));
}
}
Le deuxième test vérifie les cas limites : un abonnement expirant le 15/05 est toujours valide pendant la journée du 15/05. Le test final “gèle” le comportement DST de la section 5.2. Ainsi, si quelqu’un remplace plusDays par plus(Duration.ofDays(1)), le test signale immédiatement le problème.
Un détail facile à oublier. Si vous utilisez Instant.parse("2024-05-14T18:00:00Z") comme instant fixe, LocalDate.now(clock) à Hô Chi Minh-Ville sera 15/05 (01:00 du matin). En UTC, ce sera toujours 14/05. Donc la zone du Clock détermine quel jour “aujourd’hui” est.
7. Erreurs courantes et quand ne pas utiliser java.time
- Utiliser
LocalDateTimepour l’horodatage d’événements.LocalDateTimene connaît pas le fuseau horaire, donc en relisant vous ne savez pas si “10:00” signifie 10:00 où. Pour les événements, utilisezInstantouOffsetDateTime/ZonedDateTime. - Utiliser
ZonedDateTimepour des jours purs. La date de naissance ou la fête nationale estLocalDate. Attacher un fuseau horaire, c’est risquer que la date “dérive” vers le jour précédent lors d’une conversion de zone. - Enregistrer
ZoneIdcomme décalage.+07:00ne change pas avec les saisons, tandis queEurope/Berlinoui. Pour les rendez-vous futurs, enregistrezLocalDateTimeavecZoneId(comme chaîne). Les règles DST peuvent changer avant la date du rendez-vous, et cette approche permet de recalculer correctement. - Confondre
getDays()/toMinutesPart()avec les totaux. UtilisezChronoUnit.X.between()outoMinutes(). - Comparer avec
equals()deuxZonedDateTimedans des fuseaux différents.equals()compare aussi les fuseaux, donc 9h à Hô Chi Minh-Ville et 22h à New York au même instant retournent toujoursfalse. Pour comparer les instants, utilisezisEqual(),isBefore(),isAfter(). - Appeler
now()sans passerClockdans le code métier, ce qui rend les tests instables selon le jour ou l’heure d’exécution.
Attention :
java.timene remplace pas une bibliothèque de calendrier métier. Les jours ouvrables, les jours fériés du Viêt Nam ou le calendrier lunaire nécessitent une logique spécifique ou une bibliothèque dédiée. UnTemporalAdjusterpersonnalisé est le lieu approprié pour encapsuler cette logique.
8. Conclusion
Choisir le bon type de données est l’étape la plus importante. Utilisez LocalDate pour les jours sur le calendrier, ZonedDateTime pour l’heure liée à un endroit spécifique, et Instant pour enregistrer un moment. Pour les intervalles de temps, Period suit le calendrier tandis que Duration suit l’horloge, et vous avez vu à la section 5.2 qu’ils donnent des résultats différents quand l’heure d’été intervient.
Vous devriez aussi injecter Clock dans vos services et déclarer les fuseaux horaires explicitement. Écrivez des tests pour les cas limites comme la fin de mois, le changement d’heure d’été et minuit UTC. Faire ces choses, c’est intercepter la plupart des bugs de date/heure avant qu’ils n’atteignent la production.