pragmatic-use-temporal · git:20261004.6f45a36 · 2026-10-04 · sha256 2dbf2aeb5858cbe2
pragmatic-use-temporal git:20261004.6f45a36A
Immutable. This exact content never changes and is served at /api/v1/blob/2dbf2aeb5858cbe2.
---
name: pragmatic-use-temporal
description: Use when code reads the current time, stores or converts dates, counts working days, schedules with cron, or when PRAG0900-0904 or PRAG0690 fire (Pragmatic.Temporal, IClock, [FromClock], time zones, TestClock).
---
# Pragmatic Use Temporal
**Covers:** Handle time with Pragmatic.Temporal: IClock and [FromClock] instead of DateTime.Now, business and client time zones, LocalDate/ZonedDateTime/DateRange, business days and holidays, cron, DST, EF Core mapping, TestClock.
Two rules carry most of the module:
1. **Store instants in UTC and convert at the edge** (model binding and serialization), never in a
handler.
2. **Nothing reads the machine's clock.** "Now" comes from `IClock`, so a test can freeze it and a job
and a request agree on what today is.
## Wiring
On a Pragmatic host the generator registers the module when the package is referenced; the host says
what only it knows:
```csharp
app.UseTemporal(t => t
.UseBusinessTimeZone("Europe/Rome") // the application's own zone: opening hours, deadlines
.UseDefaultTimeZone("Europe/Rome") // a caller who says nothing is in this one
.UseHolidayProvider<CompanyHolidays>()); // default: no holidays, weekends only
```
`UseClock(clock)` replaces the clock (tests). In a plain host: `services.AddPragmaticTemporal(o => …)`.
Time-zone ids are IANA (`Europe/Rome`), resolved the same on Windows and Linux.
## Now
```csharp
[DomainAction]
public partial class ExpireOverdueOrders : VoidDomainAction
{
[FromClock] public DateTimeOffset Now { get; private set; } // written by the invoker from IClock
private IRepository<Order> _orders = null!;
// …
}
```
`[FromClock]` takes `DateTimeOffset` (`UtcNow`) or `DateOnly` (`UtcToday`), `{ get; private set; }`;
otherwise **PRAG0734**. Elsewhere inject `IClock`: `UtcNow`, `Now`, `UtcToday`, `Today`, `TimeOfDay`,
`GetTimeProvider()` for BCL APIs that take a `TimeProvider`. A host that binds `[FromClock]` without a
clock registered is **PRAG1698** at build time.
The analyzers keep the machine's clock out: **PRAG0900** `DateTime.Now`/`UtcNow`, **PRAG0901**
`DateTime.Today`, **PRAG0902** a `DateTime` without a `DateTimeKind`, **PRAG0903** `DateTimeOffset`
compared with `<`/`>` (compare `.UtcDateTime`), **PRAG0904** the same in test code.
## At the API edge
Declare how a value crosses the wire; the generator writes the registration per assembly:
| Attribute | Direction | Effect |
|---|---|---|
| `[AsUtc]` | in + out | no offset → read as UTC; always written in UTC |
| `[FromClientTimezone]` | in | wall time without an offset is the caller's local time |
| `[ToClientTimezone]` | out | written in the caller's zone |
| `[FromBusinessTimezone]` | in | wall time without an offset is the business zone's |
| `[ToBusinessTimezone]` | out | written in the business zone |
| `[KeepTimezone]` | in + out | the offset the caller sent is preserved |
An explicit offset in the payload always wins. ⚠️ The attributes are read **per property, in the
assembly that declares the type**: a DTO from a package that did not generate its own registration
carries none. Full detail: `../pragmatic-ecosystem/references/api-surface/temporal.md`.
## Types: pick by meaning
| Type | Is | Use for |
|---|---|---|
| `DateTimeOffset` (UTC) | an instant | timestamps, "when it happened", what the database stores |
| `LocalDate` (≈ `DateOnly`) | a calendar day, no zone | birthdays, due dates, leave days |
| `LocalTime` | a time of day | opening hours |
| `LocalDateTime` | a wall-clock moment, no zone | what a person typed before you know their zone |
| `ZonedDateTime` | a wall-clock moment in a zone | "9:00 in Rome", arithmetic across DST |
| `Duration` | elapsed physical time | timeouts, SLAs in hours |
| `Period` | calendar units (months, days) | "one month later", age |
| `DateRange` | `[start, end]` of days | stays, leave requests; overlap, union, enumeration |
| `CronExpression` | a recurring schedule | stored schedules, evaluated in a zone |
⚠️ **`Duration` is not calendar arithmetic.** "One day later" across a DST change is 23 or 25 hours:
`zoned.AddDays(1)` keeps the wall time, `zoned.Add(Duration.FromHours(24))` keeps the elapsed time. Say
which one you mean.
⚠️ A wall time that does not exist (spring-forward) or exists twice (fall-back) has to be resolved: be
strict at input (refuse and ask) and forgiving in background work; `SetBeforeRomeSpringForward()` on
`TestClock` puts a test right before one.
## Business days
```csharp
public sealed class PaymentTerms(ITemporalCalculator calendar, IClock clock)
{
public LocalDate DueDate(int workingDays) => calendar.AddBusinessDays(clock.Today, workingDays, "IT");
}
```
- `AddBusinessDays`, `CountBusinessDays`, `IsBusinessDay`, `IsHoliday`, `NextBusinessDay`,
`PreviousBusinessDay`, period starts and ends.
- ⚠️ **Without a country code only weekends count**: Christmas on a Thursday is a business day for
`IsBusinessDay(date)`.
- ⚠️ `CountBusinessDays(from, to)` is `[from, to)`: the end is excluded.
- `Next/PreviousBusinessDay` return a day strictly after/before, even when the day itself is one.
- The module ships **no holiday data**: implement `IHolidayProvider` (a table, a service such as the
free Nager.Date API, cached per year and country), or build a `StaticHolidayProvider` for a fixed list.
Company closures are holidays of your provider.
## Schedules
`CronExpression.Parse("0 8 * * 1-5")` (5 or 6 fields, `L`, `W`, `#`, names, `@daily`…),
`GetNextOccurrence(from, zone)`, `GetOccurrences(from, until, zone)`. Evaluated in a zone, results in UTC,
DST handled: an interval schedule fires in both passes of a repeated hour, a fixed-time one once.
Recurring **jobs** use this through `pragmatic-use-jobs`.
## Storage
Reference `Pragmatic.Temporal.EFCore` and the generated `DbContext` registration calls
`UsePragmaticTemporal()`: the temporal types get their columns (`LocalDate` → `date`, …) and stored
`DateTimeOffset`s are normalised to UTC. A host that stores instants without it gets **PRAG0690**: add
the package. When the wall-clock value really is the intent, say so in the boundary's database
configuration (`UsePragmaticTemporal(t => t.NormalizeInstantsToUtc = false)`), which the generated call
keeps.
Querying by a local day: convert the day's bounds in the business zone to UTC and compare instants;
never compare a stored UTC instant with a local wall time.
## Testing
```csharp
var clock = TestClock.AtNoon(2026, 5, 6); // or new TestClock(instant); register as IClock
clock.AdvanceDays(3);
clock.SetBeforeRomeSpringForward(2026);
var calendar = new TemporalCalculator(new TestHolidayProvider().AddHoliday(2026, 12, 25, "IT"));
var context = TestTemporalContext.ForRome(now: instant); // a request's zone, in a unit test
```
`TestHolidayProvider.AddHoliday(year, month, day, countryCode)`: the last argument is the **country**.
Freeze the clock in every test that crosses midnight, a month end or a DST change. See
`pragmatic-use-testing` for replacing `IClock` in the host under test.
## Examples
Complete files in [`examples/`](examples/README.md), copied from the Time off, Showcase and Invoicing
example applications (code that compiles and that `TimeOff.IntegrationTests`,
`Showcase.IntegrationTests` and `Invoicing.IntegrationTests` exercise) and kept identical to it by the
gate: a holiday provider and its wiring, business days counted, `[FromClock]`, `[FromBusinessTimezone]`,
and `TestClock` in a test.
`ZonedDateTime` arithmetic across a DST change is used by no tested application yet, so there is no
example of it here: the sections above are the reference.