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 de yyyy. YYYY est l’année basée sur la semaine. Le 30/12/2024 formaté avec YYYY deviendra 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, car LocalDate.plus(Duration.ofHours(5)) lève UnsupportedTemporalTypeException. LocalDate n’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 LocalDateTime pour l’horodatage d’événements. LocalDateTime ne connaît pas le fuseau horaire, donc en relisant vous ne savez pas si “10:00” signifie 10:00 où. Pour les événements, utilisez Instant ou OffsetDateTime/ZonedDateTime.
  • Utiliser ZonedDateTime pour des jours purs. La date de naissance ou la fête nationale est LocalDate. 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 ZoneId comme décalage. +07:00 ne change pas avec les saisons, tandis que Europe/Berlin oui. Pour les rendez-vous futurs, enregistrez LocalDateTime avec ZoneId (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. Utilisez ChronoUnit.X.between() ou toMinutes().
  • Comparer avec equals() deux ZonedDateTime dans des fuseaux différents. equals() compare aussi les fuseaux, donc 9h à Hô Chi Minh-Ville et 22h à New York au même instant retournent toujours false. Pour comparer les instants, utilisez isEqual(), isBefore(), isAfter().
  • Appeler now() sans passer Clock dans le code métier, ce qui rend les tests instables selon le jour ou l’heure d’exécution.

Attention : java.time ne 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. Un TemporalAdjuster personnalisé 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.

javajava-timedatetimetimezonetesting