Ir al contenido principal
Tecnología

Diseño de zonas horarias en Java - Elegir entre ZonedDateTime e Instant

La API java.time de un vistazo

Java 8 introdujo el paquete java.time como JSR-310, diseñado por Stephen Colebourne (creador de Joda-Time). El paquete se centra en cinco clases principales: Instant, OffsetDateTime, ZonedDateTime, LocalDateTime y LocalDate. Cada una representa un nivel distinto de información sobre un momento en el tiempo, y elegir la correcta es la base de un manejo adecuado de zonas horarias.

La regla de selección es usar la clase con la mínima información necesaria. Usa LocalDate o LocalDateTime cuando no haya zona horaria implicada, OffsetDateTime cuando baste un desfase UTC, ZonedDateTime cuando necesites la semántica de zona IANA que siga futuros cambios de horario de verano, e Instant cuando solo te importe un momento absoluto. Elegir el tipo menos expresivo que encaje previene la pérdida accidental de información y simplifica el razonamiento.

Qué guarda cada una de las cinco clases principales y para qué sirve
ClaseInformación que guardaUso habitual
InstantSegundos y nanosegundos desde la época, sin zonaLogs, hora de los eventos, marcas de tiempo de máquina
OffsetDateTimeFecha-hora más un desfase UTC como +09:00Columnas de base de datos, serialización, registros ya cerrados
ZonedDateTimeFecha-hora más una zona IANA como Asia/TokyoCitas futuras que deben seguir los cambios de reglas de horario de verano
LocalDateTimeSolo fecha-hora, sin desfase ni zonaValores donde la geografía es irrelevante, entrada bruta de formularios
LocalDateSolo fechaCumpleaños, festivos, fechas de cierre

Las filas no están ordenadas por riqueza de información: se diferencian por su significado. La regla es elegir la clase que lleva exactamente la información que el valor necesita.

Instant - Tiempo de máquina sin zonas

Instant representa un momento absoluto como nanosegundos desde la época Unix (1970-01-01T00:00:00Z). No lleva información de zona horaria y representa el mismo momento en toda la Tierra. Las marcas de tiempo de logs, tiempos de creación de eventos y tiempos de respuesta de API normalmente deberían ser Instant porque su significado es independiente de cualquier zona horaria local.

La aritmética con Instant es directa y predecible. plus(Duration.ofHours(1)) siempre avanza exactamente una hora física, independientemente de cualquier transición de horario de verano. La contrapartida es que Instant no puede responder directamente a preguntas como «¿qué fecha es en Tokio?» sin combinarse primero con un ZoneId. La conversión solo ocurre en la presentación, que es exactamente el lugar correcto para ella.

ZonedDateTime - Conocimiento completo de zona

ZonedDateTime combina un LocalDateTime con un ZoneId como Asia/Tokyo. Lleva el nombre de zona IANA y, por tanto, rastrea futuros cambios de horario de verano y actualizaciones históricas de zona. Para horas de reloj ambiguas durante transiciones de horario de verano, ZonedDateTime ofrece withEarlierOffsetAtOverlap() y withLaterOffsetAtOverlap() para hacer la elección explícita.

La aritmética de ZonedDateTime cambia de línea temporal según la unidad que sumes. El javadoc oficial define los métodos de unidades de tiempo, como plusHours, como operaciones sobre la línea temporal de instantes: sumar una hora siempre avanza exactamente una hora física, y como efecto secundario la fecha-hora local (el reloj de pared) puede moverse una cantidad distinta de una hora. Cruzar el límite de adelanto de horario de verano es precisamente ese caso, y el reloj de pared parece saltar dos horas. En cambio, los métodos de unidades de fecha, como plusDays, operan sobre la línea temporal local: conservan la hora del reloj de pared y avanzan la fecha, que es la intuición humana de «la misma hora mañana». Como dice el javadoc, sumar un día no es lo mismo que sumar 24 horas: el tiempo físico transcurrido puede ser de 23 o 25 horas. Usa plusHours o Duration cuando quieras tiempo físico, y plusDays o Period cuando quieras preservar el reloj de pared. Incluye siempre pruebas en los límites de horario de verano cuando la aritmética es crítica.

plusHours frente a plusDays en los límites de horario de verano (America/New_York)
Punto de partidaOperaciónResultadoTiempo físico transcurrido
2026-03-08T01:30-05:00plusHours(1)2026-03-08T03:30-04:001 hora
2026-03-08T01:30-05:00plusDays(1)2026-03-09T01:30-04:0023 horas
2026-11-01T01:30-04:00plusHours(1)2026-11-01T01:30-05:001 hora
2026-11-01T01:30-04:00plusDays(1)2026-11-02T01:30-05:0025 horas

Con las unidades de tiempo, la columna del tiempo transcurrido se mantiene constante mientras el reloj de pared salta dos horas en marzo y no se mueve en absoluto en noviembre. Con las unidades de fecha, el reloj de pared se queda en 01:30 y lo que cambia es el tiempo transcurrido, que pasa a ser de 23 o 25 horas.

OffsetDateTime - Cuando el desfase es suficiente

OffsetDateTime es similar a ZonedDateTime, pero almacena solo un desfase UTC como +09:00 en lugar de una zona IANA completa. El momento es inequívoco porque el desfase fija la hora UTC, pero no rastrea futuros cambios de política de horario de verano. Esto lo hace ideal para formatos de serialización como ISO 8601 y RFC 3339, donde tanto la hora local como el desfase UTC se expresan textualmente.

La columna TIMESTAMP WITH TIME ZONE de PostgreSQL almacena valores internamente como UTC, y el driver JDBC típicamente la mapea a OffsetDateTime o Instant. Persistir OffsetDateTime preserva el desfase original para propósitos de auditoría, permitiendo al mismo tiempo la conversión inequívoca a UTC. Usar ZonedDateTime para datos almacenados arriesga una reinterpretación si el país cambia posteriormente su política de horario de verano de forma retroactiva.

Integración con Spring Boot y JPA

Spring Boot 3.x y Hibernate 6 soportan Instant y OffsetDateTime como tipos de campo de entidad directamente. El código legacy que usa java.util.Date o Calendar debería migrarse, seleccionando los tipos de columna correspondientes: TIMESTAMP para horas locales sin zona, TIMESTAMPTZ (PostgreSQL) o TIMESTAMP (MySQL con serverTimezone configurado) para horas absolutas. El mapeo que elijas a nivel de entidad determina directamente si los errores de zona horaria son posibles o no.

La serialización JSON con Jackson también merece atención. En una configuración de Jackson a secas con JavaTimeModule registrado, los Instants se emiten como segundos epoch numéricos, lo que sorprende a los consumidores de API que esperan cadenas ISO 8601. En lugar de confiar en los valores por omisión del framework, fija el comportamiento de forma explícita con un ajuste como spring.jackson.serialization.write-dates-as-timestamps=false para que la salida se mantenga en ISO 8601, que es el estándar moderno y lo que esperan bibliotecas JavaScript como Day.js y Luxon. Alinear las convenciones de serialización a lo largo de toda la pila ahorra innumerables errores de interoperabilidad.

Un flujo de decisión para la selección de clase

Empieza con la pregunta «¿necesito aquí una noción de geografía humana?». Si la respuesta es no (logs, eventos, marcas de tiempo de máquina), usa Instant. Si es sí, pregunta si el valor debe seguir futuros cambios en las reglas de zona. Para eventos futuros programados por usuarios, como alarmas, ZonedDateTime es lo correcto. Para registros históricos y transacciones completadas, OffsetDateTime es más seguro porque el significado queda congelado en el momento de escritura. Para fechas puras sin componente horario, LocalDate es la respuesta correcta.

Es normal y saludable que una aplicación use múltiples tipos de java.time. Forzar todo a ZonedDateTime da a los logs más información de la necesaria y complica la serialización. Documenta en las capas de frontera (DTOs, columnas de BD, contratos de API) qué tipo usa cada campo, y el 90 por ciento de los errores de zona horaria simplemente desaparecen. El 10 por ciento restante suele tratarse de cobertura de pruebas en los límites de horario de verano, algo que las pruebas unitarias adecuadas pueden detectar a tiempo.

XB!LINE

¿Te resultó útil este artículo?

Artículos Relacionados