Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/the-adr-said-utc-and-one-switch-unsaid-it && read --section="top" 0%
.NET

The ADR Said UTC and One Switch Unsaid It

A decision record mandates UTC everywhere and an IClock abstraction, and thirty files honour it - then one process-global AppContext switch in shared plumbing makes every timestamp column forget it was UTC.

By Kumar Chandrachooda 06 Dec 2025 6 min read
A clock face beside a toggle switch that has flipped

Part 14 was about a number chosen deliberately. This part is about a convention chosen deliberately, documented properly, adopted widely — and then undone by one line in a file nobody reads.

Time is the domain concern this estate takes most seriously. It has four kinds of deadline, a polling scheduler, an offer validity window with a cashier buffer subtracted from it, notification offsets at seven days, one day, six hours and one hour, and a specification called IsDeadlineOverdue. Getting time wrong here is not a formatting problem; it is a business-rule problem.

The decision record, and what makes it good

ADR 02, Daty i czas w systemie, is eight short sections. It names a proposer and an approver. It states the context (most systems operate on dates; standardise storage and transport to avoid inconsistency and pointless conversion), lists two options — Polish local time versus UTC — and decides:

"The simplest solution, and simultaneously the least error-prone, is to use Option #2, i.e. UTC time. Localisation concerns should be left to client applications, for example a browser frontend or mobile apps."

Then it adds a recommendation that is the reason the estate is testable at all:

"It is recommended to use an abstraction over the system API DateTime.UtcNow, so that the solutions being built can be tested. For this purpose the project GroupFlights.Shared.Types provides the IClock interface together with an implementation already registered in the IoC container."

And it closes with a link section containing one entry: dotnet/runtime#36617, the issue that became TimeProvider in .NET 8. An ADR that names the standard-library feature which will one day make its own decision obsolete is a rare and admirable thing. Most decision records read as though the decision is permanent.

The abstraction is genuinely adopted

// src/Shared/GroupFlights.Shared.Types/Time/IClock.cs
public interface IClock
{
    DateTime UtcNow { get; }
}

public class UtcClock : IClock
{
    public DateTime UtcNow => DateTime.UtcNow;
}

Two files, three lines of substance, registered AddTransient inside AddSharedFramework(). Thirty files across the estate reference IClock — domain types, factories, specifications, validators, nine Sales command handlers, three event handlers and the deadline service. In six modules, no code calls DateTime.UtcNow or DateTime.Now directly.

The adoption pattern is the interesting part, and it is what makes Part 5's tests possible. The clock is threaded into domain methods as a parameter, not injected into entities:

offer.AcceptVariant(command.VariantId, _clock, user?.UserId);

OfferDraft.AddNewVariant, Offer.OnAcceptOfferDeadlineNotMet, Reservation.SetPassengersForFlight, IsVariantOverdue, IsDeadlineOverdue — all take an IClock. That keeps aggregates constructible with new, with no container, which is exactly why OfferDraftTests can fake time with an eleven-line nested TestClock and no mocking library.

Clock-as-parameter rather than clock-as-dependency is the choice that makes time-dependent domain logic testable, and this estate demonstrates the payoff rather than just asserting it. That is worth stealing outright.

And then, in shared plumbing

// src/Shared/GroupFlights.Shared.Plumbing/Database/Extensions.cs:10-14
public static void ConfigurePostgres(this IServiceCollection services)
{
    // Temporary fix for EF Core issue related to https://github.com/npgsql/efcore.pg/issues/2000
    AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
}

Read what that method is. It is an extension method on IServiceCollection that never touches services. It registers nothing, configures nothing, returns nothing. It mutates process-global AppContext state, and it is called once from the composition root alongside genuine registrations, where it looks like one of them.

The switch restores Npgsql 6's pre-breaking-change behaviour. With it on, DateTime maps to timestamp without time zone instead of timestamptz, and DateTimeKind is ignored on write.

The consequence is visible in every migration in the repository. Fifty-four column declarations across seven modules read type: "timestamp without time zone". Not one reads timestamp with time zone.

So the estate's decision record says “assume every date in the system is UTC”, and the estate's database stores every date in a type that, by definition, does not record whether it is.

Four consequences the ADR does not cover

DateTimeKind does not round-trip. A value written with Kind = Utc reads back as Kind = Unspecified. UtcClock produces Kind = Utc; the moment that value goes through Npgsql and comes back, the marker is gone. The clock is UTC; the round trip is not.

Comparisons still work — activeDeadline.DeadlineDateUtc <= now is correct, because DateTime comparison ignores Kind entirely. The code is right by luck, not by type, and that distinction matters the first time somebody calls ToLocalTime() on a value read from the database, which will convert an Unspecified value as though it were local.

Nothing coerces inbound dates. The estate's own .http fixtures send both shapes: a bare "2023-06-30", which System.Text.Json parses as Kind = Unspecified, and an explicit "2023-06-10T20:45:58.929Z", which parses as Kind = Utc. With the legacy switch on, both are written verbatim. A client sending an offset — 2023-06-30T22:00:00+02:00 — has that value silently stored as local time in a column the C# property calls DeadlineDateUtc.

The name is the only enforcement. There is no converter, no HasConversion(v => v.ToUniversalTime(), …), no model-building convention, no middleware normalising inbound payloads.

No validator checks Kind. PaymentValidator compares paymentSetup.DueDate <= _clock.UtcNow against a possibly-local value. InquiryValidator does the same twice, including for the rule about how far ahead a first flight may be booked. Those comparisons are wrong by up to a couple of hours for any client that sends an offset, and they will never fail loudly.

The switch is global and invisible. One AppContext flag in a shared project defines the temporal semantics of eight modules that never mention it, set by a method whose name promises to configure Postgres and whose signature promises to configure services.

The gap is the enforcement point

I want to be careful about the criticism here, because the individual pieces are all defensible.

The switch itself is a legitimate, comment-documented workaround for a real Npgsql 6 breaking change, and it links the issue. In 2022, with a codebase already written against the old behaviour, flipping it was the pragmatic call. The ADR's decision — UTC everywhere, localisation left to clients — is the right decision. The IClock recommendation is followed almost universally.

The gap is between them. ADR 02 standardises the convention and never names the enforcement point. It says every date in the system is UTC. It does not say where that becomes true: at the API boundary, in the model, in the column type, or in the developer's head. The answer turns out to be the last one, and a convention held only in developers' heads is exactly the thing an ADR exists to replace.

The enforcement points that would have made it real are all cheap:

  • At the boundary — a JsonConverter<DateTime> that coerces every inbound value to UTC, so no Unspecified or offset value reaches a validator.
  • In the model — a model-building convention applying ValueConverter<DateTime, DateTime> with DateTime.SpecifyKind(v, DateTimeKind.Utc) on read, so what comes back is what went in.
  • In the schema — dropping the legacy switch and letting timestamptz do the job, which is what Npgsql 6 changed the default to in the first place.

Any one of them turns “assume it is UTC” into “it is UTC”. None of them appears.

The two remaining leaks

For completeness, IClock is violated in exactly two places, both in SalesDbContext.UpdateVersions:

createDateProp.CurrentValue = DateTime.UtcNow;
…
updateDateProp.CurrentValue = DateTime.UtcNow;

The audit columns — CreateDateUtc and UpdateDateUtc, the two fields whose entire job is to record when something happened — are the only timestamps in the estate a test cannot control. Thirty files honour the abstraction; the two that need it most for determinism do not.

That is the shape of the whole finding, twice over. A convention is only as strong as its least-visited file, and the least-visited files here are a DbContext override and a service-collection extension that does not touch services.

Next, endpoints declared as data — the best idea in the shared mini-framework, and an honest grading of what it forgot to attach.