Click here to Skip to main content
15,867,991 members
Articles / Programming Languages / C#

A TimeZone Aware DateTime Implementation

Rate me:
Please Sign up or sign in to vote.
3.56/5 (10 votes)
24 Feb 2010CPOL6 min read 69.8K   466   37   23
An implementation that wraps DateTime to allow for keeping track of TimeZone state

Introduction

This article focuses on how I've created a time zone aware DateTime wrapper. I do not claim to be an expert on the field, but I have spent some weeks of research on the topic, and I think I understand some of the complexities we need to address. The biggest problem we have today when working with .NET DateTime instances are that they, by default, represent local time, and they do not carry any information about which time zone they were originally created in. Working with DateTime instances created in another time zone than your own seems very difficult and awkward. A usual workaround has been to convert values to UTC, together with some time zone information, an offset, and if daylight saving time is in use, before serializing the information (to disk, database, or over the network). These are some of the issues this library tries to address.

Converting DateTime instances to UTC is, of course, supported by this library. This is currently the standard way of persisting date and time data to the database. When retrieving these DateTime instances, one would typically send the UTC date and time to some factory, together with all the meta data (such as offset, time zone definition, and a flag indicating if daylight saving is in use), and be able to produce a correct LocalDateTime instance with time local to the requested time zone. Then, instances of LocalDateTime will be exposed internally in the rest of the application to ease date and time manipulations. This is currently what we are doing where I work, and we have the challenge that some Use Cases and reports are working with different DateTime instances representing different time zones concurrently in the same report (view), and it is very important for our customers that this logic works correctly.

Background

If you start Googling around on the topic of DateTime implementation, you will come across these URLs:

Basically, all three BCL blog entries try to explain why DateTime became what it is, and that they do understand that a lot of API consumers are not very satisfied. They also delve into how they are trying to amend the situation with .NET 3.5's new date time structure called DateTimeOffset. Reading the blog comments is very interesting as most of the comments indicate the users are not satisfied with the current approach. Interestingly, the new DateTimeOffset does not carry time zone information, only a TimeSpan to represent the offset. Thus, this will not solve the problem of representing local time transparently and retrieving it as such somewhere else in the world. Also, when converting from one time zone to another, the DST rules are needed. Without including the time zone information, the receiver working with a serialized DateTimeOffset must guess about this. Now, most developers work around this problem as described earlier. But then again, are they any better off with the new structures offered by .NET 3.5 than they were with DateTime?

Another thing, you can write some code to look up time zone services or the tz-database to keep your time zone definitions up to date. This is pretty much similar to how Java bundles their time zones internally in the JRE. Take a look at the implementation of the TimeZone class to see how this can be done.

Using the Code

Now, let's look at how the LocalDateTime implementation works. I have provided many constructors; some constructors derive the time zone meta data from the Operating System and identify the TimeZone instance to use. But, if you would like to create a DateTime instance rooted in another time zone, then you should use the constructor below:

C#
/// <summary>
/// Convenient constructor for creating a local date time instance in any time zone.
/// </summary>
/// <remarks>
/// Note that this constructor will not modify provided
/// date time value if provided time is within DST.
/// This should be corrected by the invoker,
/// or if the DateUtil.TimeZoneAdjustDateTime method is used,
/// this will be corrected automatically.
/// </remarks>
/// <param name="time">The time.</param>
/// <param name="zone">The time zone.</param>
public LocalDateTime(DateTime time, ITimeZone zone, bool isDST) : this()
{
    IsLocalTimeBased = false;
    ticks = time.Ticks;
    timeZoneId = zone.CanonicalID;
    this.isDST = isDST;
    NullInitSummerWinterDST();
}

For instance, using this constructor would look like this:

C#
LocalDateTime cetDateTime = 
  new LocalDateTime(new DateTime(2008, 12, 1), TimeZones.CET, false);

I used Reflector on System.DateTime to create an IDateTime interface including all the public instance methods. Here is just a small excerpt of the interface:

C#
/// <summary>
/// DateTime interface wrapping all methods
/// on DateTime so that you can interact with
/// an date time instance just as if it was a normal date time.
/// </summary>
public interface IDateTime
{
    #region Properties
    /// <summary>
    /// Gets the date component of this instance.
    /// </summary>
    IDateTime Date { get; }

    /// <summary>
    /// Gets the day of the month represented by this instance.
    /// </summary>
    int Day { get; }

    /// <summary>
    /// Gets the day of the week represented by this instance.
    /// </summary>
    DayOfWeek DayOfWeek { get; }

    /// <summary>
    /// Gets the day of the year represented by this instance.
    /// </summary>
    int DayOfYear { get; }

    /// <summary>
    /// Gets the hour component of the date represented by this instance.
    /// </summary>
    int Hour { get; }

    /// <summary>
    /// Gets a value that indicates whether the time represented
    /// by this instance is based on local time,
    /// Coordinated Universal Time (UTC), or neither.
    /// </summary>
    DateTimeKind Kind { get; }

    /// <summary>
    /// Gets the milliseconds component
    /// of the date represented by this instance.
    /// </summary>
    int Millisecond { get; }

    /// <summary>
    /// Gets the minute component of the date represented by this instance.
    /// </summary>
    int Minute { get; }

    /// <summary>
    /// Gets the month component of the date represented by this instance.
    /// </summary>
    int Month { get; }

    /// <summary>
    /// Gets a System.DateTime object that is set to the current date
    /// and time on this computer, expressed as the local time.
    /// </summary>
    IDateTime Now { get; }

This interface is, of course, implemented on the LocalDateTime so that functions that API programmers are used to are available on LocalDateTime. The type is also implemented as an immutable struct to behave as close to DateTime as possible. Thus, the instance created above could also look like:

C#
IDateTime cetDateTime = 
  new LocalDateTime(new DateTime(2008, 12, 1), TimeZones.CET, false);

When creating one hundred million date time instances compared to creating one hundred million LocalDateTime instances, the creation of LocalDateTime instances had an extra cost if constructors that need to derive time zone information were used. But, by using the constructor meant for MSSQL or a factory inside your application that produces LocalDateTimes, there was no noticeable time difference (the DateTime struct was 600 milliseconds faster on creation of one hundred million instances).

C#
/// <summary>
/// Internal constructor to be used
/// by SQL-server when creating an instance of the class
/// based on internally stored type value.
/// </summary>
/// <param name="ticks">The number of ticks that makes up a date</param>
/// <param name="timeZoneId">The CanonicalId as defined
///         by the Olson TZ-database for a given time zone.</param>
/// <param name="isDST">A stored value indicating
///       whether the ticks value is within a DST range.</param>
public LocalDateTime(long ticks, string timeZoneId, bool isDST) : this()
{
    this.ticks = ticks;
    this.timeZoneId = timeZoneId;
    this.isDST = isDST;
}

This constructor also reveals what columns you must create if you won't use LocalDateTime as a User Defined Type. By using a user defined type, these values will be stored along with the struct instance in a single column. To make this possible, I had to add some attributes to the struct and also implement the nullable interface:

C#
[Serializable]
[SqlUserDefinedType(Format.Native)]
public struct LocalDateTime : IDateTime, INullable
{
....

Below is a passing Unit Test demonstrating that the LocalDateTime implementation is time-zone aware:

C#
/// <summary>
/// First create a LocalDateTime in NY-time at 01:59:59AM,
/// one second before the timezone's summer time ends.
/// Due to different DST changes, converting to CET
/// will change the summer time to winter time 
/// for that exact point in time since CET ends
/// summer time before America/New York timezone does. 
/// While in CET time, add one second and convert
/// that LocalDateTime instance back into New York time. 
/// The time should now NOT be 2 AM, but actually 1 AM as the end of DST rule for 
/// America/New York timezone is to set the clock 1 hour back.
/// </summary>
[TestMethod]
public void TestAmerica_NewYorkEndDSTByConvertingToCETAndAddingOneSecondAndConvertingBack()
{
    // Get DST change day
    TimeZone tzNewYork = TimeZones.America__New_York;
    DaylightSavingTime winter = tzNewYork.WinterChange;

    int dayInMonth = DateUtil.FindDayInMonth(2009, winter.Month, 
                     winter.GetDayOfWeek(), winter.GetDayOccurrenceInMonth());
    DateTime winterDateTime = new DateTime(2009, winter.Month, dayInMonth);

    // Set time to 01:59 AM
    TimeSpan oneSecondBefore2AM = new TimeSpan(1, 59, 59);
    DateTime dateTime = winterDateTime.Add(oneSecondBefore2AM);

    // Create a LocalDateTime to represent this time with
    // TimeZone information specified, one second before DST ends
    LocalDateTime ldt = new LocalDateTime(dateTime, tzNewYork, true);

    // Assert that the dateTime instance is equal to localDateTime instance
    Assert.AreEqual(ldt.GetDateTime(), dateTime);

    // Assert that the localDateTime instance's DST flag is true
    Assert.IsTrue(ldt.IsDaylightSavingTime());

    LocalDateTime cet = LocalDateTime.ConvertTo(ldt, TimeZones.CET);

    // Add one second to both the dateTime instance and the cet instance
    dateTime = dateTime.AddSeconds(1);
    IDateTime localDateTime = cet.AddSeconds(1);

    //Transform the manipulated CET back to New York time:
    ldt = LocalDateTime.ConvertTo((LocalDateTime)localDateTime, ldt.TimeZone);

    // Assert that these are not equal as the dateTime instance
    // should be 02:00 AM while the localDateTime instance should be 01:00 AM
    Assert.AreNotEqual(ldt.GetDateTime(), dateTime);

    // Subtract one hour to the dateTime instance so that
    // it will be 01:00 AM and assert that these instances now are equal.
    Assert.AreEqual(ldt.GetDateTime(), dateTime.AddHours(-1));

    // Assert that localDateTime instance's DST flag now is set to false
    Assert.IsFalse(ldt.IsDaylightSavingTime());
}

Since I like to use NHibernate as my object relational mapper, I have created a CompositeUserType to make it possible to save this using the usual NHibernate mappings. This works for both UDT and the multicolumn approach.

This is the first version of this small library. It may contain bugs, and there are probably a lot of things to improve. If you have any feedback, please contact me so that I can improve this little library.

Policy Changes

Another approach worth looking into (which we currently have abandoned) is the possibility of registering the LocalDateTime struct as a 'User Defined Type' (UDT) in your favorite database. This would at least speed up data retrieval, since you would not have to convert UTC time to the offset time value for the time zone it should be expressed in. By utilizing user defined types, the information you usually use three or four columns to store can now be represented in a single column of type LocalDateTime.

After some discussion, we decided against utilizing a UDT in MSSQL. In the end, it may prove an extra burden upgrading customers if we need to re-register a UDT. Also, sorting and ordering based on date and time will not work as long as the LocalDateTime data is represented in local time to the defined time zone. At least, if UDT is used, the LocalDateTime should convert the date time to UTC and store it as such for consistent SQL manipulations in the DBMS. Although we consider these issues as such drawbacks that we ended up using the "three column approach", this doesn't mean that UDT might not be the best fit in other applications.

History

Added Unit Test project and more tests demonstrating how LocalDateTime works.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer
Norway Norway
http://www.linkedin.com/in/steinardragsnes

Comments and Discussions

 
GeneralPerformance on Add/Subtract Pin
Julian Skagen17-Feb-10 8:58
Julian Skagen17-Feb-10 8:58 
GeneralRe: Performance on Add/Subtract Pin
steinard23-Feb-10 8:41
steinard23-Feb-10 8:41 
GeneralRe: Performance on Add/Subtract Pin
steinard24-Feb-10 0:22
steinard24-Feb-10 0:22 
GeneralMy vote of 1 Pin
Gilad Kapelushnik8-Dec-08 4:48
Gilad Kapelushnik8-Dec-08 4:48 
GeneralRe: My vote of 1 Pin
steinard8-Dec-08 9:08
steinard8-Dec-08 9:08 
GeneralISO 8601 Pin
PIEBALDconsult8-Dec-08 2:01
mvePIEBALDconsult8-Dec-08 2:01 
GeneralAgreed with the below posters.... [modified] Pin
Axel Rietschin8-Dec-08 0:32
professionalAxel Rietschin8-Dec-08 0:32 
GeneralRe: Agreed with the below posters.... [modified] Pin
steinard8-Dec-08 8:44
steinard8-Dec-08 8:44 
GeneralUTC for all storage is still the best way to go ... PinPopular
HightechRider7-Dec-08 17:28
HightechRider7-Dec-08 17:28 
GeneralRe: UTC for all storage is still the best way to go ... Pin
Soundman32.27-Dec-08 22:34
Soundman32.27-Dec-08 22:34 
GeneralRe: UTC for all storage is still the best way to go ... [modified] Pin
steinard8-Dec-08 9:06
steinard8-Dec-08 9:06 
NewsRe: UTC for all storage is still the best way to go ... Pin
steinard16-Apr-09 13:45
steinard16-Apr-09 13:45 
Hi!

Thanks for the great feedback you provided a few months ago. Now the LocalDateTime API is shipped along with our product to around 50 customers all over Europe. During sprints this year we have found small bugs and more unit tests have been written. We also went for the classic three column storage in the DB, all times stored in UTC of course.

I would greatly appreciate if you (or anyone else) looked at the unit tests, seeing how the code works when working with DateTime instances representing other time zones then your own (as this was one of the major reasons for creating this library). And if any bugs are found, please report them to me so that I can fix them and prove them fixed with a unit test.

Thanks Wink | ;)
Steinar.

QuestionRe: UTC for all storage is still the best way to go ... Pin
Giovanni Bejarasco11-Jun-09 11:42
Giovanni Bejarasco11-Jun-09 11:42 
AnswerRe: UTC for all storage is still the best way to go ... Pin
steinard4-Aug-09 1:08
steinard4-Aug-09 1:08 
GeneralRe: UTC for all storage is still the best way to go ... Pin
Giovanni Bejarasco5-Aug-09 4:14
Giovanni Bejarasco5-Aug-09 4:14 
GeneralRe: UTC for all storage is still the best way to go ... Pin
steinard5-Aug-09 21:36
steinard5-Aug-09 21:36 
GeneralRe: UTC for all storage is still the best way to go ... Pin
supercat925-Feb-10 4:54
supercat925-Feb-10 4:54 
GeneralRe: UTC for all storage is still the best way to go ... Pin
HightechRider25-Feb-10 5:24
HightechRider25-Feb-10 5:24 
GeneralRe: UTC for all storage is still the best way to go ... Pin
supercat925-Feb-10 6:11
supercat925-Feb-10 6:11 
GeneralRe: UTC for all storage is still the best way to go ... Pin
HightechRider25-Feb-10 6:33
HightechRider25-Feb-10 6:33 
GeneralRe: UTC for all storage is still the best way to go ... Pin
supercat926-Feb-10 4:49
supercat926-Feb-10 4:49 
GeneralRe: UTC for all storage is still the best way to go ... Pin
steinard27-Feb-10 12:35
steinard27-Feb-10 12:35 
GeneralRe: UTC for all storage is still the best way to go ... Pin
User 5924125-Feb-10 16:01
User 5924125-Feb-10 16:01 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.