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 rulesExchangeRate— ratio + base currency + quote currencyDateOnly— a pure calendar dateTimeOnly— a daily time independent of time zoneDateTimeOffsetor NodaTimeInstant— an absolute moment in timeDateRange— 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
decimaland 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
Kindproperty
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.Unspecifiedonly for date-only values where no timezone is relevant. - Use
DateTimeOffsetor NodaTimeInstantfor 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:
-
Store absolute instants in UTC
DateTimeOffset created = DateTimeOffset.UtcNow; -
Store the user’s IANA time zone
string userZone = "Asia/Tokyo"; -
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 timelineZonedDateTime→ an instant interpreted in a particular zoneLocalDateTime→ a date and time without a zonePeriod→ 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:
- Add the
NodaTimepackage. - Introduce
Instant,LocalDateTime, orZonedDateTimein core domain objects. - Use small extension methods for interop with existing APIs.
- Store instants in UTC and time zone IDs as IANA strings.
- 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.
-
datetime2Stores only date and time. No offset. Loses meaning for absolute instants. -
datetimeoffsetStores 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:
DateOnlyvalues- 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
BigIntonly 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
Moneyvalue 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
Moneytype rather than rawdecimalvalues? - Are all timestamps stored and processed as
DateTimeOffsetor NodaTimeInstant? - 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.
8.3 Recommended Libraries
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.