Replacing C# DateTime with NodaTime

A recent client project was starting to feel like water torture. It seemed like every day we were finding another date and time bug in our software.

As an experienced software developer, this was infuriating for a couple of reasons:

  1. In all my years I had never been on a project with the number of issues we were having
  2. I felt I should simply be better than this
  3. For crying out loud it's 2017 and we're still having time zone issues!

Rules of Thumb

After having a good pity party I reviewed my standard time handling rules

  1. Store everything in UTC time
  2. Store the user's timezone
  3. Use standard helper methods to convert between UTC and user timezone

Simple enough. We had tried to do all of these things on this project but we were still having issues. What was going on?

The Reality on the Ground

The reality was that this application was built on top of a legacy database that did not follow the standard rules.

  1. Several tables had date time values stored not as UTC but as the time of a local desktop application
  2. The user's application time zone did not always match the user's browser time zone
  3. Several date time values represented a schedule and were not related to any time zone at all
  4. There were exceptions to #1

As we continued to scale our team and work on more feature in parallel things just kept getting worse.

This wasn't working

To add even more complexity one feature team decided that they needed the ability to 'time travel' within the application. So a completely separate date time abstraction was written and now required our support and had its own set of issues.

What We Were Missing?

In frustration, we stopped firefighting and took a step back, reviewed our issues and decided what we really needed was:

  1. Code that would tell us at a glance if a value is in UTC, local date time, time only or a date only value
  2. All code for converting between UTC and local values needs to be standardized. No feature specific implementations
  3. Replace any DateTime.Now or DateTime.UTCNow calls so that we could support "time travel"
  4. Support display of date time values in the user's current local time zone

Our biggest challenge was solving problem #1 with something other than a naming standard and more code reviews.

Drilling Down Our Definitions

Specifically, we needed to support the following date related types:

  • Point in Time - something happened at a specific date and time and we need to record it. The value will be displayed differently depending on the user's timezone. Basically, a UTC datetime
  • Local Date and Time - a value representing the date and time that is assumed to be in the user's timezone. Can be converted to a Point in Time with the user's time zone but should maintain its current value until explicitly converted
  • Unspecified Date and Time - a value that is the same across time zones. Like a default schedule for a task that is due every day at 8:00
  • Date Only - a value that represents just a date, like a birthday
  • Time Only - a value that only represents a time

NodaTime to the Rescue

Even though one of our teams had already begun writing a custom date abstraction we decided to use the excellent NodaTime library. NodaTime supports all the types we needed and takes care of way more time zone use cases then we needed.

noda time

The NodaTime documentation is good and is worth a look. We made use the following NodaTime types:

  • Instant - an Instant represents a point in time. We changed all our timestamps, created and modified date time properties to be an Instant. If a property type was an Instant we could treat the value as UTC and would need to convert the value when displaying it to a user.
  • LocalDateTime - A LocalDateTime
  • LocalDate - used for storing date only values like birthdays and date ranges for reports and queries.
  • LocalTime - used for representing times of day, mostly for scheduling of reminders and repeated activities
  • DateTimeZone - represents the users current timezone

Our Classes

We changed our standard auto properties on our classes from DateTime values to their NodaTime counterparts.

Before

public class User
{
    public DateTime Birthday{get;set;}
    public DateTime? ShiftStartTime{get;set;}

    public DateTime CreatedDateTime{get;set;}
    public DateTime LastModifiedDateTime{get;set;}
}

After

public class User
{
    public LocalDate Birthday{get;set;}
    public LocalTime? ShiftStartTime{get;set;}

    public Instant CreatedDateTime{get;set;}
    public Instant LastModifiedDateTime{get;set;}
}

The Clock Service

Next, we needed to define our clock service to replace our calls to DateTime.Now to support 'time travel' and to perform any necessary conversions between UTC and a user's local time.

Our clock needed to provide the current instant, the current local datetime, convert a local datetime to an instant and convert an instant to a local datetime.

I have extracted our clock service for this post. The following interface represents the highlights of what we needed.

    public interface IClockService
    {
         DateTimeZone TimeZone{get;}

         LocalDateTime? OffsetDateTime{get;}

         Instant Now{get;}

         LocalDateTime LocalNow{get;}

         Instant ToInstant(LocalDateTime local);

         LocalDateTime ToLocal(Instant instant);
    }

We needed to know the user's current timezone to convert between an instant and a local datetime, which we already stored in the database. We supported time travel by allowing the user to provide a target date and time to travel to. We would always assume that the target datetime would always be in the user's local time.

For the purposes of this post when we 'time travel' we are also frozen at that point in time.

Determining 'Now'

The first thing we had to implement was how to determine the current Instant, or 'Now'.

When the user is not in 'time travel' mode this process is very simple. NodaTime provides a system clock class that we can use to retrieve the current instant as defined by the machine we are running on.

When the user has supplied an OffsetDateTime then we will need to convert the offset local date time to an instant using the supplied time zone.

     public Instant Now {
            get
            {
                if(OffsetDateTime == null){
                    return SystemClock.Instance.GetCurrentInstant();
                }
                return OffsetDateTime.Value.InZoneLeniently(TimeZone).ToInstant();
            }
        }

We use the InZoneLeniently method to convert to an instant so that NodaTime will simply round if the user happens to select an OffsetDateTime that doesn't actually exist in their timezone.

NodaTime makes retrieving the LocalNow value is even more straightforward.

public LocalDateTime LocalNow 
{
    get{
        return Now.InZone(TimeZone).LocalDateTime;
    }
}

We could have just returned the OffsetDateTime from this property if the user has 'time traveled' but by using the current instant we can change how we allow the user to time travel without affecting our local time calculation. For example, if we ever wanted to extend the clock service to support a running clock that ticks over hours and minutes in real time.

Converting Between Types

NodaTime makes converting between types super easy.

public Instant ToInstant(LocalDateTime local)
{
    return local.InZone(TimeZone, Resolvers.LenientResolver).ToInstant();
}

public LocalDateTime ToLocal(Instant instant)
{
    return instant.InZone(TimeZone).LocalDateTime;
}

Once again we are lenient when converting from a local date to an instant. Better in a business app to come up with an acceptable solution instead of throwing an error.

Using Time Travel

Now that are clock service is in place we could make use of it in our application like this contrived controller.

[Route("api/[controller]")]
public class ValuesController : Controller
{
    // GET api/values
    [HttpGet]
    public IActionResult Get()
    {
        var timezone = DateTimeZoneProviders.Tzdb.GetZoneOrNull("US/Pacific");
        
        var clock = new ClockService(timezone, null);
        var timeTravelClock = new ClockService(timezone, new LocalDate(1999,12,31).AtMidnight());
        
        return Ok( new { Now = clock.Now, LocalNow = clock.LocalNow, TimeTravelNow = timeTravelClock.Now, TimeTravelLocalNow = timeTravelClock.LocalNow });
    }
}

NodaTime comes with the necessary time zone database included. If you are targeting windows NodaTime also supports mapping between tz and Windows TimeZoneInfo values.

Configuring NodaTime Serialization

If we were to run the ValuesController above without making any other changes to our project we would get the following response.

bad json

... which isn't anything like want we wanted. There isn't even a value for Now. What we want is for each date time value to be serialized as an ISO-8601 value, yyyy-MM-ddTHH:mm:ssZ.

To get the values we want we need to tell JSON.Net how to serialize the NodaTime classes. To do that we need to add a reference to the NodaTime.Serialization.JsonNet nuget package.

dotnet add package NodaTime.Serialization.JsonNet

Then we need to update our Startup class to add the JSON.Net converters for NodaTime. Thankfully NodaTime provides an extension method to make this process painless.

public void ConfigureServices(IServiceCollection services)
{
    // Add framework services.
    services.AddMvc()
    .AddJsonOptions(options =>
    {
        var settings=options.SerializerSettings;
        settings.DateParseHandling = DateParseHandling.None;
        settings.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
    });
}

Now we get the result we were expecting

good json

Learn More

The code and service for this blog post can be found on GitHub at https://github.com/andrewhanson/NodaTimeExplorer

The NodaTime documentation can be found here http://nodatime.org/2.2.x/userguide/