-
Notifications
You must be signed in to change notification settings - Fork 1
Handling Complexity Introducing Complex Value Objects in dotnet
Article series
- Value Objects: Solving Primitive Obsession in .NET
- Handling Complexity: Introducing Complex Value Objects in .NET ⬅
Estimated reading time: 12 min.
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.
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.
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.
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 thanStartDate
. The object can exist in an invalid state. -
Scattered Validation: The rule
EndDate >= StartDate
needs to be checked wherever anEventBooking
is created or modified, or within methods likeIsValid()
, 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
andEndDate
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...
}
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.
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 ifEnd >= 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 theEventBooking
class and its usage clearer.
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.
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
, andAddress
become part of your ubiquitous language, making the code easier to understand.
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
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.