RequestTimeZone 1.0.0
dotnet add package RequestTimeZone --version 1.0.0
NuGet\Install-Package RequestTimeZone -Version 1.0.0
<PackageReference Include="RequestTimeZone" Version="1.0.0" />
<PackageVersion Include="RequestTimeZone" Version="1.0.0" />
<PackageReference Include="RequestTimeZone" />
paket add RequestTimeZone --version 1.0.0
#r "nuget: RequestTimeZone, 1.0.0"
#:package RequestTimeZone@1.0.0
#addin nuget:?package=RequestTimeZone&version=1.0.0
#tool nuget:?package=RequestTimeZone&version=1.0.0
RequestTimeZone
Keep every server and database timestamp in UTC, and resolve the user's or tenant's time zone per
HTTP request. Built on .NET TimeProvider, DateTimeOffset only, and testable without a clock,
a browser, or a server whose time zone you have to configure.
Targets: .NET 8 and .NET 9 · Dependencies: none beyond the ASP.NET Core shared framework
The rule this package enforces
Time zones are a presentation concern. They belong at the edge of the application, where a value is shown to a person or accepted from one. Everything behind that edge — comparisons, sorting, scheduling, storage — stays in UTC.
Applications drift away from that rule in small steps: a DateTime.Now in a service, a report that
groups by the server's day rather than the user's, a container that works until it is deployed to a
region with different daylight-saving rules. This package makes the correct version the easy one, and
makes the incorrect version fail to compile.
- Every "now" comes from an injected
TimeProvider.DateTime.Now,DateTime.UtcNow,DateTimeOffset.Now,DateTime.TodayandTimeZoneInfo.Localare banned at compile time in the library itself (RoslynBannedApiAnalyzers, errorRS0030). - Every public API takes and returns
DateTimeOffset, neverDateTime. A unit test walks the reflected public surface and fails the build if that ever stops being true. - The library never changes the process or operating-system time zone. Servers stay on UTC.
Install
dotnet add package RequestTimeZone
Quick start
using RequestTimeZone;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRequestTimeZone(); // default zone: UTC
var app = builder.Build();
app.UseRequestTimeZone(); // before anything that formats a timestamp
app.MapGet("/orders/today", (IRequestTimeZone tz, IOrderRepository repository) =>
{
// The user's calendar day, expressed as the UTC range to query with.
var (from, toExclusive) = tz.LocalTodayRange();
var orders = repository.Query() // rows are stored in UTC
.Where(o => o.PlacedAtUtc >= from && o.PlacedAtUtc < toExclusive);
return orders.Select(o => new
{
o.Id,
PlacedAt = tz.ToLocal(o.PlacedAtUtc), // converted only on the way out
});
});
app.Run();
The browser tells you the zone once, on every request:
// Axios, fetch wrapper, HttpClient handler - wherever your outbound headers are set.
headers['X-TimeZone'] = Intl.DateTimeFormat().resolvedOptions().timeZone; // e.g. "Europe/Berlin"
Nothing else in the application needs to know about time zones.
The contract
public interface IRequestTimeZone
{
TimeZoneInfo Zone { get; }
string ZoneId { get; }
DateTimeOffset UtcNow { get; }
DateTimeOffset LocalNow { get; }
DateTimeOffset ToLocal(DateTimeOffset utcDateTime);
DateTimeOffset ToUtc(DateTimeOffset localDateTime);
}
Registered scoped, so it always describes the request in flight. Inject it into controllers, minimal-API handlers, MediatR handlers, or anything else the container builds.
ToUtc preserves the instant; it does not reinterpret a wall-clock reading. To turn something a user
typed into an instant, use FromLocal, which applies the zone's daylight-saving rules.
Boundary helpers
| Member | Use it for |
|---|---|
tz.LocalToday() |
The user's calendar date, which is not always the server's. |
tz.LocalTimeOfDay() |
The user's wall-clock time right now. |
tz.LocalDateOf(instant) |
Which local day a stored instant belongs to, for grouping. |
tz.FromLocal(date, time) |
A form value ("15 June, 14:30") turned into an instant to store. |
tz.LocalDayRange(date) |
The half-open UTC range [From, ToExclusive) covering a local day. |
tz.LocalTodayRange() |
The same, for today. |
tz.GetIanaZoneId() |
The IANA id, for putting into a response body. |
tz.ToLocal(nullable) / tz.ToUtc(nullable) |
Null-propagating conversions. |
LocalDayRange is 23, 24 or 25 hours long depending on daylight saving, which is exactly why "local
midnight plus one day" is the wrong thing to write at a call site.
FromLocal handles the two awkward cases explicitly:
// Berlin, 31 March 2024: 02:00 became 03:00, so 02:30 never happened.
tz.FromLocal(new DateOnly(2024, 3, 31), new TimeOnly(2, 30));
// 2024-03-31T01:30:00+00:00 - moved forward to the first instant that exists.
// Berlin, 27 October 2024: 02:30 happened twice.
tz.FromLocal(new DateOnly(2024, 10, 27), new TimeOnly(2, 30));
// 2024-10-27T01:30:00+00:00 - standard time, the later pass (the default).
tz.FromLocal(new DateOnly(2024, 10, 27), new TimeOnly(2, 30), AmbiguousTimeResolution.Daylight);
// 2024-10-27T00:30:00+00:00 - the earlier pass.
How the zone is resolved
Resolvers run in order; the first that returns a known, allowed id wins. Anything else — an unknown
id, an oversized value, a zone outside your allow-list — is logged at Debug and skipped, and
resolution continues. A bad header never fails a request; it just leaves the request on the default
zone.
| Order | Resolver | Reads |
|---|---|---|
| 1 | HeaderRequestTimeZoneResolver |
X-TimeZone header |
| 2 | CookieRequestTimeZoneResolver |
tz cookie |
| 3 | QueryStringRequestTimeZoneResolver |
?tz= |
| — | ClaimsRequestTimeZoneResolver |
zoneinfo claim (opt in) |
| — | DelegateRequestTimeZoneResolver |
your callback |
Both IANA (Europe/Berlin) and Windows (W. Europe Standard Time) ids are accepted on every
platform and are canonicalised to the IANA spelling, so ZoneId reads the same on a Windows
developer machine and a Linux container.
Configuration
builder.Services.AddRequestTimeZone(options =>
{
options.DefaultZoneId = "UTC"; // validated at startup; the app will not start if it is wrong
options.HeaderName = "X-TimeZone";
options.CookieName = "tz";
options.QueryStringKey = "tz";
options.MaxZoneIdLength = 128; // longer client values are dropped without a lookup
options.WriteResponseHeader = true; // echo the applied zone back
options.AppendVaryHeader = true; // keep shared caches from mixing users up
});
or fluently:
builder.Services.AddRequestTimeZone()
.UseDefaultZone("Europe/Berlin")
.AddClaimsResolver() // a saved user preference outranks the browser
.RestrictTo("Europe/Berlin", "Europe/Paris"); // and nothing else is accepted
Explicitly added resolvers run before the built-in chain. ClearDefaultResolvers() drops the
built-in chain entirely. Options also bind from configuration:
builder.Services.Configure<RequestTimeZoneOptions>(
builder.Configuration.GetSection(RequestTimeZoneOptions.SectionName));
builder.Services.AddRequestTimeZone();
A tenant-aware resolver
The callback gets the HttpContext, so it can reach the container, the route and the principal:
builder.Services.AddRequestTimeZone()
.AddResolver(async (context, cancellationToken) =>
{
var tenantId = context.User.FindFirst("tenant_id")?.Value;
if (tenantId is null)
{
return null; // defer to the next resolver
}
var tenants = context.RequestServices.GetRequiredService<ITenantStore>();
var tenant = await tenants.FindAsync(tenantId, cancellationToken);
return tenant?.TimeZoneId;
});
Put UseRequestTimeZone() after UseAuthentication() whenever a resolver reads the principal.
Multi-tenant safety
RestrictTo(...) is worth setting when the zone influences anything billable or audited: it means a
crafted X-TimeZone header cannot move a tenant onto an arbitrary zone. Client-supplied ids are also
length-capped before they ever reach the time-zone database, and only successful lookups are cached,
so the cache stays bounded by the tz database rather than by request traffic.
Outside a request
Background jobs, queue consumers and scheduled work resolve the same scoped service. It starts at the configured default and can be pointed at a tenant's zone:
using var scope = serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<RequestTimeZoneContext>();
context.SetZone(TimeZoneInfo.FindSystemTimeZoneById(tenant.TimeZoneId));
var tz = scope.ServiceProvider.GetRequiredService<IRequestTimeZone>();
var (from, toExclusive) = tz.LocalDayRange(tz.LocalToday().AddDays(-1)); // "yesterday, for them"
RequestTimeZoneTimeProvider wraps the ambient clock and reports the request's zone as
LocalTimeZone, for third-party code that insists on calling GetLocalNow().
Testing
Because the clock is injected, tests are exact — no tolerances, no sleeping:
var clock = new FakeTimeProvider(new DateTimeOffset(2024, 6, 15, 22, 0, 0, TimeSpan.Zero));
var tz = new RequestTimeZoneContext(clock, TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo"));
Assert.Equal(new DateOnly(2024, 6, 16), tz.LocalToday()); // already tomorrow in Tokyo
Assert.Equal(new TimeOnly(7, 0), tz.LocalTimeOfDay());
clock.Advance(TimeSpan.FromHours(3));
Assert.Equal(new TimeOnly(10, 0), tz.LocalTimeOfDay());
In an integration test, register a fake clock and set the header:
builder.Services.AddRequestTimeZone().UseTimeProvider(new FakeTimeProvider(instant));
client.DefaultRequestHeaders.Add("X-TimeZone", "America/New_York");
Persistence
The package deliberately does nothing at the storage layer, because there is nothing to do: store UTC.
- EF Core / SQL Server: map to
datetimeoffsetand write UTC values, or storedatetime2plus the fact that it is UTC. Never store a local time without its offset. - PostgreSQL:
timestamptz, with UTC in and UTC out. - Dapper / raw SQL: pass
DateTimeOffsetvalues already normalised withtz.ToUtc(...).
Convert in the API layer — response DTOs, view models, exports — and nowhere else. If a conversion appears in a repository or a domain service, that is the bug this package is designed to prevent.
Guarantees and non-goals
Guarantees
- No ambient clock or machine time zone is read anywhere in the library (enforced at compile time).
- The server's OS and process time zone are never modified.
- Malformed client input cannot fail a request.
- A misconfigured
DefaultZoneIdor allow-list stops the application at startup, not silently per request. - Scoped state: concurrent requests cannot see each other's zone (covered by a concurrency test).
Non-goals
- Formatting and culture. Pair this with
RequestLocalizationMiddleware; they are orthogonal. - Shipping a tz database. The package uses the platform's, so keep the OS or container image patched when tzdata changes.
- Guessing a zone from an IP address or a UTC-offset number. Offsets are not zones; a client should send an id.
Platform notes
- Requires ICU (the .NET default). Under
InvariantGlobalization=trueonlyUTCresolves. - On Windows, IANA ids work and are preferred; on Linux, Windows ids work too. Ids are canonicalised to the IANA spelling wherever a mapping exists.
Publishing this package to nuget.org
RepositoryOwnerinDirectory.Build.propssetsPackageProjectUrlandRepositoryUrl; change it, andAuthors/Copyright, if you fork.- Bump
VersionPrefixinDirectory.Build.propsand add aCHANGELOG.mdentry. - Verify locally:
dotnet build -c Release -warnaserror dotnet test -c Release dotnet pack -c Release -o ./artifacts - Create an API key at https://www.nuget.org/account/apikeys scoped to
RequestTimeZone, and add it to the repository as theNUGET_API_KEYsecret. - Tag the release:
git tag v1.0.0 && git push origin v1.0.0. Thereleaseworkflow packs, tests and pushes both the.nupkgand the.snupkgsymbol package.
To push by hand instead:
dotnet nuget push ./artifacts/RequestTimeZone.1.0.0.nupkg \
--api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json
Versioning and support
Semantic versioning. The public surface is covered by package validation across both target frameworks, so a breaking change cannot ship in a patch release unnoticed.
License
MIT. See LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- No dependencies.
-
net9.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 72 | 8/29/2026 |