The Java Time Bug That Still Lives in Production
A practical Joda-Time and Quarkus walkthrough explaining DST failures, silent billing errors, and how to modernize with java.time.
The incident report was short, but the consequences were not. Around midnight UTC, invoices started drifting by exactly one hour for customers in Central Europe. Nothing crashed. No alarms fired. The numbers were simply wrong, quietly and consistently. By the time finance noticed, thousands of invoices had crossed a fiscal boundary they should never have touched.
The root cause was not a bad query or a failed deployment. It was time itself, or more precisely how Java represented it. Somewhere deep in the system, a mutable Calendar instance was reused across requests. A daylight saving transition slipped in unnoticed, and a single shared object rewrote reality for every thread that touched it.
Date and time logic was one of Java’s longest-running reliability problems. The original java.util.Date and Calendar APIs were mutable, confusing, and easy to misuse in concurrent systems, which meant that time bugs often showed up not as crashes but as quiet financial or reporting errors. Joda-Time emerged as a disciplined replacement long before Java itself acknowledged the problem, introducing immutability, explicit time zones, and a model that separated calendar concepts from elapsed time.
For more than a decade, Joda-Time became the de facto standard in serious Java systems. You will still find it today in long-lived enterprise applications, billing platforms, audit pipelines, financial services, and integration layers that were written before Java 8 and have survived multiple framework migrations. It rarely appears in new greenfield projects, but it remains deeply embedded in production code that cannot simply be rewritten.
That matters because the recommended approach today is no longer Joda-Time. Since Java SE 8, the platform provides java.time (JSR-310), a modern date and time API inspired directly by Joda-Time’s design. For all new code, java.time is the correct choice and is actively maintained as part of the JDK. This tutorial does not argue otherwise.
What this tutorial does instead is explain why Joda-Time was built the way it was, how it models time correctly, and where those ideas still surface in real systems you are likely to maintain. We will build and analyze a small Quarkus service using Joda-Time on purpose, then close by showing how the same semantics map cleanly to java.time when modernization is possible.
The goal is not to teach an obsolete library. The goal is to help you recognize, reason about, and safely evolve the time logic that already runs in production.
A Quarkus service that lives in legacy time
What we are building
We are going to build a small but realistic Quarkus service that calculates billable time windows for customer subscriptions. The rules are simple enough to follow, but strict enough to fail if time handling is wrong. All logic is implemented with Joda-Time, exactly as you would find it in a long-lived enterprise system that cannot be rewritten overnight.
You need Java 21, Maven, and the Quarkus CLI installed.
We start with a clean Quarkus application using REST, because production services do not block threads just to compute timestamps.
quarkus create app com.example.billing:billing-time \
--extension=quarkus-rest-jackson
cd billing-timeJoda-Time is not a Quarkus extension. That is intentional. Legacy systems rarely get first-class integration.
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.14.0</version>
</dependency>At this point the service starts instantly and does nothing useful. That is exactly how most real systems begin. If you just want to sneak at the code, feel free to grab it from my Github repository.
Modeling time the way Joda-Time expects you to
Why LocalDate is the center of the model
Billing logic is calendar-based, not instant-based. A billing window starts on a day and ends on a day. Time zones only matter when those days are translated into real instants.
Joda-Time calls these concepts partials. They intentionally do not carry time or zone information.
package com.example.billing.domain;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.joda.time.Interval;
import org.joda.time.LocalDate;
public class BillingWindow {
private final LocalDate startDate;
private final LocalDate endDate;
private final DateTimeZone customerZone;
public BillingWindow(LocalDate startDate, LocalDate endDate, DateTimeZone customerZone) {
this.startDate = startDate;
this.endDate = endDate;
this.customerZone = customerZone;
}
public Interval toInterval() {
DateTime start = startDate.toDateTimeAtStartOfDay(customerZone);
DateTime end = endDate.plusDays(1).toDateTimeAtStartOfDay(customerZone);
return new Interval(start, end);
}
public LocalDate getStartDate() {
return startDate;
}
public LocalDate getEndDate() {
return endDate;
}
public DateTimeZone getCustomerZone() {
return customerZone;
}
}The critical detail is toDateTimeAtStartOfDay. The Joda-Time user guide explicitly recommends this method because it safely resolves DST gaps and overlaps. Manual midnight arithmetic is how systems get audited.
Exposing the logic through a REST boundary
Making time bugs observable
The service exposes a single endpoint that turns calendar input into a billable interval. This is where most real-world bugs surface.
package com.example.billing.api;
import com.example.billing.domain.BillingWindow;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import org.joda.time.DateTimeZone;
import org.joda.time.Interval;
import org.joda.time.LocalDate;
@Path("/billing/window")
public class BillingWindowResource {
@GET
public BillingWindowResponse calculate(
@QueryParam("start") String start,
@QueryParam("end") String end,
@QueryParam("zone") String zoneId) {
LocalDate startDate = LocalDate.parse(start);
LocalDate endDate = LocalDate.parse(end);
DateTimeZone zone = DateTimeZone.forID(zoneId);
BillingWindow window = new BillingWindow(startDate, endDate, zone);
Interval interval = window.toInterval();
return new BillingWindowResponse(
interval.getStart().toString(),
interval.getEnd().toString(),
interval.toDuration().getStandardHours()
);
}
}
package com.example.billing.api;
public record BillingWindowResponse(
String intervalStart,
String intervalEnd,
long durationHours) {
}Parsing happens explicitly. Zones are resolved once. Nothing is implicit.
Reproducing the DST bug on purpose
Why 48 hours sometimes equals 47
On paper, the billing window from March 29 to March 30 looks trivial. Two dates. Two midnights. Anyone reading the contract would instinctively multiply two days by twenty-four hours and move on. That intuition is exactly where most systems go wrong.
The mistake is assuming that a calendar day is a fixed unit of elapsed time. It is not. A calendar day is a label applied by humans to a stretch of time that the clock does not guarantee to be uniform. The clock follows civil time rules, and civil time is allowed to jump.
In Central Europe, the DST transition happens on the night between Saturday and Sunday in late March. At 02:00 local time, the clock jumps directly to 03:00. That hour never exists. No amount of arithmetic can recover it, because it was never part of the timeline in that zone.
When your billing window starts at 2025-03-29T00:00 in Europe/Berlin, that instant is still in winter time, UTC+01:00. When it ends at 2025-03-31T00:00, that instant is already in summer time, UTC+02:00. The calendar says two days passed. The clock says only forty-seven hours elapsed between those two instants.
This is the critical distinction Joda-Time forces you to confront. A calendar day answers the question “which day is this?” An elapsed hour answers the question “how much time actually passed?” These questions only align most of the year. During DST transitions, they diverge, and pretending they do not is how systems overcharge or undercharge without throwing errors.
The reason Joda-Time returns forty-seven hours is not cleverness. It is honesty. It constructs two real instants on the global timeline and measures the distance between them. The missing hour is not subtracted. It is simply not there to be counted.
This is also why toDateTimeAtStartOfDay(zone) matters so much. Midnight is not chosen because it is aesthetically pleasing. It is chosen because Joda-Time guarantees that “start of day” resolves safely even when midnight itself is ambiguous or skipped. The library applies the zone rules first, then produces a valid instant. Doing this manually with withHourOfDay(0) often lands you in nonexistent time.
The uncomfortable takeaway is that neither side is wrong. Finance thinks in days. The JVM counts milliseconds. Both are correct within their own models. The bug only appears when a system quietly switches models without acknowledging the boundary.
That is why this example matters. Not because DST is exotic, but because it is mundane. It happens every year. The fact that forty-eight hours can equal forty-seven is not an edge case. It is a reminder that time is not arithmetic unless you make it so, explicitly and with intent.
Run the service.
quarkus devNow call it across a DST boundary.
curl "http://localhost:8080/billing/window?start=2025-03-29&end=2025-03-30&zone=Europe/Berlin"Expected output:
{
"intervalStart": "2025-03-29T00:00:00.000+01:00",
"intervalEnd": "2025-03-31T00:00:00.000+02:00",
"durationHours": 47
}Nothing is broken. The calendar is telling the truth. One hour never existed. Joda-Time models that reality instead of guessing.
Duration versus Period: machines versus humans
Why finance and CPUs ask different questions
Right now, the service answers only one question: how much time actually elapsed? Finance and contracts usually ask another one right after: how many calendar days does this billing window represent?
The mistake many systems make is trying to stretch Duration to answer both questions. Joda-Time gives you Period precisely so you do not have to.
A Period expresses calendar units. It answers human questions. Extend the BillingWindow.java with below:
import org.joda.time.Period;
import org.joda.time.PeriodType;
public Period toCalendarPeriod() {
return new Period(
startDate.toDateTimeAtStartOfDay(customerZone),
endDate.plusDays(1).toDateTimeAtStartOfDay(customerZone),
PeriodType.days()
);
}This method does not compete with toInterval(). They intentionally coexist. One measures elapsed time. The other measures calendar intent.
The important subtlety here is PeriodType.days(). Without it, Joda-Time might express the result as months or weeks depending on the span. By constraining it to days, you encode the business meaning directly into the API.
Instead of choosing which representation is “correct,” we let the service exposes both. This mirrors how real billing systems work internally, even if only one value is eventually persisted.
Update the response DTO to carry both concepts.
package com.example.billing.api;
public record BillingWindowResponse(
String intervalStart,
String intervalEnd,
long durationHours,
int calendarDays) {
}And wire it into the resource:
import org.joda.time.Period;
Period calendarPeriod = window.toCalendarPeriod();
return new BillingWindowResponse(
interval.getStart().toString(),
interval.getEnd().toString(),
interval.toDuration().getStandardHours(),
calendarPeriod.getDays()
);At this point, the API is no longer ambiguous. It tells the caller exactly what kind of truth each number represents.
Observe the DST boundary again
Call the same endpoint across the DST transition.
curl "http://localhost:8080/billing/window?start=2025-03-29&end=2025-03-30&zone=Europe/Berlin"Now the response becomes self-explanatory:
{
"intervalStart": "2025-03-29T00:00:00.000+01:00",
"intervalEnd": "2025-03-31T00:00:00.000+02:00",
"durationHours": 47,
"calendarDays": 2
}Nothing here contradicts anything else. Two calendar days were billed. Only forty-seven hours elapsed. The system did not “lose” an hour. It simply refused to lie about it.
The one mutable class that caused outages
Why mutability is explicit in Joda-Time
Joda-Time includes mutable types for tight, single-threaded loops. They are not thread-safe and must never escape method scope.
import org.joda.time.MutableDateTime;
public class BrokenClock {
private static final MutableDateTime SHARED = MutableDateTime.now();
public static MutableDateTime now() {
SHARED.addMinutes(1);
return SHARED;
}
}If you recognize this pattern, you have debugged time bugs at scale. Joda-Time did not remove mutability. It forced you to acknowledge it.
Freezing time in tests
The feature everyone forgets
The user guide strongly recommends controlling time in tests.
import org.joda.time.DateTimeUtils;
DateTimeUtils.setCurrentMillisFixed(
new DateTime(2025, 3, 29, 12, 0, DateTimeZone.UTC).getMillis()
);Always reset after the test.
DateTimeUtils.setCurrentMillisSystem();This single utility has saved more test suites than most mocking frameworks.
Interoperating with legacy Java APIs
Crossing the unavoidable boundary
java.util.Date represents an instant, not a time zone. Joda-Time makes that explicit.
import org.joda.time.DateTime;
import java.util.Date;
public class LegacyBridge {
public static Date toLegacy(DateTime dateTime) {
return dateTime.toDate();
}
public static DateTime fromLegacy(Date date) {
return new DateTime(date);
}
}Conversions are visible, deliberate, and auditable.
Production hardening the design
Why verbosity beats cleverness
This service makes several conscious tradeoffs. It avoids JVM default time zones in business logic. It applies zones exactly once. It models calendar intent with partials. The cost is a few more lines of code. The benefit is that DST audits become boring.
This is how legacy systems stay alive.
Verification the way auditors do it
package com.example.billing;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.is;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
public class BillingWindowTest {
@Test
void dstBoundaryIsHandledCorrectly() {
given()
.queryParam("start", "2025-03-29")
.queryParam("end", "2025-03-30")
.queryParam("zone", "Europe/Berlin")
.when()
.get("/billing/window")
.then()
.statusCode(200)
.body("durationHours", is(47));
}
}This test fails in systems that lie about time.
Migration: moving from Joda-Time to java.time without breaking reality
Migration is usually presented as a mechanical exercise. Replace one type with another, fix the compiler errors, move on. That approach works for logging timestamps and DTOs, and it catastrophically fails for billing, scheduling, and audit logic. The reason is simple: Joda-Time and java.time share names, but what really matters are the questions your code is answering.
The good news is that java.time was designed by the same author, with the same mental model. That means a careful migration preserves semantics almost line by line. The bad news is that careless migrations silently change meaning while still compiling cleanly.
The only safe way forward is to migrate by concept, not by type.
Start by identifying which questions the code is asking
Before touching any code, you need to classify how time is used in the system. Some code asks “what instant is this?” Some asks “which calendar day does this belong to?” Some asks “how much time actually passed?” Others ask “how many business days does this represent?” Joda-Time forced these questions into different types. java.time does the same, but it will not stop you from mixing them if you rush.
In the example service, the distinction was explicit. LocalDate expressed billing intent. Interval and Duration expressed elapsed time. Period expressed calendar meaning. That structure is the reason migration is possible at all.
If your existing code collapses these concepts into DateTime everywhere, migration should pause until that is fixed. Replacing confusion with modern confusion is not progress.
Replace boundary types first, not core logic
The safest migration strategy is to start at the edges of the system. Parsing, formatting, REST APIs, database mappings, and serialization layers should move first. These areas tend to use Joda-Time as a transport type rather than as business logic.
A Joda-Time DateTime used only to parse an ISO string maps cleanly to ZonedDateTime. A LocalDate used in request parameters maps directly to java.time.LocalDate. These changes reduce dependency surface area without touching the core of the system.
Only after the boundaries are stable should you migrate internal logic. This keeps failures local and debuggable.
Map semantics, not syntax
Here is where many migrations go wrong. Seeing that DateTime maps to ZonedDateTime is correct but insufficient. The real question is whether the original DateTime was used as an instant, a calendar anchor, or a convenience wrapper.
In the billing example, the Joda-Time code intentionally converted LocalDate to an instant at start of day in a specific zone. The java.time equivalent must do the same thing, in the same place, for the same reason.
ZonedDateTime start = startDate.atStartOfDay(zone);
ZonedDateTime end = endDate.plusDays(1).atStartOfDay(zone);This is not just syntactic similarity. It preserves the same DST behavior, the same calendar boundaries, and the same guarantees about nonexistent hours. If instead you migrate to LocalDateTime and attach a zone later, you have already changed the meaning.
The migration rule is simple but strict: if the Joda-Time code applied a time zone at a specific boundary, the java.time code must apply it at the same boundary.
Treat Duration and Period as different migrations
One of the most dangerous migration mistakes is collapsing Period into Duration because both exist in java.time. They look interchangeable. They are not.
In Joda-Time, Period answered human questions and was calendar-sensitive. In java.time, Period does exactly the same. If your code used PeriodType.days() to express billing days, migrating that logic to Duration.ofDays() changes behavior across DST transitions. The compiler will not warn you. Finance eventually will.
If the original code expressed calendar days, migrate to java.time.Period. If it expressed elapsed time, migrate to java.time.Duration. If both existed, both must survive the migration.
Remove Joda-Time only after the semantics are proven
A common temptation is to remove the Joda-Time dependency as soon as the code compiles. That is premature. During migration, it is often safer to allow Joda-Time and java.time to coexist temporarily, as long as they do not mix within the same method.
This allows you to run old and new logic side by side, compare outputs across DST boundaries, and prove equivalence under production-like scenarios. Once the numbers match under stress, the dependency can be removed with confidence.
This approach feels slower. It is dramatically faster than investigating silent billing discrepancies months later.
Why this migration works when others fail
The reason this tutorial migrates cleanly is not because Joda-Time and java.time are similar. It is because the original code respected the conceptual boundaries Joda-Time enforced. Calendar intent, elapsed time, and instants were never conflated.
If you adopt the same discipline before migrating, java.time becomes a natural continuation rather than a risky rewrite. The code remains readable. The tests remain meaningful. The system continues to tell the truth about time.
Migration, in this context, is not modernization for its own sake. It is an opportunity to make the system explicit about assumptions it was already making. When done correctly, removing Joda-Time is not an act of deletion. It is an act of confirmation.
Time does not care which API you use. It only cares whether you ask the right questions.
Core type mapping
DateTime→ZonedDateTimeLocalDate→LocalDateInterval→Instantpairs orDurationDuration→DurationPeriod→PeriodDateTimeZone→ZoneId
Billing window rewritten with java.time
package com.example.billing.domain;
import java.time.Duration;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
public class BillingWindowModern {
private final LocalDate startDate;
private final LocalDate endDate;
private final ZoneId zone;
public BillingWindowModern(LocalDate startDate, LocalDate endDate, ZoneId zone) {
this.startDate = startDate;
this.endDate = endDate;
this.zone = zone;
}
public Duration toDuration() {
ZonedDateTime start = startDate.atStartOfDay(zone);
ZonedDateTime end = endDate.plusDays(1).atStartOfDay(zone);
return Duration.between(start, end);
}
}The structure is identical. The semantics are identical. The safety comes from the same discipline.
Migration strategy that works
Migrate at boundaries. Replace parsing and formatting first. Convert internal models last. Never mix Joda-Time and java.time in the same method.
This is how teams modernize without breaking billing.
Joda-Time taught Java developers to model time instead of guessing it
It forced immutability, made zones explicit, and separated machine time from human calendars. Those lessons still protect production systems today, even when the library itself is no longer fashionable.
Time does not forgive shortcuts, and neither do invoices.



Excellent breakdown on the DST billing trap. The fact that toDateTimeAtStartOfDay handles ambigious midnight cases is somethng most legacy systems miss completely. I ran into this exact issue on an invocing system where we'd just use withHourOfDay(0) and wondered why Q1 reconciliations always had timezone-specific discrepancies.