Skip to content

Timing — TimeProvider & IClock Abstraction

Granit.Timing provides testable time abstractions for the entire framework. Replaces all calls to DateTime.Now / DateTime.UtcNow with injectable services. Uses AsyncLocal for per-request timezone propagation.

PackageRoleDepends on
Granit.TimingIClock, ICurrentTimezoneProvider, TimeProviderGranit
[DependsOn(typeof(GranitTimingModule))]
public class AppModule : GranitModule { }

The primary abstraction for accessing the current time and performing timezone conversions:

public interface IClock
{
DateTimeOffset Now { get; }
bool SupportsMultipleTimezone { get; }
DateTimeOffset Normalize(DateTimeOffset dateTime);
DateTimeOffset ConvertToUserTime(DateTimeOffset utcDateTime);
DateTimeOffset ConvertToUtc(DateTimeOffset dateTime);
}

Usage in a service:

public class AppointmentService(IClock clock, AppDbContext db)
{
public async Task<bool> IsAvailableAsync(
Guid doctorId, DateTimeOffset requestedTime, CancellationToken cancellationToken)
{
DateTimeOffset utcTime = clock.Normalize(requestedTime);
return !await db.Appointments
.AnyAsync(a => a.DoctorId == doctorId
&& a.ScheduledAt <= utcTime
&& a.EndAt > utcTime,
cancellationToken)
.ConfigureAwait(false);
}
public Appointment CreateAppointment(Guid doctorId, DateTimeOffset scheduledAt)
{
return new Appointment
{
DoctorId = doctorId,
ScheduledAt = clock.Normalize(scheduledAt),
CreatedAt = clock.Now, // Always UTC
};
}
}

Per-request timezone context backed by AsyncLocal<string?>. Set it early in the request pipeline (e.g., from a header, claim, or user preference):

app.Use(async (context, next) =>
{
var timezoneProvider = context.RequestServices.GetRequiredService<ICurrentTimezoneProvider>();
timezoneProvider.Timezone = context.Request.Headers["X-Timezone"].FirstOrDefault()
?? "Europe/Brussels";
await next(context).ConfigureAwait(false);
});

Then IClock.ConvertToUserTime() converts UTC dates to the user’s local time. If the timezone ID is invalid or not found on the system, the value is returned unchanged (graceful fallback to UTC — no exception thrown):

public class AppointmentResponse
{
public required DateTimeOffset ScheduledAtUtc { get; init; }
public required DateTimeOffset ScheduledAtLocal { get; init; }
}
// In the handler:
var response = new AppointmentResponse
{
ScheduledAtUtc = appointment.ScheduledAt,
ScheduledAtLocal = clock.ConvertToUserTime(appointment.ScheduledAt),
};

Opt out of automatic UTC normalization for properties that store user-local times (e.g., birth dates where timezone is irrelevant):

public class Patient
{
[DisableDateTimeNormalization]
public DateTimeOffset DateOfBirth { get; set; }
}

Replace TimeProvider in tests to control time:

var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 3, 15, 10, 0, 0, TimeSpan.Zero));
services.AddSingleton<TimeProvider>(fakeTime);
// IClock.Now will return 2026-03-15T10:00:00Z
// Advance: fakeTime.Advance(TimeSpan.FromMinutes(30));
ServiceLifetimeRationale
TimeProviderSingletonTimeProvider.System is stateless and thread-safe
ICurrentTimezoneProviderSingletonBacked by AsyncLocal — value is per-async-context
IClockSingletonStateless — delegates to TimeProvider and ICurrentTimezoneProvider
PropertyDefaultDescription
DefaultTimezonenullFallback timezone when none is set per-request (IANA or Windows ID)
CategoryKey typesPackage
ModuleGranitTimingModule
ServicesIClock, ICurrentTimezoneProvider, CurrentTimezoneProvider, ClockGranit.Timing
AttributesDisableDateTimeNormalizationAttributeGranit.Timing
OptionsClockOptionsGranit.Timing
ExtensionsAddGranitTiming()Granit.Timing