Date-time bugs are common in distributed systems:

  • wrong timezone assumptions
  • DST edge-case failures
  • inconsistent serialization formats
  • mixing local and global time concepts

Java 8 java.time API solves most of these when modeled correctly.


Core Types and When to Use Them

  • Instant: machine timestamp in UTC (event time, audit fields)
  • LocalDate: date without timezone (birth date, business date)
  • LocalDateTime: date+time without zone (rare for persisted events)
  • ZonedDateTime: date+time with timezone (UI/business timezone logic)
  • OffsetDateTime: date+time with offset (API payloads)

Rule: persist timeline events as Instant.


Persist and Display Pattern

Persist in UTC:

Instant createdAt = Instant.now();

Display in user timezone:

ZoneId userZone = ZoneId.of("America/Los_Angeles");
String display = ZonedDateTime.ofInstant(createdAt, userZone)
        .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z"));

This avoids server timezone coupling.


Parsing and Formatting API Timestamps

ISO-8601 input from API:

Instant ts = Instant.parse("2026-03-07T10:15:30Z");

Offset timestamp parsing:

OffsetDateTime odt = OffsetDateTime.parse("2026-03-07T15:45:00+05:30");
Instant normalized = odt.toInstant();

For external integrations, always normalize to Instant internally.


DST Edge Case Example

ZoneId zone = ZoneId.of("America/New_York");
LocalDateTime local = LocalDateTime.of(2026, 3, 8, 2, 30); // DST transition day
ZonedDateTime zoned = local.atZone(zone);

Some local times are invalid or ambiguous during DST transitions. Use ZonedDateTime and clear business rules for scheduling.


LocalDateTime Persistence Trap

LocalDateTime has no timezone/offset. Persisting it for global events can create ambiguity across regions.

Bad for event timestamps:

LocalDateTime createdAt = LocalDateTime.now(); // ambiguous globally

Preferred:

Instant createdAt = Instant.now(); // unambiguous timeline point

Use LocalDateTime only when timezone is intentionally irrelevant.


Expiry and Duration Logic

Use Duration or Period instead of manual millis arithmetic.

Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(Duration.ofMinutes(15));
boolean expired = Instant.now().isAfter(expiresAt);

API Contract Recommendation

For external JSON APIs, standardize on ISO-8601 UTC strings:

{
  "createdAt": "2026-03-07T10:15:30Z"
}

Avoid custom date formats unless integration requires it. If custom format is mandatory, define formatter centrally and version the contract.


Database Mapping Guidance

  • prefer DB types that preserve timezone semantics (TIMESTAMP WITH TIME ZONE where supported)
  • if DB lacks true timezone support, store epoch millis/seconds or UTC instant strings consistently
  • never depend on DB/session local timezone for business correctness

Consistency across app + DB + analytics pipelines is more important than local convenience.


Testing Time-Dependent Logic

Inject Clock instead of calling Instant.now() everywhere.

public class TokenService {
    private final Clock clock;

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

    public Instant issuedAt() {
        return Instant.now(clock);
    }
}

Deterministic test:

Clock fixed = Clock.fixed(Instant.parse("2026-03-07T00:00:00Z"), ZoneOffset.UTC);

Architecture Rules for Distributed Systems

  • store timestamps as UTC Instant
  • convert to user timezone only at presentation layer
  • standardize API format to ISO-8601
  • avoid legacy java.util.Date/Calendar in new code
  • centralize time utilities and timezone policy

Related Posts