Advanced Value Object Patterns in .NET

While basic value objects solve primitive obsession, complex domain requirements need sophisticated modeling techniques. This article explores advanced patterns using Thinktecture.Runtime.Extensions to tackle real-world scenarios: open-ended dates for employment contracts, composite file identifiers across storage systems, recurring anniversaries without year components, and geographical jurisdictions using discriminated unions.

In diesem Artikel:

pg
Pawel Gerr ist Architekt und Consultant bei Thinktecture. Er hat sich auf .NET Core Backends spezialisiert und kennt Entity Framework von vorne bis hinten.

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<DateOnly>(
   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<string>(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<string>])
   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<FileUrn>(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<DateOnly>(
   // 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<string>(KeyMemberName = "_isoCode")]
   public partial class Country : Jurisdiction { /* ... */ }

   // Case 2: Federal State (assuming states are represented by a number internally)
   [ValueObject<int>]
   public partial class FederalState : Jurisdiction;

   // Case 3: District
   [ValueObject<string>(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 OpenEndDateFileUrnDayMonth, 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.

Kostenloser
Newsletter

Aktuelle Artikel, Screencasts, Webinare und Interviews unserer Experten für Sie

Verpassen Sie keine Inhalte zu Angular, .NET Core, Blazor, Azure und Kubernetes und melden Sie sich zu unserem kostenlosen monatlichen Dev-Newsletter an.

Newsletter Anmeldung
Diese Artikel könnten Sie interessieren
.NET
pg

Discriminated Unions in .NET: Modeling States and Variants

Domain models often involve concepts that exist in multiple distinct states or variations. Traditional approaches using enums and nullable properties can lead to invalid states and scattered logic. This article explores how discriminated unions provide a structured, type-safe way to model domain variants in .NET, aligning perfectly with Domain-Driven Design principles while enforcing invariants at the type level.
06.10.2025
.NET
pg

Smart Enums in .NET: Integration with Frameworks and Libraries

Learn how to seamlessly integrate Smart Enums with essential .NET frameworks and libraries. This article covers practical solutions for JSON serialization, ASP.NET Core model binding for both Minimal APIs and MVC controllers, and Entity Framework Core persistence using value converters. Discover how Thinktecture.Runtime.Extensions provides dedicated packages to eliminate integration friction and maintain type safety across your application stack.
21.09.2025
.NET
pg

Value Objects in .NET: Enhancing Business Semantics

Value objects are fundamental building blocks in Domain-Driven Design, serving far more than simple data wrappers. This article explores their strategic importance in bridging technical code and business concepts, enforcing domain rules, and fostering clearer communication with domain experts. Learn how to build robust aggregates, cultivate ubiquitous language, and encapsulate domain-specific behavior using Thinktecture.Runtime.Extensions in .NET applications.
16.09.2025
.NET
pg

Pattern Matching with Discriminated Unions in .NET

Traditional C# pattern matching with switch statements and if/else chains is error-prone and doesn't guarantee exhaustive handling of all cases. When you add new types or states, it's easy to miss updating conditional logic, leading to runtime bugs. The library Thinktecture.Runtime.Extensions solves this with built-in Switch and Map methods for discriminated unions that enforce compile-time exhaustiveness checking.
26.08.2025
.NET
pg

Value Objects in .NET: Integration with Frameworks and Libraries

Value Objects in .NET provide a structured way to improve consistency and maintainability in domain modeling. This article examines their integration with popular frameworks and libraries, highlighting best practices for seamless implementation. From working with Entity Framework to leveraging their advantages in ASP.NET, we explore how Value Objects can be effectively incorporated into various architectures. By understanding their role in framework integration, developers can optimize data handling and enhance code clarity without unnecessary complexity.
12.08.2025
.NET
pg

Smart Enums: Adding Domain Logic to Enumerations in .NET

This article builds upon the introduction of Smart Enums by exploring their powerful capability to encapsulate behavior, a significant limitation of traditional C# enums. We delve into how Thinktecture.Runtime.Extensions enables embedding domain-specific logic directly within Smart Enum definitions. This co-location of data and behavior promotes more cohesive, object-oriented, and maintainable code, moving beyond scattered switch statements and extension methods. Discover techniques to make your enumerations truly "smart" by integrating behavior directly where it belongs.
29.07.2025