Pragmatic.Temporal
Type-safe date/time handling with DST-aware arithmetic and automatic timezone management for .NET 10.
The Problem
Section titled “The Problem”.NET’s DateTime and DateTimeOffset are ambiguous:
DateTimeconflates date, time, and timezone into one type with aKindproperty that is easily ignoredDateTimeOffsetpreserves offset but not timezone (two offsets can map to different zones)- Calendar arithmetic is broken:
new DateTime(2024, 1, 31).AddMonths(1)produces2024-02-29but the behavior is not always intuitive - DST transitions cause silent bugs when local times are ambiguous or non-existent
DateTime.Nowin code makes testing non-deterministic and flaky
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.
using Pragmatic.Temporal.Types;
// Calendar date -- no time, no timezonevar birthday = new LocalDate(1990, 5, 15);
// Time of day -- no date, no timezonevar opening = new LocalTime(9, 0);
// Wall clock datetime -- no timezonevar appointment = new LocalDateTime(2026, 3, 21, 14, 30);
// Full timezone awarenessvar flight = ZonedDateTime.FromUtc(DateTimeOffset.UtcNow, "Europe/Rome");Packages
Section titled “Packages”| Package | Role |
|---|---|
| Pragmatic.Temporal | Core types, calendar arithmetic, business days, cron, IClock |
| Pragmatic.Temporal.Json | System.Text.Json converters for all temporal types |
| Pragmatic.Temporal.EntityFrameworkCore | EF Core value converters, type mapping, model conventions |
| Pragmatic.Temporal.AspNetCore | Middleware, timezone detection strategies, model binding |
| Pragmatic.Temporal.Testing | TestClock, TestTemporalContext, TestHolidayProvider |
| Pragmatic.Temporal.Analyzers | Roslyn analyzers: PRAG0900-PRAG0904 |
Installation
Section titled “Installation”dotnet add package Pragmatic.Temporaldotnet add package Pragmatic.Temporal.Json # Optional: JSON supportdotnet add package Pragmatic.Temporal.EntityFrameworkCore # Optional: EF Coredotnet add package Pragmatic.Temporal.AspNetCore # Optional: ASP.NET Coredotnet add package Pragmatic.Temporal.Testing # Optional: Test utilitiesQuick Start
Section titled “Quick Start”using Pragmatic.Temporal;
// Dates without time — birthdays, deadlinesvar birthday = new LocalDate(1990, 3, 15);var age = LocalDate.Today.Year - birthday.Year;
// Times without date — schedules, opening hoursvar checkIn = new LocalTime(14, 0);
// Date + time without timezone — local eventsvar meeting = new LocalDateTime(2025, 6, 15, 10, 30);
// Full timezone-aware — flight departures, global schedulingvar departure = ZonedDateTime.Now(TimeZoneInfo.FindSystemTimeZoneById("Europe/Rome"));
// Testable clock — inject IClock instead of using DateTime.NowIClock clock = new SystemClock();var now = clock.GetCurrentInstant();Core Types
Section titled “Core Types”LocalDate
Section titled “LocalDate”Calendar date without time or timezone. Use for birthdays, holidays, deadlines, reporting dates.
var today = LocalDate.Today;var date = new LocalDate(2026, 3, 21);
// Propertiesdate.Year // 2026date.Month // 3date.Day // 21date.DayOfWeek // DayOfWeek.Saturdaydate.DayOfYear // 80date.IsWeekend // truedate.IsWeekday // false
// Arithmeticdate.AddDays(7) // 2026-03-28date.AddMonths(1) // 2026-04-21date.AddYears(1) // 2027-03-21date.DaysBetween(other) // days between two dates
// Navigationdate.StartOfMonth() // 2026-03-01date.EndOfMonth() // 2026-03-31date.StartOfYear() // 2026-01-01date.EndOfYear() // 2026-12-31
// Combine with timedate.At(new LocalTime(14, 30)) // LocalDateTimeBacked by DateOnly internally. Implements IEquatable<LocalDate>, IComparable<LocalDate>.
LocalTime
Section titled “LocalTime”Time of day without date or timezone. Use for store hours, meeting times, alarm times.
var time = new LocalTime(14, 30);var withSeconds = new LocalTime(14, 30, 45);
// Propertiestime.Hour // 14time.Minute // 30time.Second // 0
// Common timesLocalTime.Midnight // 00:00:00LocalTime.Noon // 12:00:00Backed by TimeOnly internally.
LocalDateTime
Section titled “LocalDateTime”Date and time without timezone. Use for wall clock appointments, recurring meetings, schedules.
var dt = new LocalDateTime(2026, 3, 21, 14, 30);
// Or compose from partsvar date = new LocalDate(2026, 3, 21);var time = new LocalTime(14, 30);var dt2 = new LocalDateTime(date, time);
// Propertiesdt.Date // LocalDatedt.Time // LocalTime
// Convert to zoneddt.InZone("Europe/Rome") // ZonedDateTimeZonedDateTime
Section titled “ZonedDateTime”A specific instant in time with full timezone awareness. Use for flight times, global events, anything that happened or will happen at a specific moment.
// From UTC instant + timezonevar rome = ZonedDateTime.FromUtc(DateTimeOffset.UtcNow, "Europe/Rome");
// From local time in a zonevar local = new LocalDateTime(2026, 3, 21, 14, 30);var zoned = ZonedDateTime.FromLocal(local, "America/New_York");
// Current time in a zonevar now = ZonedDateTime.NowIn("Europe/Rome");
// Propertiesrome.UtcDateTime // DateTimeOffset (UTC)rome.Zone // TimeZoneInforome.ZoneId // "Europe/Rome"rome.Offset // TimeSpan (+01:00 or +02:00 depending on DST)rome.LocalDateTime // DateTime in that zonerome.Date // LocalDate in that zonerome.Time // LocalTime in that zonerome.IsDaylightSavingTime // bool
// Timezone conversionrome.InZone("Asia/Tokyo") // Convert to Tokyo timerome.ToUtc() // Convert to UTCConstructors are restricted. Use factory methods to ensure DST safety.
Duration
Section titled “Duration”Immutable elapsed physical time. Unlike calendar arithmetic, Duration.FromDays(1) is always exactly 24 hours.
var d = Duration.FromHours(2) + Duration.FromMinutes(30);
// Factory methodsDuration.FromDays(7)Duration.FromHours(2)Duration.FromMinutes(30)Duration.FromSeconds(45)
// Common durationsDuration.ZeroDuration.OneDay // 24 hoursDuration.OneHourDuration.OneMinute
// Propertiesd.TotalHours // 2.5d.TotalMinutes // 150.0
// Arithmeticvar sum = d1 + d2;var diff = d1 - d2;var scaled = d1 * 2;Period
Section titled “Period”Calendar-based duration in years, months, and days. A Period of “1 month” added to January 31 results in February 28/29, not an error.
var oneMonth = Period.FromMonths(1);var oneYear = Period.FromYears(1);var custom = new Period(1, 2, 3); // 1 year, 2 months, 3 days
// Apply to datesvar nextMonth = today.Add(oneMonth); // Jan 31 -> Feb 28var expiryDate = startDate.Add(oneYear);
// ISO 8601 formatvar parsed = Period.Parse("P1Y2M3D"); // 1 year, 2 months, 3 daysperiod.ToString() // "P1Y2M3D"
// Compute between datesvar age = Period.Between(birthDate, today);DateRange
Section titled “DateRange”Closed date range [Start, End] with set operations. Use for reporting periods, booking windows, overlap detection.
// Factory methodsvar q1 = DateRange.Quarter(2026, 1);var march = DateRange.Month(2026, 3);var week = DateRange.Week(new LocalDate(2026, 3, 21));var single = DateRange.SingleDay(new LocalDate(2026, 3, 21));
// Propertiesq1.Start // 2026-01-01q1.End // 2026-03-31q1.Days // 90 (inclusive)q1.IsSingleDay // falseq1.Duration // Duration
// Set operationsq1.Contains(new LocalDate(2026, 2, 15)) // trueq1.Overlaps(march) // trueq1.Intersect(march) // DateRange [Mar 1 - Mar 31]
// Enumerationforeach (var date in q1) { /* iterates all dates */ }CronExpression
Section titled “CronExpression”Zero-dependency cron expression parser supporting standard 5-field and extended 6-field (with seconds) formats.
// Common expressions (static properties)CronExpression.EveryMinute // "* * * * *"CronExpression.EveryHour // "0 * * * *"CronExpression.Midnight // "0 0 * * *"CronExpression.Noon // "0 12 * * *"CronExpression.Weekdays // "0 0 * * 1-5"CronExpression.Weekends // "0 0 * * 0,6"
// Factory methodsCronExpression.EveryMinutes(15)CronExpression.Daily(new TimeOnly(9, 0))CronExpression.Weekly(DayOfWeek.Monday, new TimeOnly(8, 0))CronExpression.Monthly(1, new TimeOnly(0, 0))
// Custom parsingvar cron = CronExpression.Parse("0/15 9-17 * * 1-5"); // Every 15 min during business hours
// Supported syntax: *, ranges (1-5), lists (1,3,5), steps (*/5), L, W, #
// Get occurrencesvar next = cron.GetNextOccurrence(DateTimeOffset.UtcNow);var nextFive = cron.GetNextOccurrences(DateTimeOffset.UtcNow, 5);
// Propertiescron.Expression // original stringcron.HasSeconds // true for 6-fieldcron.Semantics // Unix (OR) or Quartz (AND) day matchingIClock Abstraction
Section titled “IClock Abstraction”Never use DateTime.Now directly. Inject IClock for testable time:
public interface IClock{ DateTimeOffset UtcNow { get; } DateTimeOffset Now { get; } DateOnly UtcToday { get; } DateOnly Today { get; } TimeOnly UtcTimeOfDay { get; } TimeOnly TimeOfDay { get; } TimeProvider GetTimeProvider();}Production
Section titled “Production”SystemClock delegates to TimeProvider.System:
services.AddSingleton<IClock, SystemClock>();// Or use the singleton instance:IClock clock = SystemClock.Instance;TestClock allows full time control:
var clock = new TestClock();clock.SetDateTime(2026, 3, 21, 10, 0, 0);clock.Advance(TimeSpan.FromHours(2));clock.AdvanceDays(1);
// Fluent APIvar clock = TestClock.AtNoon(2026, 3, 21) .WithAutoAdvance(TimeSpan.FromMilliseconds(1));
// DST test helpersclock.SetBeforeRomeSpringForward(2026);clock.SetBeforeUsEasternSpringForward(2026);Business Day Calculations
Section titled “Business Day Calculations”ITemporalCalculator provides O(1) business day calculations with pluggable holiday providers.
var calculator = new TemporalCalculator(); // No holidaysvar delivery = calculator.AddBusinessDays(today, 5);
// With holidaysvar 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");ITemporalCalculator Methods
Section titled “ITemporalCalculator Methods”| Method | Description |
|---|---|
AddBusinessDays(from, days) | Add/subtract business days (weekends only) |
AddBusinessDays(from, days, countryCode) | Add/subtract business days (weekends + holidays) |
CountBusinessDays(from, to) | Count business days in range |
IsBusinessDay(date) | Check if date is a business day |
IsHoliday(date, countryCode) | Check if date is a holiday |
IsWeekend(date) | Check if date is Saturday or Sunday |
NextBusinessDay(from) | Get next business day |
PreviousBusinessDay(from) | Get previous business day |
StartOfWeek(date) | First day of the week |
StartOfMonth(date) | First day of the month |
StartOfQuarter(date) | First day of the quarter |
EndOfWeek(date) | Last day of the week |
EndOfMonth(date) | Last day of the month |
IHolidayProvider
Section titled “IHolidayProvider”Implement for custom holiday sources:
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 holidaysStaticHolidayProvider— configurable list with fluent builder
Holiday types: Public, Regional, Bank, Optional.
Relative Date Navigation
Section titled “Relative Date Navigation”Extension methods on LocalDate for relative navigation:
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()today.PreviousWeekday()today.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)DST Handling
Section titled “DST Handling”Pragmatic.Temporal provides explicit policies for DST transitions instead of silent defaults.
Non-Existent Times (Spring Forward)
Section titled “Non-Existent Times (Spring Forward)”// Europe/Rome: March 29, 2026 at 02:00 clocks jump to 03:00var local = new LocalDateTime(2026, 3, 29, 2, 30);
// Policy: shift to next valid timevar zoned = ZonedDateTime.FromLocal(local, "Europe/Rome", nonExistentTimePolicy: NonExistentTimePolicy.ShiftForward);// Result: 03:00 (first valid time after the gap)
// Policy: throwZonedDateTime.FromLocal(local, "Europe/Rome", nonExistentTimePolicy: NonExistentTimePolicy.ThrowException);// Throws NonExistentTimeExceptionAmbiguous Times (Fall Back)
Section titled “Ambiguous Times (Fall Back)”// Europe/Rome: October 25, 2026 at 03:00 clocks fall back to 02:00var local = new LocalDateTime(2026, 10, 25, 2, 30);
// Policy: use standard time (later occurrence)var standard = ZonedDateTime.FromLocal(local, "Europe/Rome", ambiguousTimePolicy: AmbiguousTimePolicy.UseStandardTime);// Result: 02:30 +01:00
// Policy: use daylight time (earlier occurrence)var daylight = ZonedDateTime.FromLocal(local, "Europe/Rome", ambiguousTimePolicy: AmbiguousTimePolicy.UseDaylightTime);// Result: 02:30 +02:00
// Policy: throwZonedDateTime.FromLocal(local, "Europe/Rome", ambiguousTimePolicy: AmbiguousTimePolicy.ThrowException);// Throws AmbiguousTimeExceptionASP.NET Core Integration
Section titled “ASP.NET Core Integration”Timezone Detection Middleware
Section titled “Timezone Detection Middleware”The middleware creates a TemporalContext per request with the detected client timezone:
app.UseTemporalContext();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 handles query string / route parameter binding for LocalDate.
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>(); });});TemporalBuilder Methods
Section titled “TemporalBuilder Methods”| Method | Description |
|---|---|
UseDefaultTimeZone(string) | Set default timezone by IANA ID |
UseDefaultTimeZone(TimeZoneInfo) | Set default timezone |
UseBusinessTimeZone(string) | Set business timezone |
UseHolidayProvider<T>() | Register custom holiday provider |
UseHolidayProvider(instance) | Register holiday provider instance |
UseClock<T>() | Replace the default clock |
UseClock(instance) | Replace clock with specific instance |
Analyzers
Section titled “Analyzers”| ID | Severity | Description |
|---|---|---|
| PRAG0900 | Warning | DateTime.Now, .UtcNow, DateTimeOffset.Now, .UtcNow — use IClock |
| PRAG0901 | Warning | DateTime.Today — use IClock.Today |
| PRAG0902 | Warning | new DateTime() without DateTimeKind — ambiguous Kind |
| PRAG0903 | Warning | DateTimeOffset relational comparison (<, >) without .UtcDateTime |
| PRAG0904 | Info | DateTime.Now/.UtcNow in test code — use TestClock |
Testing
Section titled “Testing”TestClock
Section titled “TestClock”Full-featured clock for deterministic time in tests:
var clock = new TestClock();clock.SetDateTime(2026, 3, 21, 10, 0, 0);
// Advance timeclock.Advance(TimeSpan.FromHours(2));clock.AdvanceDays(1);clock.AdvanceHours(3);
// Static factoriesvar clock = TestClock.AtNoon(2026, 3, 21);var clock = TestClock.AtMidnight(2026, 1, 1);var clock = TestClock.AtNow();
// Auto-advance (prevents flaky async tests)clock.WithAutoAdvance(TimeSpan.FromMilliseconds(1));
// DST test helpersclock.SetBeforeRomeSpringForward(2026);clock.SetBeforeRomeFallBack(2026);clock.SetBeforeUsEasternSpringForward(2026);TestTemporalContext
Section titled “TestTemporalContext”Pre-configured context for common test scenarios:
var rome = TestTemporalContext.ForRome();var ny = TestTemporalContext.ForNewYork();var custom = TestTemporalContext.Create("de-DE", "Europe/Berlin");TestHolidayProvider
Section titled “TestHolidayProvider”Fluent holiday provider for tests:
var holidays = new TestHolidayProvider() .AddHoliday(2026, 12, 25, "Christmas") .AddHoliday(2026, 12, 26, "Boxing Day");
var calculator = new TemporalCalculator(holidays);Comparison with NodaTime
Section titled “Comparison with NodaTime”| Aspect | Pragmatic.Temporal | NodaTime |
|---|---|---|
| Timezone data | .NET BCL (always current via OS) | Ships own TZDB (needs updates) |
| Calendars | Gregorian only | Multiple (Islamic, Hebrew, etc.) |
| Business days | Built-in with holiday providers | Not included |
| Cron expressions | Built-in parser | Not included |
| ASP.NET Core | Dedicated package with middleware | Community packages |
| EF Core | Dedicated package with conventions | Community packages |
| Analyzers | PRAG0900-0904 | Not included |
| Weight | Lightweight, focused | Comprehensive |
| IClock | Built-in with TestClock | IClock with FakeClock |
Documentation
Section titled “Documentation”Architecture
Section titled “Architecture”| Guide | What You’ll Learn |
|---|---|
| Concepts | Why Pragmatic.Temporal exists, type hierarchy, IClock, DST model, ecosystem integration |
Getting Started
Section titled “Getting Started”| Guide | What You’ll Learn |
|---|---|
| Getting Started | Mental model, golden rules, minimal example |
| Core Types | All types, storage formats, conversion rules |
Features
Section titled “Features”| Guide | What You’ll Learn |
|---|---|
| DST Handling | Ambiguous times, non-existent times, explicit policies |
| Business Days | TemporalCalculator, IHolidayProvider, holiday types |
| Testing | TestClock, TestTemporalContext, deterministic time |
| Guide | What You’ll Learn |
|---|---|
| Common Mistakes | Wrong/Right/Why for the 11 most frequent issues |
| Troubleshooting | Problem/Checklist format, diagnostics reference, FAQ |
Additional Topics
Section titled “Additional Topics”| Topic | Summary |
|---|---|
| Period | Calendar-based duration, ISO 8601 format, Period.Between() |
| DateRange | Month(), Quarter(), Year(), Week(), overlap, intersect, enumeration |
| CronExpression | Parsing, matching, next occurrence, standard and extended syntax |
| Relative Navigation | Next(DayOfWeek), NthInMonth, ISO week support |
| ASP.NET Core | Middleware, timezone strategies, TemporalContext, model binding |
| Analyzers | PRAG0900-0904 warnings for unsafe DateTime usage |
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
Requirements
Section titled “Requirements”- .NET 10.0+
License
Section titled “License”Part of the Pragmatic.Design ecosystem.