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
YYYYen lugar deyyyy.YYYYes el año basado en semana. El 30/12/2024 formateado conYYYYse 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
DurationaLocalDate, porqueLocalDate.plus(Duration.ofHours(5))lanzaUnsupportedTemporalTypeException.LocalDateno 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
LocalDateTimepara timestamp de eventos.LocalDateTimeno sabe de zonas horarias, así que al leer después no sabes si “10:00” es 10:00 en dónde. Para eventos, usaInstantuOffsetDateTime/ZonedDateTime. - Usar
ZonedDateTimepara fechas puras. La fecha de nacimiento o días festivos sonLocalDate. Si asocias una zona horaria, al cambiar de zona la fecha puede “desplazarse” al día anterior. - Guardar
ZoneIdcomo offset.+07:00no cambia con las estaciones, peroEurope/Berlinsí. Para eventos programados en el futuro, guardaLocalDateTimejunto conZoneId(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. UsaChronoUnit.X.between()otoMinutes(). - Comparar con
equals()entre dosZonedDateTimede distinta zona.equals()compara incluso la zona, así que 9h en Ciudad Ho Chi Minh y 22h en Nueva York en el mismo instante devuelvenfalse. Para comparar instantes, usaisEqual(),isBefore(),isAfter(). - Llamar a
now()sin pasarClocken el código de negocio, haciendo que los tests oscilen según el día o la hora de ejecución.
Nota:
java.timeno 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. UnTemporalAdjusterpersonalizado 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.