java.time: LocalDate, ZonedDateTime y Duration

1. Resumen

El manejo de fecha y hora es una fuente de errores bien conocida: facturas retrasadas un día, reuniones en la zona horaria equivocada, jobs que se ejecutan dos veces cuando cambia la hora de verano. El paquete java.time (Java 8+) resuelve la mayoría de estos problemas, siempre que elijas el tipo de dato correcto.

Este artículo se centra en tres tipos muy utilizados: LocalDate, ZonedDateTime y Duration. Cuando termines de leerlo, sabrás cuándo usar cada tipo y cómo manejar cambios de zona horaria y el horario de verano (DST). También sabrás cómo escribir tests que no dependen del momento en que se ejecutan.

2. Por qué usar java.time

java.util.Date y Calendar tienen muchos problemas. Son mutables, por lo que no son thread-safe. Los meses se cuentan desde 0, y el nombre Date induce a confusión porque en realidad representa un instante en el tiempo. java.time soluciona estos puntos:

  • Todos los objetos son inmutables y thread-safe. Los métodos como plusDays() siempre devuelven un nuevo objeto.
  • Cada concepto tiene su propio tipo:
Tipo Significado Ejemplo
LocalDate Fecha, sin hora, sin zona horaria Fecha de nacimiento, fecha de vencimiento
LocalDateTime Fecha + hora, sin zona horaria Hora “en el calendario” sin ubicación asociada
ZonedDateTime Fecha + hora + ZoneId Reunión a las 9h en Hanói
Instant Un punto en la línea de tiempo (UTC) Timestamp guardado en BD, logs
Duration Período de tiempo en segundos/nanosegundos Timeout de 30 segundos
Period Período de tiempo en años/meses/días Plan de suscripción de 3 meses

No necesitas agregar ninguna dependencia porque todo viene incluido en el JDK. Para ejecutar los ejemplos de prueba, necesitas JUnit 5 y AssertJ (ya incluidos si usas 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: trabajando con fechas

3.1. Crear y parsear

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() recibe meses del 1 al 12. También puedes pasar Month.MAY para mayor legibilidad. parse() sin formato usará el estándar ISO-8601 (yyyy-MM-dd). Para otros formatos, creas un DateTimeFormatter y deberías declararlo como constante static final, ya que los formateadores también son thread-safe.

Nota: No escribas YYYY en lugar de yyyy. YYYY es el año basado en semana. El 30/12/2024 formateado con YYYY se convierte en el año 2025. Este error generalmente solo se ve a finales de año.

3.2. Suma y resta y comportamiento al final del mes

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

Febrero de 2024 no tiene 31 días, así que plusMonths(1) retrocede al último día válido, que es el 29. Como consecuencia, sumar dos veces un mes es diferente a sumar dos meses de una vez. Si calculas ciclos de pago mensuales, siempre calcula desde la fecha base, por ejemplo start.plusMonths(n). No sumes de forma acumulativa desde el resultado del ciclo anterior.

3.3. TemporalAdjusters

Preguntas como “último día del mes” o “próximo lunes” tienen ajustadores incorporados:

LocalDate today = LocalDate.of(2024, 5, 15); // miércoles

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. Distancia entre dos fechas: Period y 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

Este es un error muy común. period.getDays() solo devuelve la parte de días (24) de “1 mes 24 días”, no el total de días. Para obtener el número total de días, usa ChronoUnit.DAYS.between().

4. ZonedDateTime: fecha y hora asociada a una zona horaria

4.1. Crear y cambiar de zona horaria

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]

Espera, ese no es el output completo. El output correcto es:

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]

Estos dos métodos se diferencian en lo siguiente:

  • withZoneSameInstant() conserva el instante en el tiempo. Las 9h de la mañana en Ciudad Ho Chi Minh es las 22h de la noche del día anterior en Nueva York (usando horario de verano, UTC-4). Este es el método que necesitas en el 95% de los casos, como cuando muestras una reunión a personas en diferentes zonas horarias.
  • withZoneSameLocal() conserva la hora en el reloj y cambia a un instante diferente. Este método solo se usa cuando intencionalmente reasignas una zona horaria, por ejemplo cuando los datos ingresados tienen asignada la zona horaria equivocada.

Siempre usa IDs en formato Región/Ciudad como Europe/Berlin. Evita abreviaturas como EST o ICT porque son ambiguas. Los offsets fijos como +07:00 tampoco saben nada sobre las reglas del horario de verano.

4.2. Horario de verano: horas que “no existen”

El 31/03/2024 en Alemania, el reloj salta de las 02:00 a las 03:00. Entonces las 02:30 de ese día no existen:

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 no lanza una excepción sino que avanza la hora exactamente la cantidad de tiempo del intervalo (1 hora). En el lado opuesto, cuando termina el horario de verano, una hora en el reloj aparece dos veces. Por defecto java.time elige el offset más temprano. Puedes cambiar esto con withLaterOffsetAtOverlap().

4.3. Instant: lo que deberías guardar en la base de datos

Cuando necesites guardar “un evento sucedió en este momento”, convierte a Instant (o el tipo TIMESTAMP WITH TIME ZONE en la BD):

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 regla general es guardar Instant y convertir a ZonedDateTime solo cuando muestres al usuario en su zona horaria.

5. Duration: midiendo intervalos de tiempo precisos

5.1. Crear y leer Duration

Duration timeout = Duration.ofSeconds(30);
Duration delivery = Duration.ofMinutes(135);

System.out.println(delivery);                   
System.out.println(delivery.toMinutes());       
System.out.printf("%d horas %d minutos%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 horas 15 minutos
42500

toMinutes() devuelve el número total de minutos. Mientras que toHoursPart() y toMinutesPart() (Java 9+) desglosan en partes individuales para mostrar. Es la misma trampa que Period.getDays() en la sección 3.4.

5.2. Duration y Period: “1 día” no siempre son 24 horas

Este es el punto de diferencia más importante del artículo:

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

El resultado muestra tres cosas:

  • plusDays(1) suma según el calendario, así que mantiene las 12:00 al mediodía del día siguiente. En realidad entre los dos instantes solo hay 23 horas.
  • plus(Duration.ofDays(1)) suma exactamente 24 horas, así que cae en las 13:00.
  • Duration.between() confirma que la distancia real es 23 horas.

La regla para elegir tipo es muy simple. Si el usuario piensa en términos de calendario (“recuérdame cada día a las 9h”, “plan de 1 mes”), usas Period o plusDays/plusMonths. Si tu sistema mide tiempo transcurrido (timeout, SLA de 24 horas, rate limit), usas Duration.

Nota: No puedes sumar Duration a LocalDate, porque LocalDate.plus(Duration.ofHours(5)) lanza UnsupportedTemporalTypeException. LocalDate no tiene horas, así que unidades menores a un día no tienen sentido.

6. Escribiendo código fácil de probar con Clock

Llamar a LocalDate.now() directamente en la lógica de negocio hace que los tests dependan del día de ejecución. La forma correcta es inyectar 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);
    }
}

Todos los métodos now() de java.time tienen una sobrecarga que recibe Clock. En Spring Boot 3.x, declaras un bean:

@Configuration
public class TimeConfig {

    @Bean
    public Clock clock() {
        return Clock.system(ZoneId.of("Asia/Ho_Chi_Minh"));
    }
}

Usa Clock.system(...) con zona explícita en lugar de systemDefaultZone(). Así el resultado no cambia cuando el servidor corre en UTC pero tu máquina de desarrollo corre en zona horaria de Vietnam.

En los tests, fijas el tiempo usando Clock.fixed():

class SubscriptionServiceTest {

    private static final ZoneId HCM = ZoneId.of("Asia/Ho_Chi_Minh");
    // 03:00 UTC = 10:00 de la mañana 15/05/2024 en Ciudad Ho Chi Minh
    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));
    }
}

El segundo test verifica el límite: un plan que vence el 15/05 sigue siendo válido durante el 15/05. El último test “fija” el comportamiento de DST de la sección 5.2. Así, si alguien cambia plusDays a plus(Duration.ofDays(1)), el test lo reportará inmediatamente.

Un detalle fácil de pasar por alto: si usas Instant.parse("2024-05-14T18:00:00Z") como instante fijo, LocalDate.now(clock) en Ciudad Ho Chi Minh será el 15/05 (1:00 de la mañana). En UTC seguirá siendo 14/05. Por lo tanto, la zona del Clock determina cuál es “hoy”.

7. Errores comunes y cuándo no usar

  • Usar LocalDateTime para timestamp de eventos. LocalDateTime no sabe de zonas horarias, así que al leer después no sabes si “10:00” es 10:00 en dónde. Para eventos, usa Instant u OffsetDateTime/ZonedDateTime.
  • Usar ZonedDateTime para fechas puras. La fecha de nacimiento o días festivos son LocalDate. Si asocias una zona horaria, al cambiar de zona la fecha puede “desplazarse” al día anterior.
  • Guardar ZoneId como offset. +07:00 no cambia con las estaciones, pero Europe/Berlin sí. Para eventos programados en el futuro, guarda LocalDateTime junto con ZoneId (como cadena). Las reglas de DST pueden cambiar antes de la fecha del evento, y esta forma de guardar permite recalcular correctamente.
  • Confundir getDays() / toMinutesPart() con el total. Usa ChronoUnit.X.between() o toMinutes().
  • Comparar con equals() entre dos ZonedDateTime de distinta zona. equals() compara incluso la zona, así que 9h en Ciudad Ho Chi Minh y 22h en Nueva York en el mismo instante devuelven false. Para comparar instantes, usa isEqual(), isBefore(), isAfter().
  • Llamar a now() sin pasar Clock en el código de negocio, haciendo que los tests oscilen según el día o la hora de ejecución.

Nota: java.time no reemplaza una biblioteca de calendario de negocio. Días laborales, festivos de Vietnam o calendario lunar requieren lógica específica o una biblioteca especializada. Un TemporalAdjuster personalizado es el lugar apropiado para encapsular esa lógica.

8. Conclusión

Elegir el tipo de dato correcto es el paso más importante. Usa LocalDate para fechas en calendario, ZonedDateTime para horas asociadas a una ubicación, e Instant para guardar instantes en el tiempo. Con intervalos de tiempo, Period sigue el calendario mientras que Duration sigue el reloj, y en la sección 5.2 viste que dan resultados diferentes cuando encuentran cambios de horario.

También deberías inyectar Clock en tus servicios y declarar zonas explícitamente. Escribe tests para casos límite como fin de mes, cambios de hora y medianoche UTC. Si haces esto, la mayoría de errores relacionados con fecha y hora quedarán bloqueados antes de llegar a producción.

javajava-timedatetimetimezonetesting