It's a date!
DateTimes are super complicated. On the surface, they are deceptively simple (a date and a time, right?), but as soon as you try to do something with them in the real world, they tend to become flaky and off by an hour or not exactly correct enough for their intended usage.
Part of the problem, at least for us C# developers, is that Microsoft did us no favors when they introduced the DateTime
struct (they actually followed a long-standing tradition of including a hopeless date & time representation as a core language feature). System.DateTime
is and continues to be a source of errors in the .net ecosystem.
Today, we had to fix a bug in in our sales campaign subsystem, and part of the underlying issue was the usage of System.DateTime
.
This article explains how we use more precise representations of date and time to avoid (or, in this case, remove) these kinds of bugs.
The problem at hand
At SATS, we occasionally run campaigns for a limited time. These days (Sep 2022), we are running a campaign in Norway, Sweden and Finland where you get a month of free access if you buy a membership. The campaign started on the 1st of September and will be gone on the 20th. In our digital sales flow, we have a countdown which shows how much time remains before the campaign expires:
“But wait,” someone said. “The counter is off!” And so it was. For some reason, the campaign was counting down towards midnight Sep 21st, 24 hours after the campaign has ended.
Abstraction layers, ERPs and DateTimes
Digging into the data store micro-service responsible for the campaigns confirmed that it indeed did believe the campaign ends on the 21st, while a quick check of our ERP system confirmed that the campaign was set up to end on the 20th. Somewhere in between, something was off.
Our ERP system exposes dates as strings, in the ISO 8601 format “yyyy-mm-dd”. Retrieving the details for the campaign in question resulted in a response payload looking more or less like this:
{
"validityPeriod": {
"from": "2022-09-01",
"to": "2022-09-20"
},
"name": "September free month campaign",
"valid": true
}
In our integration layer, this was transformed from its json representation to a strongly typed object with this structure:
public class Campaign
{
public int Id { get; set; }
public string Code { get; set; }
public string Name { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public bool IsValid { get; set; }
}
And this is where we went wrong. While DateTime
does intuitively look like the correct representation of a “yyyy-mm-dd” string, it is not. At least not if you are not aware of the many intricacies of how DateTime
behaves depending on DateTimeKind, system clock settings, etc, and very, very careful on how it is handled at service boundaries.
Once this object had passed through the service, in a .net Azure app service running on what is presumably a VM set to the UTC time zone, the DateTime
instance had been interpreted as "2022-09-20T00:00:00Z"
- ie no longer a local date & time, but a specific point in time. When this was then converted to Finnish time (Finland being in a different time zone than the rest of the Nordics), we were off by an hour or two, which moved campaign end date one day into the future.
This might’ve been what happened. We’re not sure, and frankly, life is too short to find out. Point is, System.DateTime
is not deterministic enough for humans to understand, and there are much better alternatives out there.
There is no reason to go into the intricacies of the DateTime object here. Suffice to say it leaves a lot to be desired.
It is, however, a nice segue into…
Proper abstractions and presentations
I am a big fan of the architecture and application code modelling the actual business and business rules. In this case, the business rule is that
the campaign’s end date is on Sep the 20th
Not Sep 20th UTC, but Sep 20th in the local time of our clubs. That means a different instant in Norway and Finland (by one hour).
The best way to model a local date in C#? Noda time’s LocalDate. (If you are not familiar with Jon Skeet’s marvellous NodaTime…there’s no time like the present).
It does not take the system clock or anything else into consideration, it merely represents a specific date somewhere. If this is a bit confusing, think of it this way: When you are asked “When is your birth day?”, the answer is the same always and everywhere (Apr 22nd in my case, feel free to send craft beer), regardless of where you are in the world. While this is enough information for a human to know exactly when to embarass you with an ill-timed “Happy birthday” in the office, a computer will only have a vague idea of when this is.
In order to make the computer know exactly when your birthday starts (or the campaign ends), it will need to know where you want your birthday to start. In which time zone, to be precise. After all, your London birthday starts an hour before your Oslo birthday.
In our case, the first thing to do was to fix the model:
public class Campaign
{
public int Id { get; set; }
public string Code { get; set; }
public string Name { get; set; }
public LocalDate StartDate { get; set; }
public LocalDate EndDate { get; set; }
public bool IsValid { get; set; }
}
This ensures that the Campaign object keeps track of only what it actually knows (the validity dates in local time), and does not implicitly add any interpretation of what those dates might or might not mean on the local system.
We then use NodaTime’s LocalDatePattern to parse the ERP format:
private static readonly LocalDatePattern erpLocalDatePattern
= LocalDatePattern.Create("yyyy-MM-dd", CultureInfo.InvariantCulture);
...
private static LocalDate ParseErpLocalDate(string dateString)
{
var result = erpLocalDatePattern.Parse(dateString);
if (result.Success)
return result.Value;
throw result.Exception;
}
This keeps the date consistent across service and class boundaries, until we know where in the world to place it. In this case, we ultimately convert it to a DateTimeOffset
(which allows for friendly and precise ISO-based string conversions and is supported out of the box by the .net serializers) at the right moment: when we know which country the campaign we are processing is valid in. More or less like this:
var campaignEndTime = erpCampaign.EndDate.AtMidnight().InZoneStrictly(country.TimeZone).ToDateTimeOffset();
When returned in an API (to our website, in this case), the representation is correct and unambiguous (Helsinki is 3 hours ahead of UTC):
{
"StartTime": "2022-09-01T00:00:00+03:00",
"EndTime": "2022-09-20T00:00:00+03:00",
"Name": "September free month campaign",
}