Architecture and Core Concepts
This guide explains why Pragmatic.Temporal exists, how its pieces fit together, and how to choose the right type for each situation. Read this before diving into the individual feature guides.
The Problem
Section titled “The Problem”.NET ships DateTime, DateTimeOffset, and TimeSpan. All three conflate distinct temporal concepts into a single type, and the compiler cannot help you tell them apart.
DateTime: one type, three meanings
Section titled “DateTime: one type, three meanings”DateTime birthday = new DateTime(1990, 5, 15); // Calendar date (no time)DateTime meeting = new DateTime(2026, 3, 21, 14, 30, 0); // Wall clock time (no timezone)DateTime logged = DateTime.UtcNow; // UTC instantAll three variables have the same type. The only hint is the Kind property (Unspecified, Local, or Utc), which is routinely ignored by serializers, ORMs, and comparison operators. Pass a DateTime across a method boundary and the receiver has no way to know which of the three meanings was intended.
DateTimeOffset: offset without identity
Section titled “DateTimeOffset: offset without identity”var rome = new DateTimeOffset(2026, 3, 21, 15, 0, 0, TimeSpan.FromHours(1)); // +01:00var london = new DateTimeOffset(2026, 3, 21, 14, 0, 0, TimeSpan.FromHours(0)); // +00:00Both values represent the same instant. But +01:00 is shared by dozens of timezones (CET, WAT, etc.) — the offset alone does not tell you which timezone produced it. After DST transitions, the offset changes even though the timezone does not. This makes it impossible to compute “same time tomorrow” correctly from a DateTimeOffset alone.
Calendar arithmetic is broken
Section titled “Calendar arithmetic is broken”// Adding 1 day across DST: DateTime thinks it's always 24 hoursvar beforeDst = new DateTime(2026, 3, 29, 1, 0, 0); // 1:00 AM, day before spring forwardvar nextDay = beforeDst.AddDays(1); // 1:00 AM next day -- but what UTC offset?DateTime.AddDays(1) always adds exactly 24 hours. On the day clocks spring forward (in Europe/Rome, March 29 2026), the local day is only 23 hours long. The result lands on the wrong wall clock time. On fall back, the local day is 25 hours, and the same problem occurs in reverse.
DateTime.Now kills testability
Section titled “DateTime.Now kills testability”public bool IsExpired() => DateTime.UtcNow > _expiryDate;This line is impossible to test deterministically. You cannot set DateTime.UtcNow to a specific value. Every test that depends on the current time becomes flaky near midnight, near month boundaries, or during DST transitions.
The Solution: Type = Scope
Section titled “The Solution: Type = Scope”Pragmatic.Temporal provides types where the type itself defines the temporal scope. A LocalDate cannot accidentally carry time information. A ZonedDateTime cannot lose its timezone. The compiler enforces correct usage.
The type hierarchy
Section titled “The type hierarchy” LocalDate Pure calendar date (birthday, deadline) LocalTime Time of day (store hours, alarm) LocalDateTime Date + time, no timezone (appointment) ZonedDateTime Instant + timezone (flight departure) Duration Physical elapsed time (always exact) Period Calendar elapsed time (1 month, 1 year) DateRange Closed date interval with set operations CronExpression Scheduling expression (cron syntax)Each type wraps a .NET primitive (DateOnly, TimeOnly, DateTime, DateTimeOffset) but restricts the API surface so only semantically valid operations are available. You cannot compare a LocalDate with a ZonedDateTime — they represent fundamentally different things.
How types compose
Section titled “How types compose”// Start with a calendar datevar date = new LocalDate(2026, 3, 21);
// Add a time of day to get a wall clock datetimevar appointment = date.At(new LocalTime(14, 30)); // LocalDateTime
// Place it in a timezone to get a specific instantvar flight = appointment.InZone("Europe/Rome"); // ZonedDateTime
// Convert to another timezone (same instant, different local time)var inTokyo = flight.InZone("Asia/Tokyo"); // ZonedDateTimeThe progression is always from less context to more context: LocalDate -> LocalDateTime -> ZonedDateTime. You can go backwards (ZonedDateTime.Date returns a LocalDate), but the compiler makes you acknowledge the timezone context explicitly.
IClock: Testable Time
Section titled “IClock: Testable Time”IClock is the abstraction that replaces DateTime.Now, DateTime.UtcNow, DateTimeOffset.Now, and DateTimeOffset.UtcNow throughout your application.
The interface
Section titled “The interface”public interface IClock{ DateTimeOffset UtcNow { get; } DateTimeOffset Now { get; } DateOnly UtcToday { get; } DateOnly Today { get; } TimeOnly UtcTimeOfDay { get; } TimeOnly TimeOfDay { get; } TimeProvider GetTimeProvider();}IClock lives in Pragmatic.Abstractions (not in Pragmatic.Temporal itself), so any module in the ecosystem can depend on it without pulling in the full temporal package. The GetTimeProvider() method returns a .NET 8+ TimeProvider for interop with APIs that expect it (e.g., Task.Delay, PeriodicTimer, IMemoryCache).
SystemClock (production)
Section titled “SystemClock (production)”SystemClock delegates to TimeProvider.System. It is registered as a singleton by default:
services.AddSingleton<IClock, SystemClock>();When using Pragmatic.Composition, this registration is automatic — the SG detects Pragmatic.Temporal in your dependencies and registers SystemClock as the default IClock.
TestClock (tests)
Section titled “TestClock (tests)”TestClock gives you full control over time in tests:
var clock = new TestClock();clock.SetDateTime(2026, 3, 21, 10, 0, 0);
// Time is now frozen at 2026-03-21 10:00:00 UTCclock.UtcNow // 2026-03-21T10:00:00Z
// Advance time manuallyclock.Advance(TimeSpan.FromHours(2));clock.UtcNow // 2026-03-21T12:00:00Z
// Convenience: advance by days, hoursclock.AdvanceDays(1);clock.AdvanceHours(3);Static factory methods cover common scenarios:
var clock = TestClock.AtNoon(2026, 3, 21); // 12:00 UTCvar clock = TestClock.AtMidnight(2026, 1, 1); // 00:00 UTCvar clock = TestClock.AtNow(); // Current time, then frozenFor async tests where multiple reads of UtcNow must return different values (to avoid timestamp collisions), use auto-advance:
var clock = TestClock.AtNoon(2026, 3, 21) .WithAutoAdvance(TimeSpan.FromMilliseconds(1));
// Each read advances by 1msclock.UtcNow // T+0msclock.UtcNow // T+1msclock.UtcNow // T+2msTimeProvider interop
Section titled “TimeProvider interop”IClock.GetTimeProvider() returns a TimeProvider that stays in sync with the clock. This means TestClock also controls TimeProvider-based APIs:
// In DI registrationservices.AddSingleton<TimeProvider>(sp => sp.GetRequiredService<IClock>().GetTimeProvider());AddPragmaticTemporal() registers this automatically. When you replace the clock with UseClock<T>() or UseClock(instance), the TimeProvider registration is updated to match.
Core Types in Depth
Section titled “Core Types in Depth”LocalDate
Section titled “LocalDate”Calendar date without time or timezone. Backed by DateOnly.
Use for: birthdays, holidays, deadlines, reporting dates, invoice dates, anything where “March 21” is the complete information.
var date = new LocalDate(2026, 3, 21);
// Propertiesdate.Year // 2026date.Month // 3date.Day // 21date.DayOfWeek // DayOfWeek.Saturdaydate.DayOfYear // 80date.Quarter // 1date.IsWeekend // truedate.IsWeekday // falsedate.IsLeapYear // false
// Arithmeticdate.AddDays(7) // 2026-03-28date.AddMonths(1) // 2026-04-21date.AddYears(1) // 2027-03-21date.DaysBetween(other) // signed integer
// Navigationdate.StartOfMonth() // 2026-03-01date.EndOfMonth() // 2026-03-31date.StartOfWeek() // 2026-03-16 (Monday)date.EndOfWeek() // 2026-03-22 (Sunday)date.StartOfQuarter() // 2026-01-01date.EndOfQuarter() // 2026-03-31date.StartOfYear() // 2026-01-01date.EndOfYear() // 2026-12-31
// Compose with timedate.At(new LocalTime(14, 30)) // LocalDateTimedate.AtMidnight() // LocalDateTime at 00:00date.AtNoon() // LocalDateTime at 12:00
// Implicit conversionDateOnly d = date; // implicit operatorLocalDate ld = d; // implicit operatorLocalTime
Section titled “LocalTime”Time of day without date or timezone. Backed by TimeOnly.
Use for: store hours, alarm times, recurring meeting times, any time that repeats across days.
var time = new LocalTime(14, 30);
// Propertiestime.Hour // 14time.Minute // 30time.Second // 0time.IsMorning // falsetime.IsAfternoon // truetime.IsEvening // false
// Common timesLocalTime.Midnight // 00:00:00LocalTime.Noon // 12:00:00
// Arithmetictime.AddHours(2) // 16:30time.AddMinutes(15) // 14:45time.DurationUntil(other) // TimeSpan
// Checkstime.IsBetween(new LocalTime(9, 0), new LocalTime(17, 0)) // true
// Compose with datetime.On(new LocalDate(2026, 3, 21)) // LocalDateTimeLocalDateTime
Section titled “LocalDateTime”Date and time without timezone. Backed by DateTime with Kind = Unspecified.
Use for: recurring appointments, schedules, “meet at 2:30 PM” where the timezone is implied by context.
var dt = new LocalDateTime(2026, 3, 21, 14, 30);
// Or compose from partsvar dt2 = new LocalDateTime(new LocalDate(2026, 3, 21), new LocalTime(14, 30));
// Decomposevar (date, time) = dt; // Deconstruct
// Navigationdt.StartOfDay() // 2026-03-21T00:00:00dt.EndOfDay() // 2026-03-21T23:59:59.999dt.StartOfHour() // 2026-03-21T14:00:00
// Convert to zoned (this is where DST policies matter)dt.InZone("Europe/Rome") // ZonedDateTime (default: ShiftForward + UseStandardTime)dt.InZone("Europe/Rome", nonExistentPolicy: NonExistentTimePolicy.ThrowException, ambiguousPolicy: AmbiguousTimePolicy.ThrowException) // Strict modeZonedDateTime
Section titled “ZonedDateTime”A specific instant in time with full timezone awareness. Backed by DateTimeOffset (UTC) + TimeZoneInfo.
Use for: flight departures, event timestamps, anything that happened or will happen at a specific moment on the planet.
// From UTC (safest -- no DST ambiguity)var rome = ZonedDateTime.FromUtc(DateTimeOffset.UtcNow, "Europe/Rome");
// From local time (requires DST policy)var zoned = ZonedDateTime.FromLocal( new DateTime(2026, 3, 21, 14, 30, 0), TimeZoneResolver.GetTimeZone("Europe/Rome"));
// Strict mode (throws on DST edge cases)var strict = ZonedDateTime.FromLocalStrict( new DateTime(2026, 3, 21, 14, 30, 0), TimeZoneResolver.GetTimeZone("Europe/Rome"));
// Current time in a zone (requires IClock)var now = ZonedDateTime.Now("Europe/Rome", clock);
// Propertiesrome.UtcDateTime // DateTimeOffset (UTC)rome.Zone // TimeZoneInforome.ZoneId // "Europe/Rome" (IANA)rome.Offset // TimeSpan (+01:00 or +02:00)rome.LocalDateTime // DateTime in that zonerome.Date // LocalDaterome.Time // LocalTimerome.IsDaylightSavingTime // bool
// Timezone conversion (same instant, different wall clock)rome.InZone("Asia/Tokyo") // Convert to Tokyorome.ToUtc() // Convert to UTC DateTimeOffset
// Arithmeticrome.AddDays(1) // "Same time tomorrow" (calendar day, DST-safe)rome.AddHours(1) // Wall clock hour (may skip/repeat during DST)rome.Add(Duration.FromHours(1)) // Physical hour (exactly 3600 seconds)Equality semantics: Two ZonedDateTime values are equal if they represent the same UTC instant, regardless of timezone. Use EqualsExact() to compare both instant and timezone.
Duration vs Period
Section titled “Duration vs Period”These two types represent fundamentally different concepts:
| Aspect | Duration | Period |
|---|---|---|
| Meaning | Physical elapsed time | Calendar elapsed time |
| Precision | Exact (ticks) | Years, months, days |
| ”1 day” | Always 24 hours | Same wall clock time tomorrow |
| ”1 month” | Not supported | Jan 31 + 1 month = Feb 28 |
| DST-safe | N/A (absolute) | Handled by type being operated on |
| Format | ISO 8601 PT1H30M | ISO 8601 P1Y2M3D |
// Duration: exact elapsed timevar flight = Duration.FromHours(2) + Duration.FromMinutes(30);flight.TotalMinutes // 150.0
// Period: calendar arithmeticvar subscription = Period.FromYears(1);var startDate = new LocalDate(2026, 3, 21);var expiryDate = startDate.Add(subscription); // 2027-03-21
// Period.Between computes the calendar differencevar age = Period.Between( new LocalDate(1990, 5, 15), new LocalDate(2026, 3, 21));age.Years // 35age.Months // 10age.Days // 6DateRange
Section titled “DateRange”Closed date range [Start, End] with set operations. Implements IEnumerable<LocalDate>.
// Factory methodsvar q1 = DateRange.Quarter(2026, 1);var march = DateRange.Month(2026, 3);var week = DateRange.Week(new LocalDate(2026, 3, 21));var last30 = DateRange.LastDays(30);
// Propertiesq1.Start // 2026-01-01q1.End // 2026-03-31q1.Days // 90 (inclusive)q1.IsSingleDay // false
// Set operationsq1.Contains(new LocalDate(2026, 2, 15)) // trueq1.Overlaps(march) // trueq1.Intersect(march) // [Mar 1 - Mar 31]q1.Union(march) // [Jan 1 - Mar 31]
// Manipulationmarch.Expand(5) // [Feb 24 - Apr 5]march.Shift(7) // [Mar 8 - Apr 7]march.Split(10) // Chunks of 10 days
// Enumerationforeach (var date in march.Weekdays()) { /* Mon-Fri only */ }foreach (var date in march.Weekends()) { /* Sat-Sun only */ }CronExpression
Section titled “CronExpression”Zero-dependency cron parser supporting standard 5-field and extended 6-field (seconds) formats.
// Common expressionsCronExpression.EveryMinute // "* * * * *"CronExpression.Midnight // "0 0 * * *"CronExpression.Weekdays // "0 0 * * 1-5"
// Factory methodsCronExpression.Daily(new TimeOnly(9, 0)) // "0 9 * * *"CronExpression.Weekly(DayOfWeek.Monday, new TimeOnly(8, 0)) // "0 8 * * 1"CronExpression.Monthly(1, new TimeOnly(0, 0)) // "0 0 1 * *"
// Custom parsing (supports *, ranges, lists, steps, L, W, #)var cron = CronExpression.Parse("0/15 9-17 * * 1-5");
// Get occurrencesvar next = cron.GetNextOccurrence(DateTimeOffset.UtcNow);var schedule = cron.GetOccurrences(from, until, zone);
// Check if a time matchescron.Matches(DateTimeOffset.UtcNow) // bool
// Propertiescron.Expression // original stringcron.HasSeconds // true for 6-fieldcron.Semantics // Unix (OR) or Quartz (AND) day matchingCronExpression is DST-aware: GetNextOccurrence accepts a TimeZoneInfo parameter and skips times that fall in DST gaps.
DST Handling
Section titled “DST Handling”Pragmatic.Temporal makes DST handling explicit. There are no silent defaults that paper over edge cases.
The two DST problems
Section titled “The two DST problems”Spring forward (gap): Clocks jump ahead. Some local times do not exist. In Europe/Rome on March 29, 2026, clocks jump from 02:00 to 03:00. The time 02:30 does not exist that day.
Fall back (overlap): Clocks fall back. Some local times occur twice. In Europe/Rome on October 25, 2026, clocks fall from 03:00 back to 02:00. The time 02:30 occurs twice — once at +02:00 (CEST) and once at +01:00 (CET).
Policies
Section titled “Policies”Two enums control behavior:
public enum NonExistentTimePolicy{ ShiftForward, // 02:30 -> 03:00 (first valid time after gap) ThrowException // Throws NonExistentTimeException}
public enum AmbiguousTimePolicy{ UseStandardTime, // 02:30 +01:00 (later occurrence) UseDaylightTime, // 02:30 +02:00 (earlier occurrence) ThrowException // Throws AmbiguousTimeException}Every method that converts local time to zoned time accepts these policies as parameters:
// Explicit policies on every conversionvar zoned = ZonedDateTime.FromLocal(localDateTime, zone, nonExistentPolicy: NonExistentTimePolicy.ShiftForward, ambiguousPolicy: AmbiguousTimePolicy.UseStandardTime);
// Strict mode: throw on any DST edge casevar strict = ZonedDateTime.FromLocalStrict(localDateTime, zone);TemporalContext and DST
Section titled “TemporalContext and DST”TemporalContext provides conversion methods that apply configurable defaults:
// ClientToUtc uses ShiftForward + UseStandardTime by defaultvar utc = context.ClientToUtc(localDateTime);
// Or specify policies explicitlyvar utc = context.ClientToUtc(localDateTime, NonExistentTimePolicy.ThrowException, AmbiguousTimePolicy.ThrowException);Business Days
Section titled “Business Days”ITemporalCalculator provides business day calculations with pluggable holiday support.
Without holidays
Section titled “Without holidays”var calculator = new TemporalCalculator();var delivery = calculator.AddBusinessDays(today, 5); // Skips weekends onlyvar count = calculator.CountBusinessDays(from, to); // O(1) formulaWith holidays
Section titled “With holidays”Plug in an IHolidayProvider for country-aware calculations:
var holidays = StaticHolidayProvider.CreateBuilder() .AddHoliday("IT", 2026, 12, 25, "Christmas") .AddHoliday("IT", 2026, 12, 26, "St. Stephen's Day") .Build();
var calculator = new TemporalCalculator(holidays);var delivery = calculator.AddBusinessDays(today, 5, "IT");IHolidayProvider
Section titled “IHolidayProvider”public interface IHolidayProvider{ IEnumerable<string> SupportedCountries { get; } IEnumerable<Holiday> GetHolidays(int year, string countryCode); IEnumerable<Holiday> GetHolidays(int year, string countryCode, string? regionCode); bool IsHoliday(LocalDate date, string countryCode);}Built-in providers:
NoHolidaysProvider— default, returns no holidays (singleton)StaticHolidayProvider— configurable list with fluent builder
Holiday types: Public, Regional, Bank, Optional.
Performance
Section titled “Performance”TemporalCalculator uses O(1) mathematical formulas for counting business days (not iterative loops). For AddBusinessDays, full weeks are skipped in bulk (5 business days = 7 calendar days), with at most 4 iterations for the remainder.
Timezone Resolution
Section titled “Timezone Resolution”TimeZoneResolver handles cross-platform timezone ID resolution. It accepts both IANA IDs (Europe/Rome) and Windows IDs (Central European Standard Time) and resolves them to TimeZoneInfo.
// IANA ID (Linux native, Windows via conversion)var rome = TimeZoneResolver.GetTimeZone("Europe/Rome");
// Windows ID (Windows native, Linux via conversion)var cet = TimeZoneResolver.GetTimeZone("Central European Standard Time");
// Get IANA ID from a TimeZoneInfo (cross-platform)var ianaId = TimeZoneResolver.GetIanaId(zone); // "Europe/Rome"
// ValidationTimeZoneResolver.IsValidTimezone("Europe/Rome") // trueTimeZoneResolver.IsValidTimezone("Invalid/Zone") // false
// Common zones (cached)var utc = TimeZoneResolver.CommonZones.Utc;var ny = TimeZoneResolver.CommonZones.NewYork;var tokyo = TimeZoneResolver.CommonZones.Tokyo;The resolver includes OpenTelemetry tracing via ActivitySource("Pragmatic.Temporal") for observability of timezone resolution in production.
TemporalContext: Per-Request Time
Section titled “TemporalContext: Per-Request Time”TemporalContext is a scoped service that holds the clock, client timezone, and business timezone for the current request. It provides conversion methods for the two most common scenarios: “show this to the user” (client timezone) and “calculate this for the business” (business timezone).
public class TemporalContext{ public required TimeZoneInfo ClientTimeZone { get; init; } public required TimeZoneInfo BusinessTimeZone { get; init; } public required IClock Clock { get; init; }
// Current time in different contexts public DateTimeOffset UtcNow { get; } public ZonedDateTime ClientNow { get; } public ZonedDateTime BusinessNow { get; } public LocalDate ClientToday { get; } public LocalDate BusinessToday { get; } public LocalDate UtcToday { get; }
// Conversions public ZonedDateTime ToClientTime(DateTimeOffset utc); public ZonedDateTime ToBusinessTime(DateTimeOffset utc); public DateTimeOffset ClientToUtc(DateTime clientLocal); public DateTimeOffset BusinessToUtc(DateTime businessLocal);
// Date range helpers for database queries public DateTimeOffset ClientStartOfDay(LocalDate date); public DateTimeOffset ClientEndOfDay(LocalDate date); public (DateTimeOffset Start, DateTimeOffset End) ClientTodayRange { get; } public (DateTimeOffset Start, DateTimeOffset End) BusinessTodayRange { get; }}Factory methods
Section titled “Factory methods”// UTC context (both client and business = UTC)var ctx = TemporalContext.Utc();
// Single zone contextvar ctx = TemporalContext.ForZone("Europe/Rome");
// Custom contextvar ctx = new TemporalContext{ Clock = clock, ClientTimeZone = TimeZoneResolver.GetTimeZone("America/New_York"), BusinessTimeZone = TimeZoneResolver.GetTimeZone("Europe/Rome")};Date range queries
Section titled “Date range queries”The ClientStartOfDay / ClientEndOfDay methods are designed for database queries that need UTC ranges corresponding to a client’s local day:
// "Show me today's orders" in the client's timezonevar (start, end) = context.ClientTodayRange;var orders = await db.Orders .Where(o => o.CreatedAt >= start && o.CreatedAt < end) .ToListAsync();Relative Date Navigation
Section titled “Relative Date Navigation”Extension methods on LocalDate provide relative navigation without manual arithmetic:
var today = new LocalDate(2026, 3, 21); // Saturday
// Day-of-week navigationtoday.Next(DayOfWeek.Monday) // 2026-03-23today.Previous(DayOfWeek.Friday) // 2026-03-20today.NextOrSame(DayOfWeek.Saturday) // 2026-03-21 (same day)
// Month-basedtoday.FirstInMonth(DayOfWeek.Monday) // 2026-03-02today.LastInMonth(DayOfWeek.Friday) // 2026-03-27today.NthInMonth(2, DayOfWeek.Tuesday) // 2nd Tuesday = 2026-03-10today.NthInMonth(-1, DayOfWeek.Sunday) // Last Sunday
// Year-basedtoday.FirstInYear(DayOfWeek.Monday)today.LastInYear(DayOfWeek.Friday)
// Business day helperstoday.NextWeekday() // Mondaytoday.PreviousWeekday() // Fridaytoday.NearestWeekday() // Sat->Fri, Sun->Mon
// ISO weektoday.IsoWeekOfYear() // ISO 8601 week numbertoday.IsoWeekYear() // ISO week year (may differ near year boundary)LocalDateExtensions.FromIsoWeekDate(2026, 12, DayOfWeek.Monday)ASP.NET Core Integration
Section titled “ASP.NET Core Integration”The Pragmatic.Temporal.AspNetCore package provides middleware, timezone detection, and model binding.
Timezone detection middleware
Section titled “Timezone detection middleware”app.UseTemporalContext();The middleware creates a TemporalContext per request with the detected client timezone. Detection strategies (evaluated in priority order):
| Strategy | Source | Priority |
|---|---|---|
QueryStringTimeZoneStrategy | ?tz=Europe/Rome | 10 |
HeaderTimeZoneStrategy | X-Timezone: Europe/Rome | 20 |
ClaimsTimeZoneStrategy | JWT claim timezone | 30 |
CookieTimeZoneStrategy | Cookie tz | 40 |
Implement ITimeZoneDetectionStrategy for custom detection.
Model binding
Section titled “Model binding”LocalDateModelBinder binds LocalDate from query strings and route parameters using ISO 8601 format (yyyy-MM-dd).
IPragmaticBuilder Integration
Section titled “IPragmaticBuilder Integration”Configure temporal services in Program.cs:
await PragmaticApp.RunAsync(args, app =>{ app.UseTemporal(temporal => { temporal.UseDefaultTimeZone("Europe/Rome"); temporal.UseBusinessTimeZone("Europe/Rome"); temporal.UseHolidayProvider<ItalianHolidayProvider>(); temporal.UseClock<SystemClock>(); });});What gets registered automatically
Section titled “What gets registered automatically”AddPragmaticTemporal() registers the following defaults:
| Service | Default | Lifetime |
|---|---|---|
IClock | SystemClock.Instance | Singleton |
TimeProvider | From IClock.GetTimeProvider() | Singleton |
IHolidayProvider | NoHolidaysProvider.Instance | Singleton |
ITemporalCalculator | TemporalCalculator (with IHolidayProvider) | Singleton |
TemporalContext | Scoped factory using TemporalOptions | Scoped |
TemporalOptions | UTC defaults | Singleton |
All registrations use TryAdd, so your overrides via TemporalBuilder always win.
Ecosystem Integration
Section titled “Ecosystem Integration”Pragmatic.Temporal is a foundational module. Other modules depend on IClock and the temporal types.
Persistence
Section titled “Persistence”Pragmatic.Temporal.EntityFrameworkCore provides EF Core value converters for all temporal types, ensuring LocalDate maps to date columns, ZonedDateTime stores as UTC datetimeoffset, and Duration stores as ISO 8601 strings.
Events
Section titled “Events”Domain events use IClock (via TimeProvider) for their timestamps. When you use TestClock, event timestamps are deterministic.
Actions
Section titled “Actions”DomainActionInvoker uses TimeProvider for activity tracing timestamps. The clock flows through the entire pipeline.
JSON Serialization
Section titled “JSON Serialization”Pragmatic.Temporal.Json provides System.Text.Json converters for all temporal types, ensuring round-trip fidelity for API payloads.
Golden Rules
Section titled “Golden Rules”- Store UTC only — databases should contain UTC timestamps
- Convert at boundaries — timezone conversion at API input/output only
- Never query with local time — pre-calculate UTC ranges before querying
- Use IClock, never DateTime.Now — for testability
- Be explicit about DST — always specify a policy for ambiguous/non-existent times
See Also
Section titled “See Also”- Getting Started — Mental model, golden rules, minimal example
- Core Types — All types, storage formats, conversion rules
- DST Handling — Ambiguous times, non-existent times, explicit policies
- Business Days — TemporalCalculator, IHolidayProvider, holiday types
- Testing — TestClock, TestTemporalContext, deterministic time
- Common Mistakes — Wrong/Right/Why for the most frequent issues
- Troubleshooting — Problem/Checklist format for runtime issues