Article series
- Solving Primitive Obsession in .NET
- Introducing Complex Value Objects in .NET
- Integration with Frameworks and Libraries
- Enhancing Business Semantics
- Advanced Value Object Patterns in .NET ⬅
Estimated reading time : 12 min.
Introduction
In the previous article, we explored the strategic importance of value objects within Domain-Driven Design, examining how they serve as fundamental building blocks that encapsulate domain-specific behavior, enforce business invariants, and help cultivate a ubiquitous language that aligns code with business terminology.
While those concepts establish the foundational role of value objects in creating expressive domain models, some complex business requirements demand more sophisticated modeling techniques. This article delves into advanced patterns where value objects, often combined with other features from Thinktecture.Runtime.Extensions, help tackle intricate real-world scenarios. We will look at specific case studies demonstrating how to represent concepts like open-ended dates, composite identifiers, recurring dates, and hierarchical structures.
Open-ended Date
Representing dates or time periods without a fixed end date is a common requirement. Consider employment contracts, subscriptions, or validity periods that might continue indefinitely. How can we model this effectively?
Using a nullable DateOnly?
might seem intuitive, where null
represents infinity. However, this often complicates both database queries and LINQ queries on in-memory collections. Checking for active items requires conditions like.Where(x => x.EndDate == null || x.EndDate >= today)
in LINQ or WHERE EndDate IS NULL OR EndDate >= @today
in SQL, which can hinder index usage and performance, especially with large datasets.
Another approach is using a sentinel value like DateOnly.MaxValue
. This simplifies queries (WHERE EndDate >= @today
) but introduces semantic ambiguity. Does MaxValue
truly mean “forever”, or is it just a very distant date? Furthermore, the default value of DateOnly
is MinValue
, which is usually an invalid end date, leading to potential bugs if not handled carefully.
We need a solution that offers clear semantics, works well with default values, and supports efficient querying. Let’s create an OpenEndDate
value object using a readonly struct
.
[ValueObject(
SkipKeyMember = true, // We implement the key member "Date" ourselves
KeyMemberName = nameof(Date), // Source Generator needs to know the name we've chosen
DefaultInstancePropertyName = "Infinite", // provides static member "OpenEndDate.Infinite"
AllowDefaultStructs = true, // Allows default(OpenEndData) which equals to Infinite
SkipToString = true)] // We want custom implementation of ToString()
public partial struct OpenEndDate
{
private readonly DateOnly? _date;
// Use MaxValue internally to represent infinity for comparison
private DateOnly Date
{
get => _date ?? DateOnly.MaxValue;
init => _date = value;
}
// Optional: additional convenience factory method
public static OpenEndDate Create(int year, int month, int day)
{
return Create(new DateOnly(year, month, day));
}
// MinValue is usually an invalid value for an end-date
static partial void ValidateFactoryArguments(
ref ValidationError? validationError,
ref DateOnly date)
{
if (date == DateOnly.MinValue)
validationError = new ValidationError("The end date cannot be DateOnly.MinValue.");
}
// Custom ToString() for clarity; implementation of IFormattable is recommended as well
public override string ToString() =>
this == Infinite : "Infinite" : Date.ToString("O", CultureInfo.InvariantCulture);
}
OpenEndDate
behaves similarly to regular DateOnly
for most operations. Since the key member is DateOnly
(which implements IComparable
), all comparison operators (==, !=, <, <=, >, >=) work naturally, making it seamless to use in date comparisons, sorting, and range queries.
// Creating instances
var contractEnd = OpenEndDate.Create(2025, 12, 31);
var subscriptionEnd = OpenEndDate.Infinite;
var defaultEndDate = default(OpenEndDate); // Represents Infinite
// Comparisons
bool isOngoing = subscriptionEnd >= OpenEndDate.Create(DateTime.Today); // true
bool specificVsInfinite = contractEnd < subscriptionEnd; // true
bool defaultEqualsInfinite = defaultEndDate == OpenEndDate.Infinite; // true
// LINQ Query (EF Core)
var activeContracts = await dbContext.Contracts
.Where(c => c.EndDate >= contractEnd) // Simple comparison
.ToListAsync();
📝 For more details on configuring Entity Framework Core to work with value objects, including setup and advanced scenarios, see Value Objects in .NET: Integration with Frameworks and Libraries.
The OpenEndDate
struct provides clear semantics (Infinite
), handles default values correctly, and allows for straightforward database queries.
Composite File Identifier
Imagine needing to reference files stored across different systems: a local file server, cloud blob storage, a document management system, etc. A simple file path or ID isn’t sufficient; we need to know where the file is stored and its unique identifier within that store.
A composite identifier is needed. We can model this using a complex value object, FileUrn
, that combines the storage system identifier (FileStore
) and the file’s unique resource name (Urn
) within that store. For easy storage and transmission (e.g., in configuration files or API calls), we also want to serialize this composite identifier to and from a single string format like "fileStore:urn"
.
// Assumption: URNs are case-insensitive
[ComplexValueObject(DefaultStringComparison = StringComparison.OrdinalIgnoreCase)]
// Enables serialization to/from a string "fileStore:urn".
[ObjectFactory(UseForSerialization = SerializationFrameworks.All)]
public partial class FileUrn
{
public string FileStore { get; }
public string Urn { get; }
// Validation and normalization of the file store and URN
static partial void ValidateFactoryArguments(
ref ValidationError? validationError,
ref string fileStore,
ref string urn)
{
if (string.IsNullOrWhiteSpace(fileStore))
{
validationError = new ValidationError("FileStore cannot be empty");
return;
}
// Disallow colon because it is the separator used in serialization.
// Alternatively, create a value object for FileStore and move the validation there,
// or create a Smart Enum, if we have a fixed set of stores
if (fileStore.Contains(':'))
{
validationError = new ValidationError("FileStore must not contain ':'");
return;
}
if (string.IsNullOrWhiteSpace(urn))
{
validationError = new ValidationError("Urn cannot be empty");
return;
}
// Normalize values
fileStore = fileStore.Trim();
urn = urn.Trim();
}
// Parsing/deserialization from string (required by [ObjectFactory])
public static ValidationError? Validate(
string? value,
IFormatProvider? provider,
out FileUrn? item)
{
if (string.IsNullOrWhiteSpace(value))
{
item = null;
return new ValidationError("FileUrn cannot empty");
}
var separatorIndex = value.IndexOf(':');
// Basic format check: separator must exist and not be at the start or the end
if (separatorIndex <= 0 || separatorIndex == value.Length - 1)
{
return new ValidationError("Invalid FileUrn format. Expected 'fileStore:urn'");
}
var fileStore = value[..separatorIndex];
var urn = value[(separatorIndex + 1)..];
// Delegate to the primary validation logic
return Validate(fileStore, urn, out item);
}
// Serialization (required by UseForSerialization = SerializationFrameworks.All)
public string ToValue()
{
return $"{FileStore}:{Urn}";
}
}
The following examples demonstrate how to work with FileUrn
in practice:
// Creating instances
var docUrn = FileUrn.Create("AzureBlobStorage", "contract.pdf");
var imageUrn = FileUrn.Create("FileSystem", "/images/logo.png");
// Parsing from string
var parsedUrn = FileUrn.Parse("AzureBlobStorage:contract.pdf", null); // IFormatProvider is null
// Attempting to parse invalid strings
bool success1 = FileUrn.TryParse("MissingSeparator", null, out _); // false
bool success2 = FileUrn.TryParse("StoreOnly:", null, out _); // false
bool success3 = FileUrn.TryParse(":UrnOnly", null, out _); // false
// Serialization (see one of the previous articles about integration for more info)
string json = JsonSerializer.Serialize(docUrn);
// Deserialization
FileUrn? deserializedUrn = JsonSerializer.Deserialize(json);
The FileUrn
provides a type-safe way to handle composite file identifiers, enforces validation, and integrates seamlessly with serialization frameworks thanks to the ObjectFactory<string>
attribute.
Recurring Dates
How do you represent a date that occurs every year, like a birthday or an anniversary, where the specific year isn’t relevant for the concept itself? Storing the full DateOnly
might work, but it carries unnecessary year information.
We can create a DayMonth
value object that captures just the day and month. Using DateOnly
as the underlying key type helps leverage its built-in date validation (like handling leap days) and comparison logic. We’ll use a fixed reference year (like 2000, a leap year) internally for these purposes.
[ValueObject(
// Make the conversion from DateOnly implict: "DayMonth dm = dateOnly"
ConversionFromKeyMemberType = ConversionOperatorsGeneration.Implicit,
// Disable conversion to DateOnly because we don't know what year to use
ConversionToKeyMemberType = ConversionOperatorsGeneration.None,
SkipToString = true)]
public readonly struct DayMonth
{
// Use a leap year for the internal representation to handle Feb 29th correctly
private const int _REFERENCE_YEAR = 2000;
// Public properties to access the day and month
public int Day => _value.Day;
public int Month => _value.Month;
// Factory method for creating from day and month.
// Recommended: implement TryCreate for exception-free creation
public static DayMonth Create(int month, int day)
{
DateOnly date;
try
{
date = new DateOnly(_REFERENCE_YEAR, month, day);
}
catch (ArgumentOutOfRangeException ex) // Catches invalid month/day combinations
{
throw new ValidationException($"Invalid day '{day}' or month '{month}'.", ex);
}
// Delegate to primary factory method
return Create(date);
}
// Normalization: adjusts the year-part to the reference year for correct comparisons
static partial void ValidateFactoryArguments(
ref ValidationError? validationError, ref DateOnly value)
{
if (value.Year != _REFERENCE_YEAR)
value = new DateOnly(_REFERENCE_YEAR, value.Month, value.Day);
}
// Custom implementation of ToString() to show day and month only
public override string ToString()
{
return _value.ToString("MM-dd", CultureInfo.InvariantCulture); // "05-15" for May 15th
}
}
The following examples demonstrate working with DayMonth
for recurring date scenarios:
// Creating instances
var birthday = DayMonth.Create(5, 15); // May 15th
var anniversary = DayMonth.Create(10, 27); // October 27th
var leapDay = DayMonth.Create(2, 29); // February 29th (valid due to reference leap year)
// Validation
try
{
var invalidDate = DayMonth.Create(4, 31); // April 31st doesn't exist
}
catch (ValidationException ex)
{
}
// Comparison (uses the underlying DateOnly for comparison)
var anotherBirthday = DayMonth.Create(5, 15);
bool isEqual = birthday == anotherBirthday; // true
bool isLater = anniversary > birthday; // true
// Implicit conversion from DateOnly
DayMonth todayDayMonth = DateOnly.FromDateTime(DateTime.Today);
The DayMonth
struct provides a type-safe representation for recurring dates, handling validation and comparison correctly while hiding the irrelevant year component.
Jurisdiction (Combining Value Objects with Unions)
Sometimes, a concept can take one of several distinct forms. Consider a geographical jurisdiction: it could be a country (identified by an ISO code), a federal state (identified by a number), or a district (identified by name). We also might need an “Unknown” or “Unspecified” state.
This is a perfect scenario for combining value objects with Discriminated Unions. Each type of jurisdiction can be modeled as a specific value object, inheriting from a common abstract Jurisdiction
base class marked with the [Union]
attribute. This demonstrates how advanced value object patterns can leverage discriminated unions to create sophisticated domain models.
For a complete implementation of this example, including the full Jurisdiction
discriminated union with Country
, FederalState
, District
, and Unknown
cases, along with detailed usage examples and pattern matching, see the article Discriminated Unions in .NET: Modeling States and Variants.
[Union]
public abstract partial class Jurisdiction
{
// Case 1: Country ISO code
[ValueObject(KeyMemberName = "_isoCode")]
public partial class Country : Jurisdiction { /* ... */ }
// Case 2: Federal State (assuming states are represented by a number internally)
[ValueObject]
public partial class FederalState : Jurisdiction;
// Case 3: District
[ValueObject(KeyMemberName = "_name")]
public partial class District : Jurisdiction { /* ... */ }
// Case 4: Unknown Jurisdiction
[ComplexValueObject] // (ab)using value object to get equality comparison based on type only
public partial class Unknown : Jurisdiction;
}
This pattern showcases the synergy between value objects and discriminated unions:
- Type-Specific Invariants: Each jurisdiction case enforces its own validation rules
- Modeling Related Concepts: Groups distinct but related concepts under a single abstraction
- Special Cases: Models the “Unknown” state explicitly and safely
- Pattern Matching: Enables type-safe operations across all jurisdiction variants
The combination provides a powerful and type-safe way to model concepts that have several distinct representations, each with its own specific data and validation rules, while still treating them under a common umbrella type.
Summary
Value objects are not limited to simple wrappers around primitive types. By leveraging features like custom key members, struct defaults, specialized factory methods (ObjectFactoryAttribute<T>
), and combining them with other patterns like discriminated unions, we can model complex domain concepts with precision and type safety.
The examples of OpenEndDate
, FileUrn
, DayMonth
, and Jurisdiction
demonstrate how Thinktecture.Runtime.Extensions facilitates the implementation of these advanced patterns, reducing boilerplate code and ensuring consistency. These techniques allow developers to create richer, more expressive domain models that accurately reflect business rules and requirements.