Skip to content

Handling Complexity Introducing Complex Value Objects in dotnet

Pawel Gerr edited this page Jul 5, 2025 · 1 revision

Handling Complexity: Introducing Complex Value Objects in .NET

Article series

  1. Value Objects: Solving Primitive Obsession in .NET
  2. Handling Complexity: Introducing Complex Value Objects in .NET ⬅

Estimated reading time: 12 min.

Introduction

In the previous article, we saw how value objects wrapping single primitive types help us create safer, more expressive code by addressing primitive obsession. We looked at examples like Amount or ReferenceCode, where a single value carries specific meaning and validation rules.

However, some concepts in our domains are naturally defined by multiple pieces of information that belong together. Think about a date range – it needs both a start date and an end date. Or consider a monetary value, which requires both an amount and a currency. Using separate primitives for these related values often leads back to familiar problems: how do we ensure consistency between them? How do we validate them as a single unit?

This is where Complex Value Objects come into play. They allow us to group related properties into a single, cohesive object that represents one concept, ensuring its internal consistency and encapsulating relevant behavior. In the examples that follow, we'll use the library Thinktecture.Runtime.Extensions to implement these concepts with minimal boilerplate code.

Note on terminology: The term "complex value object" used throughout this article isn't a separate concept from the value objects discussed in the previous article. It's simply a convenient way to differentiate between value objects wrapping a single primitive type (using [ValueObject<T>]) and those composed of multiple properties (using [ComplexValueObject]). Both are value objects in the domain-driven design sense, but the library provides different feature sets for each pattern.

Providing solutions via a .NET library

The library Thinktecture.Runtime.Extensions was created to address a fundamental challenge in modern C# development: providing robust, type-safe domain modeling without excessive boilerplate code.

With the introduction of Roslyn Source Generators in .NET 5 (2020), new possibilities opened up that were previously unattainable. Before this technology, providing a flexible tool for implementation of value objects was virtually impossible without significant runtime costs or cumbersome boilerplate. I began implementing these features using source generators shortly after their release, allowing compile-time code generation without runtime overhead. One of our oldest projects has been using the value objects successfully since late 2021, demonstrating its reliability and effectiveness in production environments.

Roslyn Source Generators are a feature of the .NET Compiler Platform (Roslyn) that allow the developers to generate code at compile time. The source generators can read existing C# code and other files, analyze them, and then produce new C# code that is added to the compilation.

⚠️️ Value Objects (along with Smart Enums and Discriminated Unions) are powerful tools in our development arsenal, but they should be applied judiciously. As with any tool, the decision of where and when to use value objects remains firmly in the hands of developers. Not every class or struct needs to become a value object – the best candidates are those that represent meaningful domain concepts with validation rules or specialized behavior.

The Challenge of Related Primitives

Let's consider representing a time period using basic DateTime values:

// ⚠️ EventName is a good candidate for a value object similar to "ReferenceCode"
// in previous article. We ignore it in this article to focus on time period only.
public class EventBooking
{
    public string EventName { get; set; }
    public DateTime StartDate { get; set; }
    public DateTime EndDate { get; set; }

    public bool IsValid()
    {
        // Validation is scattered and easily forgotten
        return EndDate >= StartDate;
    }

    // Behavior is external to the data (i.e. time period)
    public TimeSpan CalculateDuration()
    {
        if (EndDate < StartDate)
            throw new InvalidOperationException("End date cannot be before start date.");

        return EndDate - StartDate;
    }
}

This approach has several drawbacks:

  • Inconsistent State: Nothing prevents setting EndDate to be earlier than StartDate. The object can exist in an invalid state.
  • Scattered Validation: The rule EndDate >= StartDate needs to be checked wherever an EventBooking is created or modified, or within methods like IsValid(), which might be forgotten.
  • External Behavior: Calculating the duration requires external logic that must also handle potential inconsistencies.
  • Lack of Clarity: The relationship between StartDate and EndDate isn't explicitly captured by the types themselves.

Working with EventBooking requires manual checks:

var booking = new EventBooking
{
    EventName = "Team Workshop",
    StartDate = new DateTime(2025, 5, 15),
    EndDate = new DateTime(2025, 5, 10) // Inconsistent dates!
};

// Caller might forget to validate or handle errors correctly
if (booking.IsValid())
{
   // Process booking...
}

Complex Value Objects as a Solution

A complex value object treats multiple properties as a single conceptual unit. It ensures that the object is always valid and consistent according to its defined rules.

⚠️️ It's crucial to understand that complex value objects, despite containing multiple properties, remain fundamentally different from entities. While entities are defined by their unique identity (like an ID) and can change their properties over time while remaining the same entity, complex value objects—like their simpler counterparts—are fully defined by their property values. Two complex value objects with identical property values are considered equal, regardless of their object reference equality.

Basic Implementation

We can define a TimePeriod value object by making the class partial and marking it with ComplexValueObjectAttribute from Thinktecture.Runtime.Extensions:

[ComplexValueObject]
public partial class TimePeriod
{
    public DateTime Start { get; }
    public DateTime End { get; }

    // Validation is centralized during creation
    static partial void ValidateFactoryArguments(
        ref ValidationError? validationError,
        ref DateTime start,
        ref DateTime end)
    {
        if (end < start)
            validationError = new ValidationError("End date cannot be before start date.");
    }

    // Domain-specific behavior is encapsulated
    public TimeSpan Duration => End - Start;
    public bool Contains(DateTime date) => date >= Start && date <= End;
    public bool Overlaps(TimePeriod other) => Start < other.End && other.Start < End;
}

The library generates all necessary code based on the [ComplexValueObject] attribute, including factory methods (Create, TryCreate) and equality members (Equals, GetHashCode, ==, !=). These equality members compare all included properties (Start and End in this case) by default, ensuring value-based equality for the entire object. Additional boilerplate code is also handled automatically, streamlining the implementation of complex value objects.

The API design using a ref ValidationError? parameter instead of returning an error may seem odd, but this approach allows the C# compiler to eliminate the method and all its calls if the method is not implemented.

Now, let's use the TimePeriod in our EventBooking:

public class EventBooking
{
    public string EventName { get; set; }
    public TimePeriod Period { get; set; } // Use the complex value object
}

There's no need for methods IsValid() or CalculateDuration() because the TimePeriod is always valid if created, and it already encapsulates the duration as a property.

// Creation enforces validation
var period = TimePeriod.Create(new DateTime(2024, 5, 10), new DateTime(2024, 5, 15));

// This would throw a ValidationException during creation
var period = TimePeriod.Create(new DateTime(2024, 5, 15), new DateTime(2024, 5, 10));

// Exception-free creation
if (!TimePeriod.TryCreate(
        new DateTime(2024, 5, 15),
        new DateTime(2024, 5, 10),
        out var period,
        out var error))
{
    // handle error
}

// Accessing encapsulated behavior
TimeSpan duration = period.Duration;

var booking = new EventBooking
{
    EventName = "Team Workshop",
    Period = period
};

Benefits of the complex value object approach:

  • Guaranteed Consistency: A TimePeriod object can only be created if End >= Start, i.e. it cannot exist in an invalid state.
  • Centralized Validation: The validation logic is defined once within the value object itself.
  • Encapsulated Behavior: Methods related to the time period (Duration, Contains, Overlaps) are part of the object.
  • Improved Readability: Using TimePeriod makes the EventBooking class and its usage clearer.

Implementation as a Struct

Complex value objects are not limited to classes; they can also be implemented as readonly structs. As stated in previous article, the choice impacts the object's semantics, especially concerning default values and nullability.

// 📝 AllowDefaultStructs "allows" default(Boundary)
[ComplexValueObject(AllowDefaultStructs = true)]
public partial struct Boundary
{
    public decimal Lower { get; }
    public decimal Upper { get; }

    static partial void ValidateFactoryArguments(
        ref ValidationError? validationError,
        ref decimal lower,
        ref decimal upper)
    {
        if (lower > upper)
        {
            validationError = new ValidationError(
              $"Lower boundary '{lower}' must be less than or equal to upper boundary '{upper}'");
        }

        // Potential normalization like rounding could go here
        // lower = Math.Round(lower, 2);
        // upper = Math.Round(upper, 2);
    }
}

// Usage:
var boundary = Boundary.Create(1m, 2.5m);
var defaultBoundary = default(Boundary); // Represents (0, 0)

Besides AllowDefaultStructs, the implementation and the feature sets of structs and classes are identical. Without "allowing" the default values, the analyzer, which is shipped along with the source generator, will emit a compiler error.

Complex Value Objects must be initialized

Advanced Usage: Composing Value Objects

Just as complex concepts are often built from simpler ones, complex value objects can be composed using other value objects (complex or not).

Consider a postal address. It typically consists of a street, city, postal code, and country. Instead of using strings for all of these, we could compose the address from simpler value objects.

In following example, we've implemented CountryCode as a Smart Enum rather than a value object. Smart Enums provide a type-safe way to represent a fixed set of related constants with associated data and behavior. This approach works particularly well for well-known set of codes.

[ValueObject<string>]
public partial class Street { /* validation */ }

[ValueObject<string>]
public partial class City { /* validation */ }

[ValueObject<string>]
public partial class PostalCode
{
    public int Length => _value.Length; // For later use in "Address"

    /* validation */
}

// Using a Smart Enum for country code adds type safety and associated data
[SmartEnum<string>]
public partial class CountryCode
{
    public static readonly CountryCode DE = new("DE", 5);
    public static readonly CountryCode CH = new("CH", 4);

    public int PostalCodeLength { get; } // For later use in "Address"
}

The street, city and postal code are designed as value objects, the country code as a Smart Enum.

The country code can be designed as a value objects as well. The corresponding data could be loaded from the configuration or a database instead of being hardcoded in the Smart Enum definition.

[ComplexValueObject]
public partial class Address
{
    public Street Street { get; }
    public City City { get; }
    public PostalCode PostalCode { get; }
    public CountryCode Country { get; }

    // Validation gets access to all components for validation and normalization
    static partial void ValidateFactoryArguments(
        ref ValidationError? validationError,
        ref Street street,
        ref City city,
        ref PostalCode postalCode,
        ref CountryCode country)
    {
        // Use the country-specific rules from the Smart Enum instance
        if (postalCode.Length != country.PostalCodeLength)
        {
            validationError = new ValidationError(
                $"Postal code length for country {country} must be {country.PostalCodeLength}.");
        }

        // Other cross-component validation rules could go here...
    }
}

This compositional approach offers several advantages:

  • Leverages Existing Validation: Each component (Street, City, etc.) already enforces its own rules defined in its value object definition.
  • Adds Cross-Component Rules: The Address value object adds validation that depends on multiple components.
  • Builds a Domain Vocabulary: Types like Street, City, PostalCode, CountryCode, and Address become part of your ubiquitous language, making the code easier to understand.

Identifying Complex Value Object Candidates

Look for these patterns in your code as potential opportunities for complex value objects:

  • Groups of primitives always passed together
  • Validation rules spanning multiple related properties
  • Behavior that naturally operates on multiple related properties

Summary

Complex value objects extend the benefits of "simple" value objects to concepts defined by multiple related properties. They help ensure data consistency by validating related properties together during creation and promote encapsulation by grouping data and relevant behavior into a single unit. Furthermore, they enhance code clarity by making domain concepts explicit in the type system and reduce boilerplate through library Thinktecture.Runtime.Extensions which handle common implementation tasks.

Now that we've explored both simple and complex value objects, our next article will address the practical integration of value objects with common frameworks and libraries. We'll show how to handle JSON serialization and how to work with ASP.NET Core model binding and Entity Framework Core.

Clone this wiki locally