java.time, Clock Injection
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:
Instantas Z-suffixed UTC (e.g., 2026-02-27T12:00:00Z)OffsetDateTimeincludes 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.