A report says an invoice was created at 9:00 AM. Whose 9:00 AM? The server's, the user's, or UTC? When the time is stored as a plain DateTime, nothing in the value tells you, so every reader has to guess. And when the code under test calls DateTime.UtcNow, you cannot write a reliable test for "expires after 30 days" without waiting 30 days, or without a test that fails when it runs across midnight or a daylight saving change.
Time is an input to your code, just like a database or an HTTP API. Treat it that way: represent a point in time as a DateTimeOffset, and get the current time from an injected TimeProvider.
A DateTime carries a Kind of Utc, Local or Unspecified. It is easy to lose. SQL Server datetime2 stores no Kind, so EF Core reads every value back as Unspecified, and naive string formatting drops it too. You end up with a value that "looks like" UTC but might not be.
A DateTimeOffset always carries its offset from UTC, so it identifies exactly one moment, wherever it is read.
public class Invoice{public DateTime CreatedAt { get; set; } // UTC? Local? Nobody knows}invoice.CreatedAt = DateTime.UtcNow;
❌ Figure: Bad example - A DateTime relies on naming and convention to say it is UTC
public class Invoice{public DateTimeOffset CreatedAt { get; set; }}invoice.CreatedAt = timeProvider.GetUtcNow();
✅ Figure: Good example - A DateTimeOffset is an unambiguous point in time and converts to any time zone without losing it
DateTime.UtcNow and DateTimeOffset.UtcNow are static. You cannot replace them in a test, so time-dependent logic becomes untestable or flaky. TimeProvider (built into .NET 8+, and available through the Microsoft.Bcl.TimeProvider package for older targets) is the standard abstraction for time, so you do not need to invent your own IClock.
| Option | Unambiguous | Testable | Notes |
|---|---|---|---|
DateTime.UtcNow | ❌ Relies on Kind | ❌ | Easy to confuse with DateTime.Now |
DateTimeOffset.UtcNow | ✅ | ❌ | Static, so tests cannot control it |
timeProvider.GetUtcNow() | ✅ | ✅ | Needs a TimeProvider injected |
Register the real clock once at startup:
builder.Services.AddSingleton(TimeProvider.System);
public class SubscriptionService(TimeProvider timeProvider){public bool IsExpired(Subscription subscription)=> subscription.ExpiresAt <= timeProvider.GetUtcNow();}
✅ Figure: Good example - The class asks for the time instead of reaching for a static clock
FakeTimeProvider (from the Microsoft.Extensions.TimeProvider.Testing package) lets a test set the time and move it forward. A test for "expires after 30 days" then runs in milliseconds and gives the same result every day.
[Fact]public void Subscription_expires_exactly_30_days_after_it_starts(){var time = new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));var service = new SubscriptionService(time);var subscription = new Subscription { ExpiresAt = time.GetUtcNow().AddDays(30) };time.Advance(TimeSpan.FromDays(30) - TimeSpan.FromTicks(1));Assert.False(service.IsExpired(subscription));time.Advance(TimeSpan.FromTicks(1));Assert.True(service.IsExpired(subscription));}
✅ Figure: Good example - The test moves the clock to the exact boundary instead of waiting or depending on today's date
EF Core maps DateTimeOffset to SQL Server datetimeoffset for you, so the column records an unambiguous instant (UTC when the value comes from GetUtcNow()) and reports can convert it to the viewer's time zone at display time. The column only keeps a local offset, such as the user's +10:00, when the value was captured with that offset.
Other providers behave differently. Npgsql maps DateTimeOffset to PostgreSQL timestamptz and only accepts an offset of zero, and the SQLite provider stores it as text, so it cannot translate ordering or comparisons to SQL. Check your provider's documentation before relying on the offset.
Note: An offset is not a time zone. A DateTimeOffset stores an offset (for example +10:00), not a time zone (for example Australia/Brisbane). That is exactly right for "when did this happen". For a future event that must stay at the same local time across daylight saving changes, such as a 9:00 AM meeting every Monday, also store the time zone ID and calculate the offset when you need it.
Figure: An offset records when something happened, a time zone records where
DateOnlyInstant and ZonedDateTime types solve the same problem