RequestTimeZone 1.0.0

dotnet add package RequestTimeZone --version 1.0.0
                    
NuGet\Install-Package RequestTimeZone -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="RequestTimeZone" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="RequestTimeZone" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="RequestTimeZone" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add RequestTimeZone --version 1.0.0
                    
#r "nuget: RequestTimeZone, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package RequestTimeZone@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=RequestTimeZone&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=RequestTimeZone&version=1.0.0
                    
Install as a Cake Tool

RequestTimeZone

NuGet Downloads License: MIT

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.Today and TimeZoneInfo.Local are banned at compile time in the library itself (Roslyn BannedApiAnalyzers, error RS0030).
  • Every public API takes and returns DateTimeOffset, never DateTime. 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 datetimeoffset and write UTC values, or store datetime2 plus 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 DateTimeOffset values already normalised with tz.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 DefaultZoneId or 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=true only UTC resolves.
  • 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

  1. RepositoryOwner in Directory.Build.props sets PackageProjectUrl and RepositoryUrl; change it, and Authors/Copyright, if you fork.
  2. Bump VersionPrefix in Directory.Build.props and add a CHANGELOG.md entry.
  3. Verify locally:
    dotnet build -c Release -warnaserror
    dotnet test  -c Release
    dotnet pack  -c Release -o ./artifacts
    
  4. Create an API key at https://www.nuget.org/account/apikeys scoped to RequestTimeZone, and add it to the repository as the NUGET_API_KEY secret.
  5. Tag the release: git tag v1.0.0 && git push origin v1.0.0. The release workflow packs, tests and pushes both the .nupkg and the .snupkg symbol 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • 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