java.time: LocalDate, ZonedDateTime e Duration
1. Visão geral
Processamento de data e hora é fonte familiar de bugs: faturas atrasadas um dia, agendas com fusos horários errados, jobs executados duas vezes durante mudança de horário de verão. O pacote java.time (Java 8+) resolve a maioria desses problemas, desde que você escolha o tipo de dado correto.
Este artigo se concentra em três tipos mais usados: LocalDate, ZonedDateTime e Duration. Após ler, você saberá quando usar cada tipo e como lidar com conversões de fuso horário e horário de verão (DST). Você também aprenderá a escrever testes que não dependem do momento da execução.
2. Por que usar java.time
java.util.Date e Calendar têm muitos problemas. São mutáveis, então não são thread-safe. Meses são contados a partir de 0, e o nome Date é enganoso porque na verdade representa um instante. java.time corrige esses pontos:
- Todo objeto é imutável e thread-safe. Métodos como
plusDays()sempre retornam um novo objeto. - Cada conceito tem seu próprio tipo:
| Tipo | Significado | Exemplo |
|---|---|---|
LocalDate |
Data, sem hora, sem fuso horário | Data de nascimento, data de vencimento |
LocalDateTime |
Data + hora, sem fuso horário | Hora “no calendário” sem localidade |
ZonedDateTime |
Data + hora + ZoneId |
Reunião às 9h em Ho Chi Minh |
Instant |
Um ponto na linha do tempo (UTC) | Timestamp armazenado em BD, log |
Duration |
Intervalo de tempo em segundos/nano | Timeout de 30 segundos |
Period |
Intervalo de tempo em anos/meses/dias | Pacote de 3 meses |
Não é necessário adicionar nenhuma dependência porque tudo já está no JDK. Para executar os exemplos de teste, você precisa JUnit 5 e AssertJ (já inclusos se usar 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: trabalhando com datas
3.1. Criando e fazendo parse
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() aceita mês de 1 a 12. Você também pode passar Month.MAY para melhor legibilidade. parse() sem formatter usa o padrão ISO-8601 (yyyy-MM-dd). Para outros formatos, você cria um DateTimeFormatter e deve declará-lo como constante static final, pois formatter também é thread-safe.
Cuidado: Não escreva
YYYYem vez deyyyy.YYYYé week-based year. O dia 30/12/2024 formatado comYYYYvira o ano 2025. Esse erro geralmente só aparece no final do ano.
3.2. Adicionando e subtraindo, comportamento no final do mês
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
Fevereiro/2024 não tem dia 31, então plusMonths(1) recua para o último dia válido, que é 29. Como consequência, somar um mês duas vezes difere de somar dois meses uma vez. Se você calcula ciclos de pagamento mensais, sempre calcule a partir da data original, por exemplo start.plusMonths(n). Não some incrementalmente a partir do resultado anterior.
3.3. TemporalAdjusters
Perguntas como “último dia do mês” ou “próxima segunda-feira” têm ajustadores prontos:
LocalDate today = LocalDate.of(2024, 5, 15); // quarta-feira
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. Intervalo entre duas datas: Period e 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 é um erro muito comum. period.getDays() retorna apenas a parte de dias (24) de “1 mês 24 dias”, não o total de dias. Para o total de dias, use ChronoUnit.DAYS.between().
4. ZonedDateTime: data e hora ligadas a um fuso horário
4.1. Criando e convertendo fuso horário
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-05-31T22:00-04:00[America/New_York]
2024-06-01T09:00-04:00[America/New_York]
Espera, isso ainda não é a saída completa. A saída correta é:
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]
Esses dois métodos diferem no seguinte:
withZoneSameInstant()preserva o instante. 9h da manhã em Ho Chi Minh é 22h à noite do dia anterior em New York (usando horário de verão, UTC-4). Este é o método que você precisa em 95% dos casos, por exemplo ao exibir uma reunião para alguém em outro fuso horário.withZoneSameLocal()preserva a hora no relógio e muda para um instante diferente. Este método é usado apenas quando você intencionalmente reatribui o fuso horário, por exemplo quando dados de entrada foram atribuídos ao fuso errado.
Sempre use IDs no formato Região/Cidade como Europe/Berlin. Evite abreviações como EST ou ICT porque são ambíguas. Offsets fixos como +07:00 também não sabem nada sobre regras de horário de verão.
4.2. Horário de verão: o intervalo que “não existe”
No dia 31/03/2024 na Alemanha, o relógio pula de 02:00 direto para 03:00. Portanto, 02:30 naquele dia não existe:
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 não lança exception mas avança a hora exatamente pela duração do intervalo (1 hora). Na direção oposta, quando o horário de verão termina, uma hora no relógio aparece duas vezes. Por padrão java.time escolhe o offset anterior. Você pode mudar com withLaterOffsetAtOverlap().
4.3. Instant: o que deve ser armazenado no banco de dados
Quando precisa armazenar “quando um evento ocorreu”, converta para Instant (ou tipo TIMESTAMP WITH TIME ZONE no 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]
A regra geral é armazenar Instant e converter para ZonedDateTime apenas ao exibir para o usuário no seu fuso horário.
5. Duration: medindo intervalos de tempo precisamente
5.1. Criando e lendo 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() retorna o total de minutos. Já toHoursPart() e toMinutesPart() (Java 9+) separam em partes para exibição. Este é o mesmo tipo de armadilha com Period.getDays() da seção 3.4.
5.2. Duration e Period: “1 dia” nem sempre é 24 horas
Este é o ponto de diferença mais importante do artigo:
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
O resultado acima mostra três coisas:
plusDays(1)adiciona pelo calendário, então mantém 12:00 à tarde no dia seguinte. Na verdade entre os dois instantes há apenas 23 horas.plus(Duration.ofDays(1))adiciona exatamente 24 horas, então cai em 13:00.Duration.between()confirma que a distância real é 23 horas.
A regra para escolher o tipo é muito simples. Se o usuário pensa em calendário (“lembre-me toda manhã às 9h”, “pacote de 1 mês”), use Period ou plusDays/plusMonths. Se o sistema mede tempo decorrido (timeout, SLA de 24 horas, rate limit), use Duration.
Cuidado: Não é possível adicionar
DurationaLocalDate, poisLocalDate.plus(Duration.ofHours(5))lançaUnsupportedTemporalTypeException.LocalDatenão tem hora, então unidades menores que dia são sem sentido.
6. Escrevendo código fácil de testar com Clock
Chamar LocalDate.now() diretamente na lógica de negócio torna o teste dependente da data de execução. A forma correta é injetar 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);
}
}
Todo método now() de java.time tem uma sobrecarga que aceita Clock. No Spring Boot 3.x, você declara um bean:
@Configuration
public class TimeConfig {
@Bean
public Clock clock() {
return Clock.system(ZoneId.of("Asia/Ho_Chi_Minh"));
}
}
Use Clock.system(...) com zone explícito em vez de systemDefaultZone(). Assim o resultado não muda quando o servidor roda em UTC mas a máquina de dev roda em horário do Vietnã.
No teste, você fixa a hora com Clock.fixed():
class SubscriptionServiceTest {
private static final ZoneId HCM = ZoneId.of("Asia/Ho_Chi_Minh");
// 03:00 UTC = 10:00 da manhã de 15/05/2024 em 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));
}
}
O segundo teste verifica limites: um pacote expira no dia 15/05 mas ainda é válido durante o próprio dia 15/05. O último teste “congela” o comportamento de DST da seção 5.2. Assim, se alguém mudar plusDays para plus(Duration.ofDays(1)), o teste vai denunciar.
Um detalhe fácil de esquecer. Se você usar Instant.parse("2024-05-14T18:00:00Z") como instante fixado, LocalDate.now(clock) em Ho Chi Minh será 15/05 (01:00 da manhã). Em UTC ainda é 14/05. Por isso a zone do Clock define qual é “hoje”.
7. Erros comuns e quando não usar
- Usar
LocalDateTimepara timestamp de evento.LocalDateTimenão conhece fuso horário, então ao ler depois você não sabe “10:00” é 10:00 aonde. Para eventos, useInstantouOffsetDateTime/ZonedDateTime. - Usar
ZonedDateTimepara data pura. Data de nascimento ou feriado éLocalDate. Se você anexar fuso horário, ao converter zone a data pode “vagar” para o dia anterior. - Armazenar
ZoneIdcomo offset.+07:00não muda com as estações, masEurope/Berlinmuda. Para agendamentos no futuro, armazeneLocalDateTimejunto comZoneId(string). As regras de DST podem mudar antes do agendamento, e este método permite recalcular corretamente. - Confundir
getDays()/toMinutesPart()com total. UseChronoUnit.X.between()outoMinutes(). - Comparar com
equals()entre doisZonedDateTimeem zonas diferentes.equals()compara também a zone, então 9h em Ho Chi Minh e 22h em New York no mesmo instante retornamfalse. Para comparar instantes, useisEqual(),isBefore(),isAfter(). - Chamar
now()sem passarClockno código de negócio, deixando os testes frágeis conforme a data ou hora de execução.
Cuidado:
java.timenão substitui bibliotecas de calendário de negócio. Dias úteis, feriados do Vietnã ou calendário lunar precisam de lógica própria ou biblioteca especializada.TemporalAdjustercustomizado é o lugar apropriado para encapsular essa lógica.
8. Conclusão
Escolher o tipo de dado correto é o passo mais importante. Use LocalDate para datas no calendário, ZonedDateTime para hora ligada a um lugar, e Instant para armazenar instantes. Com intervalos de tempo, Period segue o calendário enquanto Duration segue o relógio, e na seção 5.2 você viu que eles dão resultados diferentes com horário de verão.
Você também deve injetar Clock no service e declarar a zone explicitamente. Escreva testes para casos limites como final de mês, mudança de horário de verão e meia-noite UTC. Fazendo isso, a maioria dos bugs de data e hora será bloqueada antes de chegar à produção.