Skip to content
Precision Matters: Handling Money, Time Zones, and Ranges Correctly in C#

Precision Matters: Handling Money, Time Zones, and Ranges Correctly in C#

1 The Hidden Cost of Imprecision

Precision issues in financial and time-based systems rarely appear as obvious failures. They show up as small inconsistencies—pennies lost in calculations, off-by-one billing intervals, timestamps that shift unexpectedly when converted between time zones. At first, these problems seem harmless. But in real enterprise environments, where millions of operations happen daily, these “minor” inconsistencies accumulate into financial losses, audit discrepancies, and customer complaints.

The root cause is predictable: developers rely on primitive types like double and DateTime because they are easy to work with. They’re familiar, they compile, and they often appear to “work fine” during early testing. But these primitives do not model financial or temporal concepts accurately. When dealing with cents, tax percentages, billing cycles, or UTC instants, convenience becomes a source of long-term risk.

This section explains why imprecision is not just a theoretical math problem. It is a systemic issue that affects financial software, subscription services, logistics systems, and anything governed by regulatory or audit requirements. To fix it, we must move away from loosely defined primitives and toward semantic types that express meaning, intent, and domain rules directly.

1.1 The “Good Enough” Fallacy: Why Native Primitives Fail in Enterprise Systems

Many teams assume primitives are “good enough” because they work in small examples. The real problems emerge under production workloads. When amounts are multiplied thousands of times, or timestamps are converted across multiple time zones, the hidden assumptions inside double and DateTime start to surface.

The Problem with double

double uses binary floating-point representation. It cannot precisely represent most decimal fractions—including values as simple as 0.1 or 10.99. This leads to small but measurable errors:

// Incorrect: binary floating point introduces noise
double price = 10.99;
double total = price * 3; // 32.969999999999999 instead of 32.97

Even one incorrect multiplication can cause reconciliation mismatches. When chained across large datasets—percentage discounts, tax multipliers, currency conversions—these small errors become costly.

The Problem with DateTime

DateTime combines a date, a time, and an ambiguous “Kind” flag (Unspecified, Utc, or Local). Because it doesn’t encode intent clearly, developers often misinterpret or mis-handle values:

var dt = new DateTime(2024, 03, 01, 00, 00, 00);
// Local? UTC? Date-only? Unknown.

This ambiguity causes downstream issues in:

  • International billing
  • Cross-region scheduling
  • Subscription renewals
  • Distributed systems logging

The same primitive type ends up carrying unrelated meanings: birthdates, UTC instants, business hours, and calendar events. The lack of semantic clarity leads to silent bugs that are difficult to trace later.

1.2 Real-World Horror Stories

Precision errors are common enough that they show up repeatedly in real systems. Here are patterns that appear in audits and codebases across industries.

Fractional Cent Drift

A billing platform calculated taxes using double. At first, the rounding errors seemed harmless—fractions of a cent here and there. Over millions of invoices, these tiny differences added up. Payment gateways flagged mismatches between expected totals and actual charges. Correcting the issue required re-issuing statements and rewriting the billing logic using decimal-safe operations.

The DST One-Hour Duplicate Billing

A transportation system charged users by the hour. During the fall daylight saving transition, the 1:00–2:00 AM hour occurred twice. Their naive DateTime logic treated the second occurrence as additional usage. Customers were billed for two hours of activity when only one occurred, and the system lacked any mechanism to detect ambiguous timestamps.

Serialization Precision Loss

A trading dashboard stored monetary values in JavaScript, which uses IEEE 754 doubles. Large cent-level values like 123456789012 could not be represented precisely. When serialized to JSON and sent to the backend, the values no longer matched the exact amounts stored in the database. This led to multi-million-dollar discrepancies in reconciliation.

All three cases share the same pattern: the system trusted primitive types to behave precisely, and they didn’t.

1.3 The Mathematical Reality: Floating-Point vs. Decimal

Binary floating-point (double) is fast and efficient for scientific computation. But it was not designed for financial operations where decimal precision is critical.

A simple decimal like 0.1 becomes a repeating fraction in binary:

0.1 (decimal) = 0.0001100110011001100... (binary)

The representation must be rounded, introducing tiny errors that accumulate when:

  • Applying percentages
  • Summing large datasets
  • Performing multiplications or divisions

decimal avoids this problem by using a base-10 representation designed for financial arithmetic. But even decimal is only part of the story. Money also involves:

  • Currency codes
  • Scale rules (2 decimals? 0 decimals? 3 decimals?)
  • Rounding conventions
  • Valid and invalid arithmetic operations

A number alone cannot express all of these constraints. Precision requires both correct arithmetic and correct domain modeling.

1.4 The Goal: Moving from Data Types to Semantic Types Using DDD

Domain-Driven Design promotes modeling values according to their meaning, not their storage format. A monetary amount is not just a number—it is an amount in a specific currency, governed by rules. A timestamp is not just a date and time—it represents a moment with an intended interpretation.

Semantic types express this meaning directly, making invalid operations impossible or obvious.

Examples include:

  • Money — amount + currency + scale rules
  • ExchangeRate — ratio + base currency + quote currency
  • DateOnly — a pure calendar date
  • TimeOnly — a daily time independent of time zone
  • DateTimeOffset or NodaTime Instant — an absolute moment in time
  • DateRange — start and end boundaries with clear invariants

Using these types closes the gap between intent and implementation. Instead of relying on developers to “remember” how values should be handled, the type system encodes the rules.

The rest of this article builds these semantic types step-by-step and shows how to use them to avoid subtle, expensive precision failures.


2 Financial Precision: Beyond the Decimal Type

Using decimal is the first step toward accurate financial calculations, but it is not enough on its own. Money has semantics that go beyond arithmetic: currency rules, rounding conventions, and domain invariants. A production-ready system needs a value object that captures all of these behaviors explicitly. This section continues the theme from earlier by replacing loosely defined primitives with types that communicate intent and prevent invalid operations.

2.1 The decimal Type Deep Dive

2.1.1 Understanding the 128-bit Structure and Precision Limits

decimal is designed for exact base-10 arithmetic. It consists of:

  • A 96-bit integer coefficient
  • A scale that defines how many decimal places are used
  • A sign bit

This structure allows precise representation of values like 0.01, 10.99, or 100.00 without the rounding noise you see in binary floating-point types.

Key properties:

  • 28–29 digits of precision
  • Up to 28 decimal places
  • Exact arithmetic for decimal fractions
decimal a = 10.1m; 
decimal b = 0.2m;
decimal c = a / b; // exact decimal arithmetic

For typical enterprise billing workloads, this precision is more than enough. When systems require nanosecond timestamps or sub-cent financial instruments—such as high-frequency trading—developers often switch to scaled integers (long representing micro-units). But for most business domains, decimal provides the right balance between safety and readability.

2.1.2 Performance Implications Compared to Floating-Point Types

decimal trades speed for correctness. Operations on decimal are slower than double because:

  • The CPU does not have native decimal instructions
  • Multiplication and division require heavier software routines
  • Conversions between decimal and other numeric types have overhead

For financial systems, this cost is negligible compared to the benefit of exact arithmetic. Unless your architecture needs microsecond-level execution, the clarity and correctness of decimal outweigh its performance cost.

2.2 Architecting the Money Value Object

A Money type should enforce semantics directly in the design. Its responsibilities include:

  • Immutability
  • Currency correctness
  • Safe arithmetic
  • Equality and comparison logic
  • JSON serialization support
  • Rounding according to domain rules

This mirrors the reasoning from Section 1: the domain should dictate the rules, not the primitives.

2.2.1 Implementing Money as a Readonly Struct or Record

In .NET, both readonly struct and record types work well for value objects. A record often leads to clearer domain code and easier serialization.

public readonly record struct Money(decimal Amount, string Currency)
{
    public Money
    {
        if (string.IsNullOrWhiteSpace(Currency))
            throw new ArgumentException("Currency is required.");

        Currency = Currency.ToUpperInvariant(); // ISO 4217 consistency
    }
}

Immutability ensures that once a Money value is created, its amount and currency remain stable. This prevents accidental mutations during calculations, especially when values flow through multiple layers.

2.2.2 Operator Overloading With Currency Checks

Arithmetic involving money must enforce currency alignment. Adding or subtracting values in different currencies is meaningless unless explicitly converted.

Correct approach:

public static Money operator +(Money a, Money b)
{
    if (a.Currency != b.Currency)
        throw new InvalidOperationException($"Cannot add {a.Currency} to {b.Currency}.");

    return new Money(a.Amount + b.Amount, a.Currency);
}

Multiplying by scalars is allowed:

public static Money operator *(Money a, decimal factor)
    => new(a.Amount * factor, a.Currency);

Dividing by scalars follows the same rule:

public static Money operator /(Money a, decimal divisor)
    => new(a.Amount / divisor, a.Currency);

Preventing Money * Money or Money / Money is intentional—they have no meaningful definition in most domains.

2.2.3 Implementing IComparable and IEquatable

Sorting and comparisons require explicit currency checks:

public int CompareTo(Money other)
{
    if (Currency != other.Currency)
        throw new InvalidOperationException("Cannot compare different currencies.");

    return Amount.CompareTo(other.Amount);
}

Equality includes both the amount and the currency code. This ensures that 10 USD is not accidentally treated as the same as 10 EUR.

2.3 The Currency Conundrum

Currencies have rules—minor units, rounding expectations, and precision requirements. These rules vary across countries and markets.

2.3.1 Handling ISO 4217 Codes

A reliable money implementation stores the ISO 4217 currency code ("USD", "EUR", "JPY", etc.). A lookup table defines the number of allowed decimal places:

public static readonly IReadOnlyDictionary<string, int> CurrencyScales =
    new Dictionary<string, int>
    {
        ["USD"] = 2,
        ["EUR"] = 2,
        ["JPY"] = 0,
        ["KWD"] = 3
    };

This ensures that the value object enforces the correct scale automatically.

2.3.2 Handling Sub-Units and Decimal Places

To ensure amounts always match the currency’s rules, enforce normalization:

private static decimal Normalize(string currency, decimal amount)
{
    int scale = CurrencyScales[currency];
    return Math.Round(amount, scale, MidpointRounding.ToEven);
}

This prevents invalid values like 10.999 JPY or 5.1234 USD.

2.4 Advanced Arithmetic and Allocation

Many domains need more than simple arithmetic. Rounding rules and allocation algorithms play a significant role in billing, taxation, and revenue sharing.

2.4.1 Rounding Strategies

Different domains adopt different rounding strategies:

  • Banker’s rounding (ToEven) – preferred in financial institutions to reduce bias.
  • AwayFromZero – common in consumer billing where simple expectations matter.

Make the rounding strategy explicit:

public Money Round(int decimals, MidpointRounding mode)
    => new(Math.Round(Amount, decimals, mode), Currency);

This keeps rounding predictable and consistent across the system.

2.4.2 Allocation Algorithm (Remainder Distribution)

When splitting a monetary value across multiple recipients, the total must remain exact. Distributing $100.00 across 3 people illustrates the problem: 33.33 * 3 = 99.99, leaving one cent unassigned. A remainder distribution algorithm ensures nothing is lost.

public IEnumerable<Money> Allocate(int parts)
{
    int scale = CurrencyScales[Currency];
    decimal raw = Amount / parts;
    decimal truncated = Math.Floor(raw * (decimal)Math.Pow(10, scale)) /
                        (decimal)Math.Pow(10, scale);

    decimal remainder = Amount - (truncated * parts);

    for (int i = 0; i < parts; i++)
    {
        var allocation = truncated;

        if (remainder > 0)
        {
            allocation += 1 / (decimal)Math.Pow(10, scale);
            remainder -= 1 / (decimal)Math.Pow(10, scale);
        }

        yield return new Money(allocation, Currency);
    }
}

This ensures:

  • The allocations sum exactly to the original amount
  • No value is created or lost
  • The rounding bias is distributed fairly

This pattern—small adjustments applied early—reflects common banking and billing behavior.


3 Temporal Truths: Modern Date and Time in .NET

Time is even more complex than money. Calendars shift, daylight saving rules change, offsets vary by region, and legacy APIs blur the distinction between local time and absolute time. Modern .NET finally provides the tools to represent temporal concepts explicitly, but using them effectively requires a clear mental model. This section builds on the earlier principle: primitives are not enough. Time values must carry intent, or the system will misinterpret them in subtle, costly ways.

3.1 The Evolution of Time

3.1.1 DateTime vs. DateTimeOffset

DateTime tries to represent several different concepts under a single type:

  • A date
  • A time
  • An optional timezone interpretation via the Kind property

This creates ambiguity. The same type can represent a birthday (no time zone), a UTC instant, or a local timestamp tied to a user’s region. The runtime behavior changes depending on the Kind value, and developers often overlook this.

Examples:

DateTime.Now;      // Local time; offset depends on machine
DateTime.UtcNow;   // UTC time
new DateTime(...); // Kind.Unspecified -> meaning unclear

DateTimeOffset removes this ambiguity. It represents a precise instant on the global timeline by pairing the local time with its offset from UTC:

var dto = DateTimeOffset.Now; // exact instant + offset

This makes it the right default for:

  • Audit logs
  • Distributed event streams
  • API boundaries
  • Scheduling systems that require absolute consistency

DateTimeOffset expresses when something happened, independent of where it happened.

3.1.2 Why DateTime.Kind Causes Bugs

DateTime.Kind is a hidden hazard. When converting DateTime values, .NET uses the Kind to infer the offset. For Kind.Unspecified, the runtime must guess, which leads to unpredictable behavior:

var dt = new DateTime(2024, 03, 01, 0, 0, 0, DateTimeKind.Unspecified);
var converted = TimeZoneInfo.ConvertTimeToUtc(dt, TimeZoneInfo.Local);

If the runtime assumes the value is local when it was intended to be UTC—or vice versa—the result shifts by hours. These shifts ripple through billing cycles, scheduled tasks, and event processors:

  • Billing windows start or end at the wrong instant
  • Scheduled jobs fire early or late
  • Duplicate events appear around DST transitions

The rule is simple:

  • Use DateTimeKind.Unspecified only for date-only values where no timezone is relevant.
  • Use DateTimeOffset or NodaTime Instant for all instants in time.

This eliminates entire categories of timezone-related defects.

3.2 Semantic Types in .NET 6+

Modern .NET introduces two types that express time without the baggage of DateTime.

3.2.1 DateOnly

DateOnly models a calendar date with no time or zone. This is essential for values such as:

  • Birthdays
  • Holidays
  • Subscription effective dates
  • Business rules that only care about the date
DateOnly birthDate = new(1990, 5, 18);

If these values were stored using DateTime, time zone conversions could shift the date unexpectedly. DateOnly removes all ambiguity by design.

3.2.2 TimeOnly

TimeOnly models a time of day without a date—ideal for recurring or daily operations:

  • Store or office hours
  • Job schedules
  • Daily maintenance windows
TimeOnly opensAt = new(9, 0);

Combining DateOnly and TimeOnly produces a local DateTime, but the resulting value still lacks global meaning until a time zone is applied. This separation provides semantic clarity: first define the local rule, then apply the zone.

3.3 The New Standard for Testing

Time-dependent code is notoriously difficult to test. Hard-coded calls to the system clock (DateTime.Now, DateTimeOffset.UtcNow) lead to unstable tests, especially around DST transitions or month boundaries. .NET 8 introduces a practical solution.

3.3.1 TimeProvider (Introduced in .NET 8)

TimeProvider abstracts access to the system clock. Instead of calling the static DateTimeOffset.UtcNow, the application receives a clock abstraction:

var now = timeProvider.GetUtcNow();

This provides:

  • A consistent API for real time
  • A plug-in mechanism for test clocks
  • Safer time calculations across domains

It aligns with the same principle used for money: make the dependency explicit and controllable.

3.3.2 Deterministic Unit Testing

In unit tests, a FakeTimeProvider produces deterministic, reproducible time values:

var fake = new FakeTimeProvider(new DateTimeOffset(2024, 03, 01, 0, 0, 0, TimeSpan.Zero));

var service = new BillingService(fake);
var result = service.CalculateCharge();

With this approach:

  • Tests do not break at midnight
  • DST transitions no longer introduce surprises
  • Timeline-sensitive logic becomes predictable

Every test becomes isolated from the environment, and failures are easier to diagnose. For systems that calculate billing periods, renewal dates, expirations, or usage windows, this stability is essential.


4 Mastering Time Zones and Daylight Saving Time

Time zones add another layer of complexity on top of the temporal issues discussed earlier. Offsets change by region, governments adjust daylight saving rules with little notice, and historical zone data is constantly updated. Treating time zone conversion as “UTC ± offset” breaks down quickly in global systems. Following the same pattern established for money and local time values, this section focuses on using the right abstractions and consistent semantics to avoid subtle and expensive errors.

4.1 The Two Standards: Windows Time Zones vs. IANA (Olson) Time Zones

Different platforms use different naming conventions for time zones, which creates ambiguity when values cross system boundaries. Windows uses registry-backed identifiers such as "Pacific Standard Time". Most other environments—Linux, macOS, browsers, mobile devices, and cloud platforms—use IANA identifiers like "America/Los_Angeles".

Examples:

  • Windows: TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time")

  • IANA: "America/Los_Angeles"

Modern .NET (especially on Linux containers) supports IANA through ICU-based globalization, but older runtime configurations may still rely on Windows IDs. For cross-platform applications, storing IANA identifiers consistently avoids future migration pain. The application can convert between formats only when interacting with a platform that requires Windows IDs.

A minimal mapping example:

public static string ToIana(string windowsId)
{
    return windowsId switch
    {
        "Pacific Standard Time" => "America/Los_Angeles",
        "Eastern Standard Time" => "America/New_York",
        _ => throw new KeyNotFoundException($"Unknown Windows zone: {windowsId}")
    };
}

Choosing a single standard for storage mirrors the approach used for currencies in Section 2: simplify the model by removing ambiguity at the boundaries.

4.2 The DST Nightmare

Daylight saving transitions introduce two types of problematic timestamps that developers must handle with explicit logic. Hours may repeat in the fall or disappear in the spring. Systems that schedule jobs, calculate usage windows, or bill customers for time-based activity must treat these cases deliberately instead of assuming that every minute in the local time zone exists exactly once.

4.2.1 Handling Ambiguous Times and Invalid Times

When the clock moves backward, an ambiguous time occurs. For example, in "America/New_York", the hour between 1:00 and 2:00 AM happens twice during the fall transition. The same local timestamp maps to two distinct UTC instants:

var zone = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
var ambiguous = new DateTime(2024, 11, 3, 1, 30, 0, DateTimeKind.Unspecified);

bool isAmbiguous = zone.IsAmbiguousTime(ambiguous); // true

Systems must decide which interpretation applies:

  • Use the earlier offset
  • Use the later offset
  • Reject the input entirely
  • Require the caller to specify which mapping is correct

When the clock moves forward, an invalid time occurs. In the spring, the interval between 2:00 and 3:00 AM may not exist at all:

bool isInvalid = zone.IsInvalidTime(new DateTime(2024, 3, 10, 2, 15, 0));

Any logic that assumes this time existed will produce inconsistent or duplicated records. Rejecting invalid timestamps early keeps the system state predictable and avoids misaligned billing or scheduling.

4.2.2 Strategies for Mapping UTC to Local User Time Reliably

The same principle used earlier—model intent explicitly—applies here. Working internally in UTC eliminates most DST problems. Conversions happen only at the boundary where timestamps are shown to users.

A reliable pattern:

  1. Store absolute instants in UTC

    DateTimeOffset created = DateTimeOffset.UtcNow;
  2. Store the user’s IANA time zone

    string userZone = "Asia/Tokyo";
  3. Convert for display

    var zone = TimeZoneInfo.FindSystemTimeZoneById(userZone);
    var local = TimeZoneInfo.ConvertTime(created, zone);

When scheduling future events, store either:

  • A UTC instant (e.g., renewal timestamps), or
  • A semantic description: DateOnly + TimeOnly + zone

Avoid storing a plain DateTime. As with currency scale rules, the meaning must be explicit; DST rules change over time, so any ambiguity becomes a source of defects.

4.3 The Case for NodaTime

While base .NET APIs have improved, they were not designed for exhaustive handling of global time—just as decimal was not enough to represent a full Money type. NodaTime provides a more complete framework: clear types, IANA zones by default, and APIs that force developers to address ambiguous or invalid states directly.

4.3.1 Why NodaTime Is Superior to System.DateTime

NodaTime aligns with the same principles used earlier for value objects:

  • Types are immutable
  • Concepts are modeled explicitly
  • Ambiguity is not allowed to slip through silently

It provides:

  • A full IANA database
  • Strong separation between local and zoned values
  • Consistent handling of invalid or ambiguous mappings
  • A clear distinction between calendar operations and timeline operations

Example:

var zone = DateTimeZoneProviders.Tzdb["Europe/Berlin"];
Instant now = SystemClock.Instance.GetCurrentInstant();
ZonedDateTime berlin = now.InZone(zone);

The intent—viewing an instant in a specific zone—is explicit and free of hidden assumptions.

4.3.2 Key Concepts: Instant, ZonedDateTime, Period, Duration

NodaTime splits temporal modeling into semantically meaningful pieces:

  • Instant → a true point on the timeline
  • ZonedDateTime → an instant interpreted in a particular zone
  • LocalDateTime → a date and time without a zone
  • Period → calendar-based differences (months, years)
  • Duration → exact elapsed time

Example illustrating DST-aware mapping:

var start = new LocalDateTime(2024, 3, 10, 1, 30);
var zone = DateTimeZoneProviders.Tzdb["America/New_York"];
var result = zone.MapLocal(start); // may be Ambiguous, Unambiguous, or Skipped

Unlike DateTime, the API requires you to address any ambiguity before continuing.

4.3.3 Migration Path for .NET Core Applications

Migrating to NodaTime does not have to be all-or-nothing. Start with the most fragile areas, then expand outward.

A practical path:

  1. Add the NodaTime package.
  2. Introduce Instant, LocalDateTime, or ZonedDateTime in core domain objects.
  3. Use small extension methods for interop with existing APIs.
  4. Store instants in UTC and time zone IDs as IANA strings.
  5. Gradually convert JSON and database representations.

Example conversion helpers:

public static class NodaExtensions
{
    public static Instant ToInstant(this DateTimeOffset dto)
        => Instant.FromDateTimeOffset(dto);

    public static DateTimeOffset ToDateTimeOffset(this Instant instant)
        => instant.ToDateTimeOffset();
}

By following this incremental approach, teams can improve correctness without rewriting everything at once, similar to how the Money type replaced raw decimal values earlier in the article.


5 The Logic of Ranges: Handling Spans of Time

Nearly every domain involves periods of time—subscription windows, event schedules, pricing intervals, maintenance outages, or reporting periods. Yet most systems still represent these spans as two unrelated DateTime values. Without a shared convention for boundaries, edge cases emerge: double counting, unexpected overlaps, gaps that should not exist, or billing intervals that don’t align with business rules. As with money and instants in earlier sections, solving this requires a consistent model backed by semantic intent.

5.1 The “Off-by-One” Error

Time-range logic frequently fails due to inconsistent boundary semantics. The core question is simple: is the end included? But when different parts of the system answer this differently, subtle defects appear. APIs return unexpected results, billing cycles extend or shrink, and scheduling overlaps become difficult to reason about. A clear, unified convention eliminates entire categories of errors.

5.1.1 Inclusive vs. Exclusive Boundaries

An inclusive range [Start, End] includes both endpoints. It works intuitively in reports:

  • Start: October 1, 00:00
  • End: October 31, 23:59:59

But inclusive ranges break down in code because time is continuous—there is no natural “final second” for many operations. Inclusive logic also causes trouble when combining adjacent ranges; small rounding differences can introduce unexpected collisions.

The half-open pattern [Start, End) includes the start but excludes the end. This yields clean arithmetic and aligns with how modern APIs, databases, and scheduling libraries represent time. Contrast the semantics:

bool ContainsInclusive(DateTime value, DateTime start, DateTime end)
    => value >= start && value <= end;

bool ContainsExclusive(DateTime value, DateTime start, DateTime end)
    => value >= start && value < end;

The exclusive boundary avoids accidental overlap and creates predictable behavior when intervals line up back-to-back.

5.1.2 The Half-Open Interval Pattern [Start, End)

The half-open convention is the industry standard for systems that care about precision. Adjacent billing periods become unambiguous:

  • Billing cycle 1 → [2024-01-01, 2024-02-01)
  • Billing cycle 2 → [2024-02-01, 2024-03-01)

There is no overlap, no gap, and no need to manipulate “23:59:59.999” boundaries. Adding or subtracting days or months becomes a straightforward arithmetic operation. Teams that adopt half-open intervals consistently find that many off-by-one bugs disappear on their own.

5.2 Building a DateRange Value Object

Following the same approach used for Money and time-zone-aware instants, a dedicated value object makes the meaning of a range explicit. A DateRange should guarantee:

  • A defined start
  • An exclusive end
  • A valid interval where the end is not earlier than the start

Encoding these rules in the type prevents misinterpretations throughout the system.

5.2.1 Immutable Structure Design

An immutable record ensures that once a range is created, its boundaries cannot be changed accidentally. Using DateTimeOffset expresses that the boundaries are absolute moments, which aligns with the earlier guidance around precision and time zone clarity.

public readonly record struct DateRange(DateTimeOffset Start, DateTimeOffset End)
{
    public bool IsEmpty => Start == End;
}

If the domain operates on local calendar concepts (e.g., business days), the range can be defined using DateOnly combined with a stored time zone, mirroring the approach in Section 4.

5.2.2 Validation Logic (Start must be <= End)

Validation up front keeps invalid state out of the domain. This is the same principle used for enforcing currency correctness in the Money type.

public readonly record struct DateRange(DateTimeOffset Start, DateTimeOffset End)
{
    public DateRange
    {
        if (End < Start)
            throw new ArgumentException("Range end must not be earlier than start.");
    }
}

If the range is empty (Start == End), the value object still represents a valid semantic concept—just an interval with zero length—which is often useful for reporting or filtering.

5.3 Range Operations (Allen’s Interval Algebra)

Allen’s interval algebra is a well-established framework for reasoning about time spans. Even a small subset of its operations can eliminate many ad-hoc checks. Implementing these in the value object gives the domain model a consistent vocabulary for interval relationships.

5.3.1 Contains: Is a Specific Time Inside the Range?

Containment fits naturally with the half-open model:

public bool Contains(DateTimeOffset instant)
    => instant >= Start && instant < End;

This ensures that the instant at the exact end of one interval cleanly belongs to the next, supporting predictable billing and scheduling boundaries.

5.3.2 Overlaps: Do Two Meetings Conflict?

Two ranges overlap if they share any portion of time. This formula handles every scenario:

public bool Overlaps(DateRange other)
    => Start < other.End && End > other.Start;

It correctly covers:

  • Nested intervals
  • Partial overlaps
  • Ranges provided in the wrong order
  • Adjacent ranges (which correctly do not overlap)

This is especially helpful for scheduling meetings, allocating shared resources, or enforcing subscription exclusivity.

5.3.3 Abuts/Gap: Detecting Continuity Breaks

Two ranges abut when one ends exactly where the other begins. This matters for billing continuity, service-level calculations, and usage tracking.

public bool Abuts(DateRange other)
    => End == other.Start || other.End == Start;

Finding a gap between intervals becomes straightforward:

public static DateRange? Gap(DateRange a, DateRange b)
{
    if (a.Overlaps(b) || a.Abuts(b)) return null;

    var start = a.End < b.Start ? a.End : b.End;
    var end   = a.End < b.Start ? b.Start : a.Start;

    return new DateRange(start, end);
}

This allows a telemetry or billing system to spot missing data or breaks in continuity without trying to manage complicated timestamp comparisons across the codebase.

5.3.4 Intersection & Union: Billable Time Across Overlapping Sessions

Intersection identifies the actual time two ranges share. This is crucial in billing scenarios where multiple sessions occur simultaneously and only the overlapping portion is chargeable.

public DateRange? Intersect(DateRange other)
{
    var maxStart = Start > other.Start ? Start : other.Start;
    var minEnd   = End < other.End ? End : other.End;

    return minEnd > maxStart ? new DateRange(maxStart, minEnd) : null;
}

Union merges overlapping or abutting ranges into a single continuous interval:

public DateRange? Union(DateRange other)
{
    if (!Overlaps(other) && !Abuts(other))
        return null;

    var start = Start < other.Start ? Start : other.Start;
    var end   = End > other.End ? End : other.End;

    return new DateRange(start, end);
}

These operations support:

  • Consolidating user sessions for billing
  • Merging adjacent subscription periods
  • Calculating effective usage windows
  • Simplifying temporal queries

The DateRange value object becomes a precise, reusable abstraction that aligns with the domain rules—just like Money and time-zone-aware instants earlier in the article.


6 Persistence and Serialization Challenges

A domain model only delivers value if its accuracy survives the trip to the database, over the network, and into client applications. Precision in money and time can be lost silently when the schema uses the wrong SQL type, when JSON deserialization infers incorrect formats, or when a JavaScript client truncates values that don’t fit into its numeric limits. The same rule that guided earlier sections applies here: make meaning explicit and do not rely on defaults. The goal is to preserve semantic intent at every boundary so that Money, DateRange, and time-zone-aware timestamps behave consistently from storage to transport.

6.1 Database Mapping (EF Core 8/9)

EF Core has reached a point where value objects map cleanly to relational schemas without ceremony. With Complex Types introduced in EF Core 8, modeling semantic types like Money or DateRange no longer requires awkward ownership constructs or nested configurations. The real challenge is choosing SQL column types that align with the domain rules—especially around precision and time zone context.

6.1.1 Mapping Value Objects (Money) Using Complex Types vs. OwnsOne

Before EF Core 8, developers used OwnsOne to map value objects. It worked, but the configuration often felt mechanical:

builder.OwnsOne(p => p.Price, money =>
{
    money.Property(x => x.Amount)
         .HasColumnName("PriceAmount")
         .HasPrecision(18, 2);

    money.Property(x => x.Currency)
         .HasColumnName("PriceCurrency")
         .HasMaxLength(3);
});

Complex Types remove the “owned entity” semantics and treat the value object as part of the aggregate—precisely what DDD intends:

public class Product
{
    public int Id { get; init; }
    public Money Price { get; init; }
}

The mapping is concise and aligned with the domain:

builder.ComplexProperty(p => p.Price, money =>
{
    money.Property(x => x.Amount).HasPrecision(18, 2);
    money.Property(x => x.Currency).HasMaxLength(3);
});

This pattern mirrors the earlier Money implementation: immutable, inline, and free of accidental identity. OwnsOne still has a role when the object has navigations or lifecycle semantics, but for pure value objects like Money, ExchangeRate, or DateRange, Complex Types are the natural choice.

6.1.2 Storing Time: datetime2 vs. datetimeoffset in SQL Server

SQL Server provides multiple datetime types, and choosing the wrong one undermines temporal accuracy. The same principles from Sections 3 and 4 carry forward: preserve intent.

  • datetime2 Stores only date and time. No offset. Loses meaning for absolute instants.

  • datetimeoffset Stores date, time, and the offset. Preserves the original instant and its context.

When storing global instants—DateTimeOffset, NodaTime’s Instant, or any timestamp used for logging or billing—datetimeoffset(7) is the correct choice:

builder.Property(x => x.Start)
    .HasColumnType("datetimeoffset(7)");

builder.Property(x => x.End)
    .HasColumnType("datetimeoffset(7)");

This ensures:

  • UTC timestamps remain stable
  • Time zone transitions are preserved
  • Historical data remains interpretable even as DST rules evolve

Use datetime2 only for:

  • DateOnly values
  • Local business hours (TimeOnly)
  • Temporal fields that must not carry zone information

Storing the correct SQL type maintains the same semantic guardrails enforced in the domain layer.

6.1.3 Storing IANA Time Zone IDs in the Database

Storing offsets is insufficient because offsets change seasonally. Instead, store the canonical IANA zone identifiers used throughout Sections 3 and 4:

builder.Property(u => u.TimeZoneId)
    .HasMaxLength(64)
    .IsRequired();

Typical examples in the database:

  • "Europe/Berlin"
  • "Asia/Kolkata"
  • "America/Los_Angeles"

Because these identifiers are stable and cross-platform, they support Linux containers, browser clients, and NodaTime conversion without ambiguity. The application can always reconstruct the user’s correct local time from an IANA ID and a UTC instant.

6.2 Serialization Wars (System.Text.Json)

Serialization is a common source of precision loss, especially when money or time crosses the boundary from .NET to JavaScript. Browser clients do not support decimal; they support IEEE 754 numbers. Likewise, timestamps without explicit zone information often default to the client’s locale.

The solution is to define explicit converters that encode meaning the same way the domain model does.

6.2.1 Writing Custom JsonConverter Factories for Money and DateRange

System.Text.Json treats unknown types generically unless instructed otherwise. A semantic type like Money should appear as a structured JSON object, not a loose decimal or a serialized tuple.

A safe converter:

public class MoneyJsonConverter : JsonConverter<Money>
{
    public override Money Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        using var doc = JsonDocument.ParseValue(ref reader);
        var root = doc.RootElement;

        var amount = root.GetProperty("amount").GetDecimal();
        var currency = root.GetProperty("currency").GetString()!;

        return new Money(amount, currency);
    }

    public override void Write(Utf8JsonWriter writer, Money value, JsonSerializerOptions options)
    {
        writer.WriteStartObject();
        writer.WriteNumber("amount", value.Amount);
        writer.WriteString("currency", value.Currency);
        writer.WriteEndObject();
    }
}

DateRange follows the same pattern as its domain semantics:

public class DateRangeJsonConverter : JsonConverter<DateRange>
{
    public override DateRange Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var obj = JsonDocument.ParseValue(ref reader).RootElement;

        var start = obj.GetProperty("start").GetDateTimeOffset();
        var end   = obj.GetProperty("end").GetDateTimeOffset();

        return new DateRange(start, end);
    }

    public override void Write(Utf8JsonWriter writer, DateRange value, JsonSerializerOptions options)
    {
        writer.WriteStartObject();
        writer.WriteString("start", value.Start.ToString("O"));
        writer.WriteString("end", value.End.ToString("O"));
        writer.WriteEndObject();
    }
}

The "O" round-trip format preserves exact offset and sub-second precision.

6.2.2 The ISO 8601 Standard for APIs

API boundaries should use ISO 8601 timestamps with explicit zone information:

2024-01-18T14:30:00Z
2024-01-18T09:30:00-05:00

This eliminates guesswork:

  • No locale-specific month/day confusion
  • No implicit time zone assumptions
  • No hidden conversion rules

For DateOnly, stick to:

2024-03-01

Adding a midnight time (T00:00:00Z) mistakenly implies an instant, which is semantically incorrect.

6.2.3 Handling Precision Loss in JavaScript Clients (53-bit Integer Limit)

JavaScript cannot safely represent integers larger than 2^53 - 1. This affects:

  • Ticks
  • Millisecond-precision epoch values
  • Large monetary values
  • Identifiers stored as integers

To prevent precision loss:

  • Serialize large integers as strings
  • Prefer ISO 8601 timestamps rather than raw epoch numbers
  • Use BigInt only when the environment explicitly supports it

A safe JSON payload:

{
  "timestamp": "2024-05-10T12:00:00Z",
  "amount": "123456789012345.67"
}

This ensures clients maintain full precision and aligns with the earlier focus on semantic correctness.


7 Architecting for Robustness: A Reference Implementation

Precision in money, time zones, and ranges becomes meaningful only when these concepts are applied together in a real system. A subscription billing engine is a good example because it contains every type of temporal and monetary challenge discussed earlier—recurring billing windows, prorations, time zone conversions, and strict financial accuracy. The goal of this section is not to build a full product but to demonstrate how the semantic types developed so far integrate into a coherent, reliable architecture.

7.1 Scenario: Creating a Subscription Billing Engine

A subscription engine typically manages:

  • Users with time zone preferences
  • Defined subscription periods, represented as half-open [Start, End) ranges
  • Monthly prices expressed using the Money value object
  • Pro-rated charges when a subscription starts or ends mid-cycle

Each of these features depends on precise temporal semantics. If a single timestamp is stored without an offset, or if a range boundary is misinterpreted, the billing engine will drift—overcharging in some cases, undercharging in others, and producing inconsistencies during DST shifts. The reference implementation below demonstrates how to avoid these pitfalls by keeping meaning explicit in the domain model.

7.2 Domain Model Code Walkthrough

7.2.1 Defining the Subscription Entity

A subscription ties together a user, a price, and a period of activity. The entity stays deliberately small so the value objects retain their importance.

public class Subscription
{
    public Guid Id { get; private set; }
    public Guid UserId { get; private set; }
    public Money MonthlyPrice { get; private set; }
    public DateRange ActivePeriod { get; private set; }

    public Subscription(Guid userId, Money price, DateRange range)
    {
        UserId = userId;
        MonthlyPrice = price;
        ActivePeriod = range;
    }
}

The ActivePeriod is a DateRange using the half-open [Start, End) pattern described in Section 5. This creates a stable base for all billing calculations and prevents off-by-one errors when periods join end-to-start.

7.2.2 Calculating Pro-Rated Charges (Money × Time)

Proration is a common challenge because it blends monetary and temporal semantics. The engine must determine what portion of the billing window overlaps with the subscription’s active period, then compute the fractional charge without losing precision.

The correct formula is:

charge = MonthlyPrice × (activeDays / daysInBillingPeriod)

Using decimal operations and the earlier Intersect method avoids rounding drift and ensures charges sum precisely across invoices.

public Money CalculateProratedCharge(DateRange billingPeriod)
{
    var overlap = ActivePeriod.Intersect(billingPeriod);
    if (overlap is null) 
        return new Money(0, MonthlyPrice.Currency);

    var daysInPeriod = (decimal)(billingPeriod.End - billingPeriod.Start).TotalDays;
    var activeDays   = (decimal)(overlap.Value.End - overlap.Value.Start).TotalDays;

    var factor = activeDays / daysInPeriod;
    return MonthlyPrice * factor;
}

This handles:

  • Mid-month cancellations
  • Mid-month activations
  • Shortened cycles (e.g., February)
  • DST impacts when boundaries are stored as DateTimeOffset

The method remains small because the semantic types already express the complexity.

7.3 Validating Invariants: Preventing Overlapping Subscriptions

A user should never have two active subscriptions of the same type at the same time. Overlaps are detected using the DateRange.Overlaps logic introduced earlier. The invariant belongs to the domain layer, not the database, because the rule expresses business meaning, not relational structure.

public static bool HasOverlap(IEnumerable<Subscription> subs, DateRange candidate)
    => subs.Any(s => s.ActivePeriod.Overlaps(candidate));

This validation can be enforced:

  • When creating new subscriptions
  • When modifying existing ones
  • Before writing to the database

Clear invariants reduce the number of checks scattered across the application and ensure invalid state never persists.

7.4 Testing Strategy

Billing systems are extremely sensitive to boundary conditions. Small mistakes around leap years, short months, or DST transitions often appear only under load or after deployment. A robust test strategy combines deterministic clock control with property-based testing.

7.4.1 Property-Based Testing With FsCheck

Property-based testing generates many randomized inputs, uncovering corner cases that traditional unit tests rarely cover. It is especially effective for range and financial logic because both involve boundary-sensitive calculations.

Example property ensuring symmetric overlap detection:

Property OverlapIsSymmetric =>
    Prop.ForAll<DateRange, DateRange>((a, b) =>
        a.Overlaps(b) == b.Overlaps(a));

For monetary allocation, the key invariant is that allocations must sum to the original total:

Property AllocationSumsToTotal =>
    Prop.ForAll<Money, int>((m, parts) =>
    {
        if (parts <= 0) return true;

        var allocations = m.Allocate(parts).ToList();
        var total = allocations.Aggregate(
            new Money(0, m.Currency),
            (acc, next) => acc + next
        );

        return total.Equals(m);
    });

These properties test hundreds of combinations automatically, revealing precision edge cases that only appear after many iterations.

7.4.2 Simulating Leap Years and DST Using FakeTimeProvider

Time-dependent behavior must not depend on the machine’s current clock. The FakeTimeProvider introduced in .NET 8 allows the domain layer to evaluate time-based logic consistently across environments.

Simulating a leap-year date:

var fake = new FakeTimeProvider(new DateTimeOffset(2024, 2, 29, 12, 0, 0, TimeSpan.Zero));
var service = new BillingService(fake);
var result = service.RunEndOfMonthProcessing();

Simulating a DST transition:

var change = new DateTimeOffset(2024, 11, 3, 1, 30, 0, TimeSpan.Zero);
fake.SetUtcNow(change);

With a controlled clock:

  • Billing cutoffs land consistently
  • End-of-month logic behaves identically in development and production
  • Overlapping and missing hours during DST shifts do not corrupt billing windows

The combination of deterministic time and semantic types ensures the billing engine behaves predictably even in the most difficult calendar scenarios.


8 Conclusion and Future-Proofing

Precision is not a luxury in systems that deal with money, time zones, or temporal ranges. Financial calculations, time conversions, and interval logic all break down when the domain model relies on ambiguous primitives or implicit behavior. Throughout the article, the focus has been on expressing meaning directly in the type system—Money, DateRange, DateTimeOffset, DateOnly, TimeOnly, and NodaTime’s richer abstractions. These types anchor the rules of the domain so that correctness is enforced consistently across computation, storage, and transport.

8.1 Checklist for Code Reviews

A stable set of review questions helps teams apply these principles consistently. The goal is not to enforce stylistic preferences but to prevent subtle logic errors that become costly once systems scale.

  • Are all monetary amounts represented with the Money type rather than raw decimal values?
  • Are all timestamps stored and processed as DateTimeOffset or NodaTime Instant?
  • Are all time spans represented using the half-open [Start, End) range model?
  • Are user time zones stored as IANA identifiers rather than offsets?
  • Are ambiguous or invalid local times (DST transitions) handled explicitly?
  • Are SQL columns using the correct types (e.g., datetimeoffset(7) for instants, decimal(precision, scale) for amounts)?
  • Are custom JSON converters ensuring round-trip safety for semantic types?
  • Are domain invariants (e.g., no overlapping subscription ranges) enforced in the domain layer instead of scattered across the application?

A short checklist like this surfaces errors early and reinforces the semantic modeling approach emphasized throughout the article.

8.2 Performance Considerations: When Abstraction Cost Is Too High

The abstractions introduced here are suitable for almost all enterprise systems. However, a small category of domains—such as market-making engines or high-frequency trading platforms—operate under microsecond-level constraints. In these environments:

  • Monetary values are represented as scaled integers (long), not decimals
  • Timestamps are often stored as raw nanosecond counts
  • Memory allocations must be carefully controlled
  • Conversions such as UTC ↔ local are often avoided entirely

These systems optimize for throughput at the cost of expressiveness. They rely on strict discipline and heavily specialized infrastructure. For nearly every other .NET system—billing, logistics, banking, subscriptions, scheduling—the semantic approach described earlier is the correct tradeoff. It protects correctness without adding measurable overhead.

Several mature libraries embody the same design principles discussed in this article and can serve as starting points or extensions to your domain model:

  • NodaTime — the most reliable library for global time, offsets, DST rules, and IANA zone data
  • NodaMoney or NMoneys — robust implementations of monetary value types with currency metadata
  • FluentValidation — declarative validation that keeps invariants visible and enforceable

Using well-tested libraries reduces boilerplate and helps avoid reinventing complex temporal or financial logic.

8.4 Final Thoughts: Correctness > Convenience

Convenience often encourages developers to use double, DateTime, or bare decimal values because they are readily available and easy to instantiate. But as shown throughout the article, these shortcuts introduce ambiguity—ambiguous time zones, ambiguous rounding, ambiguous interval boundaries. Ambiguity is the enemy of precision, and precision is the foundation of trustworthy systems.

Semantic types do not add accidental complexity—they remove it. They transform invisible assumptions into explicit rules and eliminate entire classes of defects by construction. Correctness becomes part of the architecture rather than relying on discipline or manual reviews.

Building precise systems is not about writing more code. It is about making meaning impossible to misinterpret. When dealing with money, time zones, and ranges, the long-term cost of imprecision far exceeds the cost of modeling the domain accurately from the start.

Advertisement