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?
ApplicationUser.TimezoneId
, right? Or could there be cases where one user is looking at another user's data? – FrolicZonedDateTime
– Bugloss