ASP.NET Core Integration
Pragmatic.Temporal.AspNetCore gives every HTTP request a TemporalContext (client + business timezone), detects the client’s timezone from the request, and binds temporal types from route/query values.
Conversions are explicit by default (through
TemporalContextmethods in your handlers). Opt in per property with the timezone conversion attributes ([AsUtc],[FromClientTimezone],[ToClientTimezone], …) and the framework converts request/response values for you.
When you need it: your API serves users in multiple timezones and handlers need to answer “what does today mean for this caller?” without parsing headers by hand.
What you write:
builder.Services.AddPragmaticTemporalAspNetCore( configureTemporal: o => { o.BusinessTimeZone = TimeZoneResolver.GetTimeZone("Europe/Rome"); });
var app = builder.Build();
app.UseAuthentication(); // BEFORE UsePragmaticTemporal if you use claim-based detectionapp.UsePragmaticTemporal(); // detects timezone, builds the per-request TemporalContextWhat you get:
- core Temporal services (
IClock,ITemporalCalculator, …) registered; - a scoped
TemporalContextresolvable from DI, built per request with the detected client timezone; - temporal JSON converters and per-property timezone behaviors on both MVC’s
JsonOptionsand Minimal API’sHttp.Json.JsonOptions; - model binders for
LocalDate,LocalTime,LocalDateTime, plus attribute-driven binding forDateTimeOffset/DateTime(inserted at the top, so they win over default binders).
Consume the context in any handler:
public sealed class OrdersController(TemporalContext temporal, AppDbContext db) : ControllerBase{ [HttpGet("today")] public Task<List<Order>> Today() => db.Orders.WhereClientToday(o => o.CreatedAt, temporal).ToListAsync();}Middleware ordering: place UsePragmaticTemporal() after UseAuthentication() (the claims strategy reads HttpContext.User) and before anything that resolves TemporalContext.
Timezone Detection
Section titled “Timezone Detection”The middleware tries each strategy in ascending Priority order; the first non-null result wins, otherwise TemporalOptions.DefaultTimeZone is used.
| Priority | Strategy | Reads | Default key |
|---|---|---|---|
| 50 | QueryStringTimeZoneStrategy |
query string | ?tz=Europe/Rome |
| 100 | HeaderTimeZoneStrategy |
HTTP header | X-Timezone: Europe/Rome |
| 200 | ClaimsTimeZoneStrategy |
authenticated user’s claims | claim type timezone |
| 300 | CookieTimeZoneStrategy |
cookie | cookie tz |
Query string has the highest priority by design: it makes per-request debugging trivial (?tz=Asia/Tokyo).
Values longer than 64 characters or not resolvable as IANA/Windows ids are silently discarded and the next strategy is tried. To fail loudly instead, enable:
configureTemporal: o => o.ThrowOnInvalidTimeZone = true // → TimeZoneNotFoundException (400-worthy)This only applies when a value was supplied but invalid (distinguished via IRawTimeZoneDetectionStrategy); an absent timezone still falls back to the default.
Customizing keys
Section titled “Customizing keys”builder.Services.AddPragmaticTemporalAspNetCore( configureAspNetCore: o => { o.TimezoneHeader = "X-User-Timezone"; o.TimezoneClaim = "tz"; o.TimezoneQueryParameter = "timezone"; o.TimezoneCookie = "user_tz"; });These properties are propagated only to the built-in strategies; custom strategies you add to o.DetectionStrategies carry their own configuration.
Custom strategies
Section titled “Custom strategies”Implement ITimeZoneDetectionStrategy (and IRawTimeZoneDetectionStrategy if you want ThrowOnInvalidTimeZone to see your raw values):
public sealed class RouteTimeZoneStrategy : ITimeZoneDetectionStrategy, IRawTimeZoneDetectionStrategy{ public int Priority => 40; // before the built-ins
public TimeZoneInfo? Detect(HttpContext context) => TimeZoneResolver.TryGetTimeZone(GetRawValue(context), out var tz) ? tz : null;
public string? GetRawValue(HttpContext context) => context.GetRouteValue("tz") as string;}Add it to o.DetectionStrategies in configureAspNetCore.
TemporalContext Outside a Request
Section titled “TemporalContext Outside a Request”TemporalContext is registered scoped and normally comes from HttpContext.Items. When resolved outside an HTTP request (background job, hosted service), TemporalContextAccessor falls back to a context built from TemporalOptions (including DefaultTimeZone/BusinessTimeZone, the DST policies, FirstDayOfWeek, and DefaultCountryCode), so background work behaves like request work.
Model Binding
Section titled “Model Binding”LocalDate, LocalTime, LocalDateTime (and their nullable forms) bind from route values and query strings:
[HttpGet("bookings/{date}")]public IActionResult ForDay(LocalDate date, LocalTime? after) { ... }// GET /bookings/2026-06-01?after=14:30:00Expected formats (mirrors each type’s TryParse):
| Type | Format |
|---|---|
LocalDate |
yyyy-MM-dd |
LocalTime |
HH:mm:ss (also HH:mm, HH:mm:ss.fff) |
LocalDateTime |
yyyy-MM-ddTHH:mm:ss |
An empty value binds null for nullable parameters; an unparsable value adds a ModelState error with the expected format in the message and fails the bind. Request bodies are handled by the JSON converters and the conversion attributes, not by these binders.
DateTimeOffset/DateTime parameters decorated with an input conversion attribute get the same treatment from route/query values:
[HttpGet("orders")]public IActionResult After([FromClientTimezone] DateTimeOffset since) { ... }// GET /orders?since=2026-06-01T09:00:00 → 09:00 wall time in the CLIENT zone → UTC instant// GET /orders?since=2026-06-01T09:00:00%2B02:00 → explicit offset wins, normalized to UTCTimezone Conversion Attributes
Section titled “Timezone Conversion Attributes”When you need it: your DTOs store instants in UTC (golden rule) but the API contract wants values expressed in the caller’s (or the business’s) timezone, and you don’t want conversion boilerplate in every handler.
What you write: decorate the DTO property and register it (apps on the Pragmatic host get the registration generated from the attributes; standalone apps register once at startup).
public sealed class OrderResponse{ [ToClientTimezone] // output: convert to the caller's zone public DateTimeOffset CreatedAt { get; set; }}
public sealed class CreateOrderRequest{ [FromClientTimezone] // input: wall time in the caller's zone → UTC public DateTime RequestedDelivery { get; set; }}
// Standalone registration (startup, once):TemporalJsonBehaviorRegistry.Register<OrderResponse>( nameof(OrderResponse.CreatedAt), TemporalJsonBehavior.ToClientTimezone);TemporalJsonBehaviorRegistry.Register<CreateOrderRequest>( nameof(CreateOrderRequest.RequestedDelivery), TemporalJsonBehavior.FromClientTimezone);What you get: serialization applies the conversion automatically using the per-request TemporalContext, with no reflection and no per-handler code.
| Attribute | Direction | Effect |
|---|---|---|
[AsUtc] |
in + out | input without offset is read as UTC; output is always emitted in UTC |
[FromClientTimezone] |
in | wall time without offset is interpreted in the client zone (DST-safe) |
[FromBusinessTimezone] |
in | wall time without offset is interpreted in the business zone |
[ToClientTimezone] |
out | value is converted to the client zone |
[ToBusinessTimezone] |
out | value is converted to the business zone |
[KeepTimezone] |
out | explicit passthrough, no conversion ever |
Semantics worth knowing:
- Explicit offsets always win on input:
"2026-06-01T12:00:00+04:00"is a known instant;From*/AsUtcmerely normalize it to UTC. The interpretation attributes only matter for values without offset information. - Wall-time interpretation is DST-safe:
From*conversions go through the context’sNonExistentTimeHandling/AmbiguousTimeHandlingpolicies. - No ambient context → no conversion: outside a request (or without the middleware)
To*/From*behaviors pass values through unchanged rather than guessing.AsUtcneeds no context and always applies. - Registered names match the wire name case-insensitively, so CLR names work with camelCase policies. With more exotic naming policies (snake_case), register the serialized name.
See Also
Section titled “See Also”- JSON Serialization: wire formats and the Minimal API registration
- EF Core Integration:
WhereToday/WhereClientTodayconsuming the request context - Concepts: client vs business timezone, convert-at-boundaries rule
