Java deja que cualquier referencia sea null, así que un valor ausente puede romper el código lejos de donde se perdió. Objects y Optional hacen visible ese valor ausente, y java.time te obliga a decir qué momento, y en qué ciudad, quieres decir.
Una referencia de Java siempre puede ser null, y nada en el tipo te avisa cuándo esperarlo. Este post cubre las herramientas que te da el JDK para eso: los helpers de Objects, Optional para un resultado que puede no existir y los casos en que Optional empeora el código. Después pasa a java.time, donde un detalle que falta causa el mismo tipo de bug. Una fecha, una hora del reloj de pared y un momento en la línea de tiempo son cosas distintas, y confundirlas te da bugs que aparecen dos veces al año.
Cada programa de abajo se ejecutó en Java 25, y su salida está copiada de esa ejecución. Para ejecutar uno tú mismo, guárdalo como Main.java y ejecuta java Main.java. Ninguno lee el reloj real ni la zona horaria de tu computadora, así que vas a obtener la misma salida que nosotros.
null significa “ningún objeto”, y el fallo llega después
Se lanza un NullPointerException cuando el código llama a un método o lee un campo a través de una referencia que es null. El problema es que el null suele llegar en silencio, y el fallo aparece unas líneas más adelante. Map.get devuelve null para una clave que no existe:
Map<String, String> settings = new HashMap<>();
void main() {
settings.put("theme", "dark");
IO.println(settings.get("theme").toUpperCase());
IO.println(settings.get("font").toUpperCase());
}
Imprime y se detiene:
DARK
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because the return value of "java.util.Map.get(Object)" is null
Ese mensaje es un helpful NullPointerException message, que ya mostró la parte sobre valores y referencias. Nombra la expresión exacta que era null. Pero no puede decirte por qué faltaba la clave. Ese es el verdadero problema de null: te dice dónde ocurrió el fallo, no dónde se perdió el valor.
Revisar null con Objects
La clase java.util.Objects tiene métodos estáticos pequeños que manejan null en un solo lugar, en vez de repartir revisiones if (x != null) por todas partes. Estos son los tres que más vas a usar:
record Account(String owner, String nickname) {
Account {
Objects.requireNonNull(owner, "owner is required");
nickname = Objects.requireNonNullElse(nickname, owner);
}
}
void main() {
IO.println(new Account("Ana", "annie"));
IO.println(new Account("Ben", null));
try {
new Account(null, "ghost");
} catch (NullPointerException e) {
IO.println("rejected: " + e.getMessage());
}
String typed = null;
String stored = "Ana";
IO.println(Objects.equals(typed, stored));
IO.println(Objects.equals(null, null));
}
Imprime:
Account[owner=Ana, nickname=annie]
Account[owner=Ben, nickname=Ben]
rejected: owner is required
false
true
requireNonNull(value, message)lanza la excepción de inmediato, en el constructor, en vez de dejar que un ownernullviaje hasta que algo llame a un método sobre él. La parte sobre excepciones explica por qué importa el mensaje.requireNonNullElse(value, fallback)devuelve el valor, o el valor alternativo cuando el valor esnull. Ben no tenía apodo, así que recibió su nombre. El valor alternativo no puede sernull:requireNonNullElse(null, null)lanza una excepción.Objects.equals(a, b)estruecuando los dos sonnull,falsecuando solo uno lo es, y en otro caso llama aa.equals(b). Escribirtyped.equals(stored)habría lanzado una excepción, porquetypedesnull.
El sistema de tipos de Java no tiene forma de decir “este String nunca es null“. Todo tipo de referencia lo permite, y javac no lo revisa. Bibliotecas de terceros como JSpecify agregan anotaciones como @Nullable, y las herramientas de tu editor o de tu build las leen y te avisan. Esta serie usa solo el JDK, así que no las usa, pero te las vas a encontrar en proyectos reales.
Optional: un tipo de retorno para “quizás no hay resultado”
Optional<T> es una caja pequeña que guarda un valor o nada. Un método que devuelve Optional<User> dice en su firma que puede no haber usuario, así que quien lo llama no puede olvidarse de manejar ese caso, como sí se olvida de un null.
record User(String name, String email) {}
List<User> users = List.of(
new User("ana", "ana@example.com"),
new User("ben", null));
Optional<User> findUser(String name) {
return users.stream()
.filter(u -> u.name().equals(name))
.findFirst();
}
void main() {
IO.println(findUser("ana"));
IO.println(findUser("zoe"));
IO.println(findUser("zoe").isPresent());
Optional<String> anaEmail = Optional.ofNullable(findUser("ana").orElseThrow().email());
Optional<String> benEmail = Optional.ofNullable(findUser("ben").orElseThrow().email());
IO.println(anaEmail);
IO.println(benEmail);
IO.println(Optional.empty().equals(benEmail));
}
Imprime:
Optional[User[name=ana, email=ana@example.com]]
Optional.empty
false
Optional[ana@example.com]
Optional.empty
true
findFirst ya devuelve un Optional, como mencionó la parte sobre streams. Hay tres formas de crear uno tú mismo:
Optional.of(value)cuando sabes que el valor no esnull.Optional.ofNullable(value)cuando podría serlo. Unnullse convierte en unOptionalvacío, como pasó con el email que le faltaba a Ben.Optional.empty()para no tener nada.
orElseThrow() sin argumentos devuelve el valor, o lanza una excepción si no hay ninguno. Lo usamos con Ana y Ben porque sabemos que existen.
orElse ejecuta su argumento aunque no haga falta
orElse y orElseGet dan un valor alternativo para un Optional vacío, y parecen intercambiables. No lo son, porque Java evalúa los argumentos de un método antes de llamarlo:
String loadDefault() {
IO.println(" loading the default name...");
return "guest";
}
void main() {
Optional<String> name = Optional.of("ana");
IO.println("orElse:");
IO.println(name.orElse(loadDefault()));
IO.println("orElseGet:");
IO.println(name.orElseGet(this::loadDefault));
}
Imprime:
orElse:
loading the default name...
ana
orElseGet:
ana
El Optional tenía un valor las dos veces, así que no se usó ningún valor alternativo. Pero orElse(loadDefault()) llamó primero a loadDefault(), para tener un argumento que pasar. orElseGet recibe un Supplier, una función que solo llama cuando el Optional está vacío. Si el valor alternativo es una constante como "guest", orElse está bien. Si lee un archivo, consulta una base de datos o construye algo costoso, usa orElseGet.
Pedirle su valor a un Optional vacío
get() devuelve el valor que hay dentro de un Optional, y lanza una excepción cuando no hay ninguno. Eso lo hace tan poco seguro como una revisión de null que olvidaste:
void main() {
Optional<String> email = Optional.empty();
try {
email.orElseThrow(() -> new IllegalStateException("ben has no email on file"));
} catch (IllegalStateException e) {
IO.println("caught: " + e.getMessage());
}
IO.println(email.get());
}
Imprime y se detiene:
caught: ben has no email on file
Exception in thread "main" java.util.NoSuchElementException: No value present
orElseThrow(supplier) te deja lanzar una excepción que explica qué faltaba. get() y orElseThrow() lanzan NoSuchElementException con el mensaje No value present. Hacen exactamente lo mismo, pero orElseThrow() dice lo que hace en su nombre, y por eso se agregó en Java 10. Prefiérelo a get().
Optional.of(null) lanza una excepción
Optional.of rechaza un null, y la excepción llega sin ningún mensaje:
String nickname(String name) {
return name.equals("ana") ? "annie" : null;
}
void main() {
IO.println(Optional.ofNullable(nickname("ben")));
IO.println(Optional.of(nickname("ben")));
}
Imprime y se detiene:
Optional.empty
Exception in thread "main" java.lang.NullPointerException
No hay helpful message, porque Optional.of revisa con Objects.requireNonNull dentro del JDK en vez de llamar a un método sobre el null. El primer frame del stack trace nombra Objects.requireNonNull. Si ves ahí una NPE sin mensaje, la solución suele ser ofNullable.
Transformar un Optional sin desempaquetarlo
map, filter y flatMap trabajan sobre el valor que hay dentro de un Optional, y se saltan el trabajo cuando está vacío, así que una cadena de pasos no necesita ningún if:
record User(String name, String email) {}
Map<String, User> users = new HashMap<>();
Map<String, String> cities = new HashMap<>();
Optional<User> findUser(String name) {
return Optional.ofNullable(users.get(name));
}
Optional<String> cityOf(User user) {
return Optional.ofNullable(cities.get(user.name()));
}
void main() {
users.put("ana", new User("ana", "ana@example.pt"));
users.put("ben", new User("ben", null));
cities.put("ana", "Lisbon");
IO.println(findUser("ana").map(User::email));
IO.println(findUser("ben").map(User::email));
IO.println(findUser("zoe").map(User::email));
IO.println(findUser("ana").map(User::email).filter(e -> e.endsWith(".com")));
IO.println(findUser("ana").map(this::cityOf));
IO.println(findUser("ana").flatMap(this::cityOf));
IO.println(findUser("ben").flatMap(this::cityOf));
}
Imprime:
Optional[ana@example.pt]
Optional.empty
Optional.empty
Optional.empty
Optional[Optional[Lisbon]]
Optional[Lisbon]
Optional.empty
mapaplica una función al valor. Si la función devuelvenull, como hizo elemail()de Ben, obtienes unOptionalvacío, no unOptionalque guardanull.filterconserva el valor solo si pasa la prueba. El email de Ana termina en.pt, así que el resultado está vacío.flatMapes para una función que ya devuelve unOptional.map(this::cityOf)envolvió unOptionaldentro de otro.flatMapno lo hace.
Tres métodos más completan el conjunto:
record User(String name) {}
Map<String, User> users = new HashMap<>();
Optional<User> findUser(String name) {
return Optional.ofNullable(users.get(name));
}
void main() {
users.put("ana", new User("ana"));
users.put("ben", new User("ben"));
findUser("ana").ifPresentOrElse(
u -> IO.println("hello, " + u.name()),
() -> IO.println("no such user"));
findUser("zoe").ifPresentOrElse(
u -> IO.println("hello, " + u.name()),
() -> IO.println("no such user"));
IO.println(findUser("zoe").or(() -> findUser("ana")));
List<String> found = Stream.of("ana", "zoe", "ben")
.map(this::findUser)
.flatMap(Optional::stream)
.map(User::name)
.toList();
IO.println(found);
}
Imprime:
hello, ana
no such user
Optional[User[name=ana]]
[ana, ben]
ifPresentOrElseejecuta una acción si hay valor y otra si no hay nada.orda un valor alternativo que también es unOptional, lo que sirve para una segunda búsqueda que también podría fallar.orElsete obligaría a elegir un valor simple.stream()convierte un valor en un stream de un elemento, y la ausencia de valor en un stream vacío. Junto conflatMap, quita las búsquedas fallidas de una lista, así que Zoe simplemente no aparece.
Dónde no va Optional
Optional se diseñó como tipo de retorno, y empeora el código en casi todos los demás lugares. Un parámetro de tipo Optional es el caso más claro, porque quien llama todavía puede pasar null:
String greet(Optional<String> name) {
return "Hello, " + name.orElse("guest");
}
void main() {
IO.println(greet(Optional.of("Ana")));
IO.println(greet(Optional.empty()));
IO.println(greet(null));
}
Imprime y se detiene:
Hello, Ana
Hello, guest
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.util.Optional.orElse(Object)" because "<parameter1>" is null
Ahora el parámetro tiene tres estados en vez de dos, y cada llamada tiene que envolver su argumento. Dos métodos simples, greet(String name) y greet(), dicen lo mismo con más claridad. El mensaje dice <parameter1> en vez de name porque la clase se compiló sin información de depuración. La compilamos otra vez con javac -g, y el mensaje nombró name.
Los otros lugares que conviene evitar:
- Campos. Un campo puede guardar
nully tu clase lo controla, así que revísalo en el constructor. Además,Optionalno esSerializable:Serializable.class.isAssignableFrom(Optional.class)esfalse. - Colecciones de
Optional. UnaList<Optional<User>>obliga a cada lector a desempaquetar cada elemento. Deja fuera los vacíos, como hizoflatMap(Optional::stream)más arriba. Optional<List<T>>. Una lista ya puede decir “nada”: está vacía. DevuelveList.of(), y el bucleforde cada llamada simplemente funciona, sin casos especiales.
Para primitivos, OptionalInt, OptionalLong y OptionalDouble evitan el boxing. La parte sobre streams mostró OptionalDouble saliendo de average():
void main() {
OptionalInt best = IntStream.of(72, 95, 88).max();
IO.println(best);
IO.println(best.getAsInt());
OptionalInt none = IntStream.empty().max();
IO.println(none);
IO.println(none.orElse(0));
}
Imprime:
OptionalInt[95]
95
OptionalInt.empty
0
Tienen menos métodos que Optional: no hay map, filter ni flatMap. El getter es getAsInt(), no get().
Los tipos de java.time: fecha, hora y dónde
El paquete java.time, agregado en Java 8, tiene un tipo distinto para cada pregunta que puedes hacer sobre el tiempo. La misma fecha y la misma hora del reloj de pared pueden ser dos momentos distintos, según la ciudad:
void main() {
var date = LocalDate.of(2026, 3, 29);
var time = LocalTime.of(9, 30);
var dateTime = LocalDateTime.of(date, time);
var lisbon = ZonedDateTime.of(dateTime, ZoneId.of("Europe/Lisbon"));
var tokyo = ZonedDateTime.of(dateTime, ZoneId.of("Asia/Tokyo"));
IO.println(date + " is a " + date.getDayOfWeek());
IO.println(time);
IO.println(dateTime);
IO.println(lisbon);
IO.println(tokyo);
IO.println(lisbon.toInstant());
IO.println(tokyo.toInstant());
}
Imprime:
2026-03-29 is a SUNDAY
09:30
2026-03-29T09:30
2026-03-29T09:30+01:00[Europe/Lisbon]
2026-03-29T09:30+09:00[Asia/Tokyo]
2026-03-29T08:30:00Z
2026-03-29T00:30:00Z
LocalDatees una fecha sin hora: un cumpleaños.LocalTimees una hora sin fecha: “la tienda abre a las 09:30”.LocalDateTimees las dos cosas, sin zona.ZonedDateTimeagrega una zona comoEurope/Lisbon, más el offset respecto a UTC que regía en ese momento,+01:00aquí.Instantes un punto en la línea de tiempo en UTC, impreso con unaZ. Dos computadoras en continentes distintos coinciden en unInstant.
Los dos valores con zona muestran 09:30 en la pared, pero hay ocho horas entre ellos. Cada ZoneId de este post está escrito explícitamente. ZoneId.systemDefault() devuelve la zona que tenga configurada la máquina, así que el código que lo usa da respuestas distintas en una laptop y en un servidor.
Explicado como si tuvieras diez años
Un LocalDateTime es una foto de un reloj de pared. La foto muestra las 09:30 del domingo 29 de marzo, pero no tiene idea de en qué ciudad estaba colgado el reloj. Si preguntas “¿eso fue antes o después del almuerzo en Tokio?”, la foto no puede responder.
Un ZonedDateTime es la misma foto con la ciudad escrita atrás: “Lisboa”. Ahora cualquiera, en cualquier lugar, puede calcular cuándo se tomó la foto en su propia ciudad.
La versión precisa
Un LocalDateTime es un año, mes, día, hora, minuto, segundo y nanosegundo. No identifica un momento, así que su método toInstant te obliga a pasar un offset. Un ZonedDateTime es un LocalDateTime, un ZoneId y un ZoneOffset. La zona guarda las reglas, tomadas de la base de datos tz que viene con el JDK, que dicen qué offset rige en cada momento. toInstant() resta el offset y te da UTC.
Dónde falla la analogía: el nombre de una ciudad no siempre alcanza. Algunas horas del reloj de pared ocurren dos veces en una ciudad, cuando los relojes se atrasan en otoño, y otras nunca ocurren, cuando se adelantan en primavera. Por eso un ZonedDateTime guarda el offset además de la zona. La sección sobre el horario de verano muestra los dos casos.
Los objetos de java.time nunca cambian
Todos los tipos de java.time son inmutables, así que plusDays devuelve un objeto nuevo y deja el original como estaba. Ignorar el valor de retorno es el bug clásico, y javac no avisa:
void main() {
var due = LocalDate.of(2026, 3, 29);
due.plusDays(14);
IO.println("ignored the result: " + due);
due = due.plusDays(14);
IO.println("kept the result: " + due);
}
Imprime:
ignored the result: 2026-03-29
kept the result: 2026-04-12
El primer plusDays(14) construyó una fecha nueva y la tiró. Si estás acostumbrado al viejo Calendar.add, que cambiaba el objeto, esto parece correcto y no hace nada. La ventaja de la inmutabilidad es que puedes compartir una fecha entre hilos, o usarla como clave de un map, sin que nadie te la cambie por debajo.
Sumar meses: el fin de mes se mueve
Sumar un mes a una fecha conserva el día del mes cuando puede, y usa el último día válido cuando no puede. El 31 de enero es donde se nota:
void main() {
var jan31 = LocalDate.of(2026, 1, 31);
IO.println(jan31.plusMonths(1));
IO.println(LocalDate.of(2028, 1, 31).plusMonths(1));
IO.println(jan31.plusMonths(1).plusMonths(1));
IO.println(jan31.plusMonths(2));
IO.println(LocalDate.of(2026, 3, 31).minusMonths(1));
}
Imprime:
2026-02-28
2028-02-29
2026-03-28
2026-03-31
2026-02-28
2026 no es año bisiesto, así que febrero termina el 28. 2028 sí lo es, así que termina el 29. Las líneas tercera y cuarta son la sorpresa: un mes más un mes no es dos meses. Después del primer paso, el 31 ya se convirtió en 28, y nada lo recuerda. Si cobras el último día de cada mes, calcula cada fecha a partir de la fecha inicial, como hace plusMonths(2), no a partir de la anterior.
Duration y Period
Duration mide el tiempo en segundos y nanosegundos, y Period lo mide en años, meses y días. Suenan a la misma idea, pero un mes no tiene un número fijo de segundos:
void main() {
var start = LocalDate.of(2026, 1, 31);
var end = LocalDate.of(2026, 3, 29);
IO.println(Period.between(start, end));
IO.println(ChronoUnit.DAYS.between(start, end));
var boarding = LocalDateTime.of(2026, 3, 28, 22, 45);
var landing = LocalDateTime.of(2026, 3, 29, 1, 0);
IO.println(Duration.between(boarding, landing));
IO.println(Duration.ofMinutes(135).toHours());
}
Imprime:
P1M29D
57
PT2H15M
2
Los dos se imprimen en formato ISO 8601. P1M29D es un mes y 29 días. PT2H15M es dos horas y quince minutos, donde T separa la parte de fecha de la parte de hora. ChronoUnit.DAYS.between da una cuenta simple cuando eso es lo que necesitas.
La diferencia importa más cuando hay una zona de por medio, porque un día del calendario no siempre dura 24 horas.
Parsear y formatear fechas
Todos los tipos de java.time se imprimen en formato ISO 8601, y parse vuelve a leer ese mismo formato. Para cualquier otro formato, construyes un DateTimeFormatter a partir de un patrón:
void main() {
var date = LocalDate.parse("2026-03-29");
var meeting = LocalDateTime.parse("2026-03-29T14:05");
IO.println(date.plusDays(1));
IO.println(meeting);
var pretty = DateTimeFormatter.ofPattern("EEEE d MMMM uuuu, HH:mm", Locale.US);
IO.println(meeting.format(pretty));
var european = DateTimeFormatter.ofPattern("dd/MM/uuuu");
IO.println(LocalDate.parse("05/04/2026", european));
IO.println(date.format(european));
}
Imprime:
2026-03-30
2026-03-29T14:05
Sunday 29 March 2026, 14:05
2026-04-05
29/03/2026
EEEE es el nombre completo del día y MMMM el nombre completo del mes. Esas palabras dependen de un idioma, así que el formatter pretty nombra Locale.US. Sin él, Java usa el locale de la máquina, y el mismo programa imprime domingo en una computadora configurada en portugués. El patrón european solo tiene números, así que no necesita locale. 05/04/2026 se parseó como 5 de abril, porque el patrón dice que el día va primero.
Un texto que no encaja lanza DateTimeParseException:
void main() {
try {
LocalDate.parse("29/03/2026");
} catch (DateTimeParseException e) {
IO.println("caught: " + e.getMessage());
}
IO.println(LocalDate.parse("2026-02-30"));
}
Imprime y se detiene:
caught: Text '29/03/2026' could not be parsed at index 0
Exception in thread "main" java.time.format.DateTimeParseException: Text '2026-02-30' could not be parsed: Invalid date 'FEBRUARY 30'
El primer texto tiene la forma equivocada, y el mensaje apunta al índice 0, donde el parser ISO esperaba un año de cuatro dígitos. El segundo tiene la forma correcta y una fecha imposible. parse revisa las dos cosas, así que no puedes colar un 30 de febrero en tus datos.
Horario de verano: 1 día no son 24 horas
El día en que los relojes se adelantan, un día del calendario dura 23 horas, y java.time te obliga a elegir cuál de los dos querías decir. En Lisboa en 2026, eso es el domingo 29 de marzo, cuando la 01:00 pasa a ser las 02:00:
void main() {
var lisbon = ZoneId.of("Europe/Lisbon");
var saturdayNoon = ZonedDateTime.of(2026, 3, 28, 12, 0, 0, 0, lisbon);
IO.println("start: " + saturdayNoon);
IO.println("plusDays(1): " + saturdayNoon.plusDays(1));
IO.println("plusHours(24): " + saturdayNoon.plusHours(24));
IO.println("Period.ofDays(1): " + saturdayNoon.plus(Period.ofDays(1)));
IO.println("Duration.ofDays(1): " + saturdayNoon.plus(Duration.ofDays(1)));
var hours = Duration.between(saturdayNoon, saturdayNoon.plusDays(1)).toHours();
IO.println("hours from noon to noon: " + hours);
}
Imprime:
start: 2026-03-28T12:00Z[Europe/Lisbon]
plusDays(1): 2026-03-29T12:00+01:00[Europe/Lisbon]
plusHours(24): 2026-03-29T13:00+01:00[Europe/Lisbon]
Period.ofDays(1): 2026-03-29T12:00+01:00[Europe/Lisbon]
Duration.ofDays(1): 2026-03-29T13:00+01:00[Europe/Lisbon]
hours from noon to noon: 23
La primera línea muestra una rareza: el offset de invierno de Lisboa es cero, y un offset de cero se imprime como Z, no como +00:00.
plusDays(1) trabaja sobre el calendario. Conserva la hora del reloj de pared, el mediodía, y deja que cambie el offset. plusHours(24) trabaja sobre la línea de tiempo. Suma exactamente 24 horas de tiempo real, lo que cae a las 13:00 con el nuevo offset.
Las dos últimas líneas son la trampa. Duration.ofDays(1) suena a un día, pero un Duration es un número de segundos, así que son 24 horas. Period.ofDays(1) es un día del calendario. Usa Period o plusDays para “mañana a la misma hora”, y Duration o plusHours para “exactamente 24 horas a partir de ahora”.
Desde el mediodía del sábado en Lisboa, los relojes saltan de la 01:00 a las 02:00 el domingo. plusDays(1) conserva la hora del reloj de pared, así que cae el domingo a las 12:00, solo 23 horas reales después. plusHours(24) suma 24 horas reales, así que cae el domingo a las 13:00.
Una hora que nunca ocurrió, y otra que ocurrió dos veces
El 29 de marzo de 2026, ningún reloj de Lisboa marcó la 01:30. El 25 de octubre de 2026, cuando los relojes se atrasan, la 01:30 ocurrió dos veces. Java tiene que elegir algo en los dos casos:
void main() {
var lisbon = ZoneId.of("Europe/Lisbon");
var rules = lisbon.getRules();
var inGap = LocalDateTime.of(2026, 3, 29, 1, 30);
IO.println("offsets for " + inGap + ": " + rules.getValidOffsets(inGap));
IO.println(ZonedDateTime.of(inGap, lisbon));
var inOverlap = LocalDateTime.of(2026, 10, 25, 1, 30);
IO.println("offsets for " + inOverlap + ": " + rules.getValidOffsets(inOverlap));
var first = ZonedDateTime.of(inOverlap, lisbon);
IO.println(first);
IO.println(first.withLaterOffsetAtOverlap());
}
Imprime:
offsets for 2026-03-29T01:30: []
2026-03-29T02:30+01:00[Europe/Lisbon]
offsets for 2026-10-25T01:30: [+01:00, Z]
2026-10-25T01:30+01:00[Europe/Lisbon]
2026-10-25T01:30Z[Europe/Lisbon]
- En el hueco (gap), ningún offset es válido, así que Java adelanta la hora lo que dura el hueco. La 01:30 pasó a ser las 02:30. No lanza una excepción, lo que significa que una alarma de la 01:30 suena en silencio a las 02:30.
- En el solapamiento (overlap), hay dos offsets válidos, y Java elige el primero, el offset de verano
+01:00.withLaterOffsetAtOverlap()te da la segunda 01:30, una hora después en tiempo real.
Si una tarea programada tiene que ejecutarse exactamente una vez, guarda su hora como Instant, o revisa getValidOffsets cuando conviertas una hora local en una con zona.
Si te encuentras Date y Calendar, conviértelos en el borde
java.util.Date y Calendar son las clases de fecha que Java tenía antes de Java 8. Son mutables, cuentan los meses desde cero y Date.toString() usa en silencio la zona horaria de la máquina. Todavía te los vas a encontrar en bibliotecas antiguas. Conviértelos a java.time en el momento en que entran a tu código, y de vuelta solo cuando le pases un valor a esa biblioteca:
Date lastLoginFromOldLibrary() {
return new Date(1774779330000L);
}
void main() {
Instant lastLogin = lastLoginFromOldLibrary().toInstant();
IO.println(lastLogin);
IO.println(lastLogin.atZone(ZoneId.of("Europe/Lisbon")));
Date backForTheLibrary = Date.from(lastLogin);
IO.println(backForTheLibrary.getTime());
}
Imprime:
2026-03-29T10:15:30Z
2026-03-29T11:15:30+01:00[Europe/Lisbon]
1774779330000
toInstant() y Date.from son el puente en las dos direcciones. Un Date es una cuenta de milisegundos desde 1970, así que convertirlo en un Instant no pierde nada. En la otra dirección se pierde todo lo que sea más fino que un milisegundo: probamos un Instant que terminaba en .123456789 y recibimos .123. Para un GregorianCalendar, toZonedDateTime() hace el mismo trabajo y conserva su zona.
Clock: código que puedes probar
Un método que llama a LocalDate.now() da una respuesta distinta cada día, y eso lo hace difícil de probar. Pásale un Clock, y quien llama decide qué significa “ahora”:
record Subscription(String owner, LocalDate lastDay) {}
boolean isExpired(Subscription subscription, Clock clock) {
return LocalDate.now(clock).isAfter(subscription.lastDay());
}
void main() {
var ana = new Subscription("ana", LocalDate.of(2026, 3, 29));
var lisbon = ZoneId.of("Europe/Lisbon");
var lateSunday = Clock.fixed(Instant.parse("2026-03-29T22:30:00Z"), lisbon);
var justAfter = Clock.fixed(Instant.parse("2026-03-29T23:30:00Z"), lisbon);
IO.println(LocalDate.now(lateSunday) + " expired: " + isExpired(ana, lateSunday));
IO.println(LocalDate.now(justAfter) + " expired: " + isExpired(ana, justAfter));
}
Imprime:
2026-03-29 expired: false
2026-03-30 expired: true
Clock.fixed devuelve un reloj detenido en un Instant y en una zona. Las 22:30 UTC son las 23:30 en Lisboa, todavía domingo. Una hora después son las 00:30 del lunes en Lisboa, así que la suscripción venció, aunque en UTC todavía sea domingo. Una prueba puede revisar los dos lados de la medianoche sin esperar a que llegue.
En producción pasarías Clock.system(ZoneId.of("Europe/Lisbon")), o la zona donde estén tus usuarios. Cada método now de java.time tiene una sobrecarga que recibe un Clock, así que un solo parámetro los cubre a todos.
Qué recordar
- Cualquier referencia puede ser
null, y javac no lo revisa. UsaObjects.requireNonNullen la entrada,requireNonNullElsepara valores por defecto yObjects.equalspara comparar valores que podrían sernull. - Devuelve
Optionalcuando un método puede no tener resultado. No lo uses para campos, parámetros ni colecciones, y devuelve una lista vacía en vez deOptional<List>. orElseevalúa su valor alternativo siempre, yorElseGetsolo cuando hace falta. PrefiereorElseThrow()aget(), y usaofNullablecuando un valor podría sernull.- Los objetos de
java.timeson inmutables.plusDaysdevuelve una fecha nueva, así que guarda el resultado. LocalDateTimeno tiene zona y no es un momento. UsaZonedDateTimecon unZoneIdexplícito, oInstant, cuando el momento importa.- Cuando cambia el horario de verano,
plusDays(1)yPeriodconservan la hora del reloj de pared, mientras queplusHours(24)yDurationsuman horas reales. - Pasa un
Clockal código que necesita “ahora”, y pruébalo conClock.fixed.
Di qué zona horaria quieres decir, siempre, y deja que el tipo diga si un valor puede faltar.