Implementation strategy for Noda Time in an existing MVC5 application
Asked Answered
B

1

21

Our application is a big n-tier ASP.NET MVC application that is heavily dependent on Dates and (local) Times. Up until now we have been using DateTime for all our models, which worked fine because for years we were strictly a national website, dealing with a single timezone.

Now things have changed and we're opening our doors for an International audience. The first thought was "Oh, Crap. We need to refactor our entire solution!"

TimeZoneInfo

We opened LinQPad and started sketching out various converters to transform regular DateTime objects into DateTimeOffset objects, based on a TimeZoneInfo object that was created based on the User's TimeZone ID value from said User's profile.

We figured that we'd change all DateTime properties in the models into DateTimeOffset and be done with it. After all, we now had all the information we needed to store and display the User's local date and time.

Much of the code snippets were inspired by Rick Strahl's blog post on the subject.

NodaTime and DateTimeOffset

But then I read Matt Johnson's excellent comment. He validated my intention to switch to DateTimeOffset claiming: "DateTimeOffset is essential in a web application".

Regarding Noda Time, Matt says:

Speaking of Noda Time, I'll disagree with you that you have to replace everything throughout your system. Sure, if you do, you'll have a lot less opportunity to make mistakes, but you certainly can just use Noda Time where it makes sense. I've personally worked on systems that needed to do time zone conversions using IANA time zones (ex. "America/Los_Angeles"), but tracked everything else in DateTime and DateTimeOffset types. It's actually quite common to see Noda Time used extensively in application logic, but left completely out of the DTOs and persistence layers. In some technologies, like Entity Framework, you couldn't use Noda Time directly if you wanted to - because there's no where to hook it up.

This could have been directed directly at us, as we are in that exact scenario right now, including our choice to use IANA time zones.

Our plan, good or bad?

Our main goal is to create the least complex workflow for dealing with dates and times in various time zones. Avoid time zone calculations as much as possible in our Services, Repositories and Controllers.

In a nutshell the plan is to accept local dates and times from our front-end, converting them as soon as possible to a ZonedDateTime and convert those to DateTimeOffset as late as possible, just before saving the information to the database.

The key factor in determining the correct ZonedDateTime is the TimeZoneId property in the User model.

public class ApplicationUser : IdentityUser
{
    [Required]
    public string TimezoneId { get; set; }
}

Local DateTime to NodaTime

In order to prevent a lot of duplicate code, our plan is to create custom ModelBinders that convert local DateTime to ZonedDateTime.

public class LocalDateTimeModelBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        HttpRequestBase request = controllerContext.HttpContext.Request;

        // Get the posted local datetime
        string dt = request.Form.Get("DateTime");
        DateTime dateTime = DateTime.Parse(dt);

        // Get the logged in User
        IPrincipal p = controllerContext.HttpContext.User;
        var user = p.ApplicationUser();

        // Convert to ZonedDateTime
        LocalDateTime localDateTime = LocalDateTime.FromDateTime(dateTime);
        IDateTimeZoneProvider timeZoneProvider = DateTimeZoneProviders.Tzdb;
        var usersTimezone = timeZoneProvider[user.TimezoneId];
        var zonedDbDateTime = usersTimezone.AtLeniently(localDateTime);

        return zonedDbDateTime;
    }
}

We can litter our controllers with these Model Binders.

[HttpPost]
[Authorize]
public ActionResult SimpleDateTime([ModelBinder(typeof (LocalDateTimeModelBinder))] ZonedDateTime dateTime)
{
   // Do stuff with the ZonedDateTime object
}

Are we over-thinking this?

Storing the DateTimeOffset in the DB

We will use the concept of Buddy properties. To be honest, I'm not a huge fan of this, because of the confusion it creates. New developers will probably struggle with the fact that we have more than 1 way to save a create date.

Suggestions on how to improve this are very welcome. I have read comments about hiding the properties from IntelliSense to setting the real properties to private.

public class Item
{
    public int Id { get; set; }
    public string Title { get; set; }

    // The "real" property
    public DateTimeOffset DateCreated { get; private set; } 


    // Buddy property
    [NotMapped]
    public ZonedDateTime CreatedAt
    {
        get
        {
            // DateTimeOffset to NodaTime, based on User's TZ
            return ToZonedDateTime(DateCreated);
        }

        // NodaTime to DateTimeOffset
        set { DateCreated = value.ToDateTimeOffset(); }
    }


    public string OwnerId { get; set; }
    [ForeignKey("OwnerId")]
    public virtual ApplicationUser Owner { get; set; }

    // Helper method
    public ZonedDateTime ToZonedDateTime(DateTimeOffset dateTime, string tz = null)
    {
        if (string.IsNullOrEmpty(tz))
        {
            tz = Owner.TimezoneId;
        }
        IDateTimeZoneProvider timeZoneProvider = DateTimeZoneProviders.Tzdb;
        var usersTimezoneId = tz;
        var usersTimezone = timeZoneProvider[usersTimezoneId];

        var zonedDate = ZonedDateTime.FromDateTimeOffset(dateTime);
        return zonedDate.ToInstant().InZone(usersTimezone);
    }
}

Everything in between

We now have a Noda Time based application. The ZonedDateTime object makes it easier to do ad-hoc calculations and time zone driven queries.

Is this a correct assumption?

Bugloss answered 16/10, 2015 at 10:15 Comment(9)
Digesting your post now.... Will answer shortly... :)Frolic
Cool :) Thanks Matt. This will be a refactor with a big impact, so just want to validate our plan before we start.Bugloss
Important question - what kind of string input do expect to receive as parameter to your controller? ISO? With offset or plain? UTC? Varies? Please give example values. Thanks.Frolic
We get local datetime, without UTC info.Bugloss
Example would be "2015-10-16 20:40:00"Bugloss
And you always interpret that to be in the time zone of the user, as identified by ApplicationUser.TimezoneId, right? Or could there be cases where one user is looking at another user's data?Frolic
(I need to remember not to press enter for a new line) :) So we receive local datetime and then use the logged-in user's IANA Timezone ID to convert it to the proper ZonedDateTimeBugloss
Let us continue this discussion in chat.Bugloss
@FredFickleberryIII did you write by any chance about your experience with introducing NodaTime in a blog post that you can link? I'm in the same position now and curious, what you ended up doing.Clywd
F
17

First, I must say I am impressed! This is a very well-written post, and you appear to have explored many of the issues around this subject.

Your approach is good. However, I will offer the following for you to consider as improvements.

  • The model binder could be improved.

    • I would name it ZonedDateTimeModelBinder, since you are applying it to create ZonedDateTime values.

    • You'll want to use the bindingContext to get the value, rather than expecting the input to always be in request.Form.Get("DateTime"). You can see an example of this in the WebAPI model binder I wrote for LocalDate. MVC model binders are similar.

    • You'll also see in that example how I use Noda Time's parsing capabilities instead of DateTime.Parse. You might consider doing something that in yours, using a LocalDateTimePattern.

    • Make sure you understand how AtLeniently works, and also that we've changed its behavior for the upcoming 2.0 release (for good reason). See "Lenient resolver changes" at the bottom of the migration guide. If this matters in your domain, you may want to consider using the new behavior today by implementing your own resolver.

    • You might consider that there could be contexts where the current user's time zone is not the one for the data you're currently working with. Perhaps an admin is working with some other user's data. Therefore, you might need an overload that takes the time zone ID as a parameter.

  • For the common case, you might try registering the model binder globally, which will save you some keystrokes on your controllers:

      ModelBinders.Binders.Add(typeof(ZonedDateTime), new ZonedDateTimeModelBinder());
    

    You can always still use the attributed way if there is a parameter to pass.

  • Towards the bottom of your code, ZonedDateTime.FromDateTimeOffset(dto).ToInstant().InZone(tz) is fine, but can be done with less code. Either of these is equivalent:

    • ZonedDateTime.FromDateTimeOffset(dto).WithZone(tz)
    • Instant.FromDateTimeOffset(dto).InZone(tz)
  • This sounds like it's a production application, and thus I would take the time now to set up the ability to update your own time zone data.

  • See the user guide about how to use NZD files instead of the embedded copy in DateTimeZoneProviders.Tzdb.

  • A good approach is to constructor-inject IDateTimeZoneProvider and register it in a DI container of your choice.

  • Be sure to subscribe to the Announcements list from IANA so you know when new TZDB updates are published. Noda Time NZD files usually follow a short time later.

  • Or, you could get fancy and write something to check for the latest .NZD file and auto update your system, as long as you understand what (if anything) needs to occur on your side after an update. (This comes into play when an app includes scheduling of future events.)

  • WRT buddy properties - Yes, I agree they are a PITA. But unfortunately EF doesn't have a better approach at this time, because it doesn't support custom type mappings. EF6 likely won't ever have that, but it's being tracked in aspnet/EntityFramework#242 for EF7.

    • Update - In EF Core, you can't now use Value Converters to support Noda Time datatypes in your entity models.

Now, with all of that said, you might go about things slightly differently. I've done the above, and yes - it's complex. A simplified approach would be:

  • Don't use Noda Time types in your entities at all. Just use DateTimeOffset instead of ZonedDateTime.

  • Involve ZonedDateTime and the user's time zone only at the point where you're doing application logic.

The downside with this approach is that muddies the waters with regard to your domain. Sometimes business logic finds its way into services instead of staying in entities where it belongs. Or if it does stay in an entity, you now have to pass in a timeZoneId parameter to various methods where you might not otherwise be thinking about it. Sometimes that is acceptable, but sometimes not. It just depends on how much work it creates for you.

Lastly, I'll address this part:

We now have a Noda Time based application. The ZonedDateTime object makes it easier to do ad-hoc calculations and time zone driven queries.

Is this a correct assumption?

Yes and no. Before you go too deep on applying all of the above to your application, you might want to try a few operations in isolation with ZonedDateTime.

Primarily, ZonedDateTime ensures that time zone is being considered when converting to and from other types, and when doing math operations that involve instantaneous time (using Duration objects).

Where it doesn't really help is when working with calendar time. For example, if I want to "add one day" - I need to think about whether that means "add a duration of 24 hours", or "add a period of one calendar day". For most days that will be the same thing, but not on days containing DST transitions. There, they could be 23, 23.5, 24, 24.5, or 25 hours in duration, depending on the time zone. ZonedDateTime won't let you directly add a Period. Instead, you have to get the LocalDateTime, then add the period, then re-apply the time zone to get back to a ZonedDateTime.

So - think carefully about whether you need it the same way everywhere or not. If your application logic is strictly about calendar days, then you may find it best written exclusively in terms of LocalDate. You might have to work through the various properties and methods to actually use that logic, but at least the logic is modeled in its purest form.

Hope this helps, and hopefully this will be a useful post for other readers. Good luck, and feel free to call on me for assistance.

Frolic answered 16/10, 2015 at 19:33 Comment(4)
Now that is an answer. I appreciate this Matt and I'll spend the weekend experimenting with your suggestions!Bugloss
Off-topic: but felt it was only appropriate to let you know that your suggestions made the difference. Thanks!Bugloss
@Matt You said, "you could get fancy and write something to check for the latest .NZD file and auto update your system." Is a best practice to check for an update file daily, or perhaps even more frequently, since offsets etc. can change daily depending on local rules?Brutish
"Best practice" is an overloaded term... lol. Sure - you could do it programmatically. But at a minimum, I'd subscribe to the "Announcements" mailing list at iana.org/time-zones so you are aware when changes are released. Downstream releases like Noda Time's .nzd files follow shortly therafter.Frolic

© 2022 - 2024 — McMap. All rights reserved.