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 YYYY em vez de yyyy. YYYY é week-based year. O dia 30/12/2024 formatado com YYYY vira 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 Duration a LocalDate, pois LocalDate.plus(Duration.ofHours(5)) lança UnsupportedTemporalTypeException. LocalDate nã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 LocalDateTime para timestamp de evento. LocalDateTime não conhece fuso horário, então ao ler depois você não sabe “10:00” é 10:00 aonde. Para eventos, use Instant ou OffsetDateTime/ZonedDateTime.
  • Usar ZonedDateTime para 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 ZoneId como offset. +07:00 não muda com as estações, mas Europe/Berlin muda. Para agendamentos no futuro, armazene LocalDateTime junto com ZoneId (string). As regras de DST podem mudar antes do agendamento, e este método permite recalcular corretamente.
  • Confundir getDays() / toMinutesPart() com total. Use ChronoUnit.X.between() ou toMinutes().
  • Comparar com equals() entre dois ZonedDateTime em zonas diferentes. equals() compara também a zone, então 9h em Ho Chi Minh e 22h em New York no mesmo instante retornam false. Para comparar instantes, use isEqual(), isBefore(), isAfter().
  • Chamar now() sem passar Clock no código de negócio, deixando os testes frágeis conforme a data ou hora de execução.

Cuidado: java.time nã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. TemporalAdjuster customizado é 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.

javajava-timedatetimetimezonetesting