JAVA Contents

java.time, Clock Injection

Use java.time types intentionally, model time as Instants and Durations internally, inject Clock for deterministic tests, and avoid subtle arithmetic and persistence pitfalls that cause production time bugs.

On this page

java.time is a modeling tool, not just an API

Most production time bugs come from using the wrong type for the job. The java.time API gives you the vocabulary to model time correctly, but you must choose intentionally.

Core types and what they mean

  • Instant: a point on the UTC timeline. Best default for events, createdAt, updatedAt, expirations.
  • Duration: machine time elapsed (milliseconds/seconds). Best for timeouts, TTLs, backoff.
  • LocalDate: calendar date without time. Best for billing dates, birthdays, daily reports.
  • LocalDateTime: date+time without timezone. Dangerous unless you always reattach a known zone.
  • ZonedDateTime: civil time + zone rules. Best for schedules.
  • OffsetDateTime: time + fixed offset. OK for APIs, but not a replacement for zone rules.

Production baseline

  • Internally: use Instant and Duration.
  • At boundaries: convert to/from user zones using ZonedDateTime.
  • For calendar semantics: store LocalDate (not an Instant).

Instant vs LocalDate: a real business example

Consider subscription billing: 'charge on the 1st of each month'. That is a calendar concept. You should model it as LocalDate (or YearMonth) plus business rules, not as a fixed Instant repeating every 30 days.

Anti-pattern: month billing via Duration

// Wrong for calendar billing: months are not a fixed number of seconds
Instant next = now.plus(Duration.ofDays(30));

Correct approach: calendar arithmetic

import java.time.*;

ZoneId zone = ZoneId.of("Europe/Istanbul");
ZonedDateTime znow = ZonedDateTime.now(zone);

ZonedDateTime nextBilling = znow
  .withDayOfMonth(1)
  .plusMonths(1)
  .withHour(9).withMinute(0).withSecond(0).withNano(0);

Time arithmetic: prefer Duration for machine time

Timeouts, TTLs, retries, and backoffs should use Duration. Do not store raw milliseconds with unclear semantics.

Duration timeout = Duration.ofMillis(1500);
Duration ttl = Duration.ofMinutes(15);

Truncation and rounding pitfalls

Truncation bugs appear when you mix seconds and milliseconds, or when you convert between types incorrectly.

Common bug: seconds vs milliseconds

long nowSeconds = Instant.now().getEpochSecond();
Thread.sleep(nowSeconds); // catastrophic: expects milliseconds

Production result: a sleep for billions of milliseconds, or overflow behavior. Always name units in variables.

Correct: explicit unit naming

long nowEpochMillis = Instant.now().toEpochMilli();
long nowEpochSeconds = Instant.now().getEpochSecond();

Duration conversions

Duration d = Duration.ofSeconds(2);
long ms = d.toMillis();

Clock injection: determinism and reproducibility

Direct calls to Instant.now() in business logic make tests flaky and make incident reproduction harder. Inject Clock to control time.

Example: a TTL-based token

import java.time.*;

public final class ApiToken {
  public final String value;
  public final Instant expiresAt;

  public ApiToken(String value, Instant expiresAt) {
    this.value = value;
    this.expiresAt = expiresAt;
  }

  public boolean isExpired(Clock clock) {
    return Instant.now(clock).isAfter(expiresAt);
  }
}

Example: service with injected clock

public final class TokenService {
  private final Clock clock;

  public TokenService(Clock clock) {
    this.clock = clock;
  }

  public ApiToken issue(String value, Duration ttl) {
    Instant now = Instant.now(clock);
    return new ApiToken(value, now.plus(ttl));
  }
}

Test with a fixed clock

Clock fixed = Clock.fixed(Instant.parse("2026-02-27T12:00:00Z"), ZoneOffset.UTC);
TokenService svc = new TokenService(fixed);

ApiToken t = svc.issue("abc", Duration.ofMinutes(10));
System.out.println(t.expiresAt); // 2026-02-27T12:10:00Z

Distributed systems: clock skew is real

In distributed environments, different nodes may have slightly different clocks. This matters for:

  • token expiry checks
  • deduplication windows
  • scheduled tasks
  • ordering based on timestamps

Practical mitigations:

  • Prefer server-side timestamps generated by a single authority (DB, event store) where ordering matters.
  • Use leeway windows for expiry (e.g., accept tokens that expire within a small skew window).
  • Monitor NTP drift (ops topic, but the code should tolerate minor skew).

Persistence: store time without ambiguity

Your persistence layer must define clear semantics:

  • If you store Instants, store them as UTC timestamps (or epoch millis).
  • If you store LocalDate, store only the date, not a midnight Instant (midnight depends on zone and DST).

Anti-pattern: storing LocalDate as midnight Instant

// Dangerous: midnight changes with DST and zone rules
Instant midnight = localDate.atStartOfDay(zone).toInstant();

If you need the date, store the date. If you need a moment, store an Instant.

Serialization: ISO-8601 always

For APIs and logs, ISO-8601 is the standard:

  • Instant as Z-suffixed UTC (e.g., 2026-02-27T12:00:00Z)
  • OffsetDateTime includes offset (e.g., 2026-02-27T15:00:00+03:00)

Scheduling: machine time vs civil time

When you schedule tasks, decide whether the schedule is:

  • machine time: run every 10 minutes (Duration-based)
  • civil time: run every day at 09:00 in a timezone (ZonedDateTime-based)

Mixing these concepts is a common source of DST bugs.

Checklist

  • Use Instant and Duration as internal defaults.
  • Use LocalDate for calendar concepts, not midnight Instants.
  • Use ZonedDateTime for civil schedules (with explicit ZoneId).
  • Inject Clock into business logic for deterministic tests.
  • Name time units in variables (ms vs s) to avoid truncation bugs.
  • Plan for clock skew; avoid strict equality on time across nodes.
  • Serialize times as ISO-8601.

Final principle

Time bugs are rarely about the API. They are about the model. java.time gives you the vocabulary; production correctness comes from choosing the right words.