Skip to content

Value Objects

Pawel Gerr edited this page Mar 9, 2025 · 43 revisions

This library provides an easy way for implementation of simple and complex Value Objects. The library comes with some Roslyn Analyzers and Code Fixes to guide the software developers through the implementation. Furthermore, additional Nuget packages add support for System.Text.Json, Newtonsoft.Json, MessagePack, Entity Framework Core and ASP.NET Core Model Binding.

Requirements

  • C# 11 (or higher) for generated code
  • SDK 8.0.400 (or higher) for building projects

Getting started

Required Nuget package: Thinktecture.Runtime.Extensions

Why Value Objects?

Value objects help solve several common problems in software development:

  1. Type Safety: Prevent mixing up different concepts that share the same primitive type

    // Problem: Easy to accidentally swap parameters
    void ProcessOrder(int customerId, int orderId) { ... }
    ProcessOrder(orderId, customerId); // Compiles but wrong!
    
    // Solution: Value objects make it type-safe
    [ValueObject<int>]
    public partial struct CustomerId { }
    
    [ValueObject<int>]
    public partial struct OrderId { }
    
    void ProcessOrder(CustomerId customerId, OrderId orderId) { ... }
    ProcessOrder(orderId, customerId); // Won't compile!
  2. Validation: Ensure data consistency through built-in validation

    [ValueObject<string>]
    public partial class EmailAddress
    {
        static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref string value)
        {
            if (string.IsNullOrWhiteSpace(value))
            {
                validationError = new ValidationError("Email cannot be empty");
                return;
            }
    
            if (!value.Contains("@"))
            {
                validationError = new ValidationError("Invalid email format");
                return;
            }
            
            value = value.Trim().ToLowerInvariant();
        }
    }
    
    // Usage - validation is automatic
    var email = EmailAddress.Create("user@example.com");  // Success
    var invalid = EmailAddress.Create("not-an-email");    // Throws ValidationException
  3. Immutability: Prevent accidental modifications and ensure thread safety

    [ValueObject<decimal>]
    public partial struct Amount
    {
        static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref decimal value)
        {
            if (value < 0)
            {
                validationError = new ValidationError("Amount cannot be negative");
                return;
            }
        }
    }
    
    // Once created, cannot be modified
    var amount = Amount.Create(100.50m);

The value objects described here are divided in 2 categories:

  • Simple or keyed value objects are types with 1 field/property (aka key member), which share a lot of features with Smart Enums
  • Complex value objects are types with 2 or more fields/properties

Simple value objects

A simple value object has 1 field/property only, i.e., it is kind of wrapper for another (primitive) type. The main use case is to prevent creation of values/instances which are considered invalid according to some rules.
In DDD (domain-driven design), working with primitive types, like string, directly is called primitive obsession and should be avoided.

A value object can be an immutable class or a readonly struct flagged with ValueObjectAttribute<T>. The source generator will automatically implement all necessary methods and functionality.

The property or field of type T of a simple value object will be called the key member from now on.

Here are examples of simple value objects with different validation rules:

// Percentage that must be between 0 and 100
[ValueObject<decimal>(AllowDefaultStructs = true)]
public partial struct Percentage
{
    static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref decimal value)
    {
        if (value < 0 || value > 100)
        {
            validationError = new ValidationError("Percentage must be between 0 and 100");
            return;
        }

        // Round to 2 decimal places
        value = Math.Round(value, 2);
    }
}

// Temperature in Celsius with conversion methods
[ValueObject<decimal>(AllowDefaultStructs = true)]
public partial struct Temperature
{
    static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref decimal value)
    {
        if (value < -273.15m) // Absolute zero
        {
            validationError = new ValidationError("Temperature cannot be below absolute zero (-273.15°C)");
            return;
        }

        // Round to 1 decimal place
        value = Math.Round(value, 1);
    }

    public decimal ToFahrenheit() => _value * 9/5 + 32;
    public decimal ToKelvin() => _value + 273.15m;
}

// ISBN (International Standard Book Number)
[ValueObject<string>]
public partial class ISBN
{
    static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref string value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            validationError = new ValidationError("ISBN cannot be empty");
            return;
        }

        // Remove any hyphens and whitespace
        value = value.Replace("-", "").Replace(" ", "");
        
        if (!IsValidISBN(value))
        {
            validationError = new ValidationError("Invalid ISBN format or checksum");
            return;
        }
    }

    private static bool IsValidISBN(string isbn)
    {
        // ISBN-13 validation
        if (isbn.Length != 13 || !isbn.All(char.IsDigit))
            return false;

        var sum = 0;

        for (var i = 0; i < 12; i++)
        {
            var digit = isbn[i] - '0';
            sum += (i % 2 == 0) ? digit : digit * 3;
        }

        var checksum = (10 - (sum % 10)) % 10;
        return checksum == (isbn[12] - '0');
    }
}

After implementing a value object, the source generator provides the following functionality:

  1. Creation and Validation
// Using Create (throws ValidationException if invalid)
var percentage = Percentage.Create(75.5m);    // Success: 75.50%
var temp = Temperature.Create(-300);          // Throws: below absolute zero

// Using TryCreate (returns false if invalid)
if (Percentage.TryCreate(150, out var invalidPercentage))
{
    // Won't reach here - validation fails
}

// Using Validate (returns ValidationError if invalid)
var error = Temperature.Validate(-300, null, out var invalidTemp);
if (error != null)
{
    Console.WriteLine(error.ToString());  // "Temperature cannot be below absolute zero"
}
  1. Type Conversion
// Explicit conversion from underlying type
var temp = (Temperature)25.0m;

// Implicit conversion to underlying type
decimal percentValue = percentage;        // 75.50m
decimal tempValue = temp;                // 25.0m
  1. Equality and Comparison
var temp1 = Temperature.Create(20.0m);
var temp2 = Temperature.Create(20.0m);
var temp3 = Temperature.Create(25.0m);

// Equality
bool areEqual = temp1 == temp2;          // true
bool areNotEqual = temp1 != temp3;       // true

// Comparison (if supported by underlying type)
bool isWarmer = temp3 > temp1;           // true
bool isCoolerOrSame = temp1 <= temp2;    // true
  1. String Formatting
// ToString implementation
Console.WriteLine(percentage);            // "75.50"
Console.WriteLine(temp);                  // "25.0"

// IFormattable implementation (if underlying type supports it)
var formatted = percentage.ToString("0.0", CultureInfo.InvariantCulture); // "75.5"
  1. Parsing (useful for ASP.NET Core binding)
// TryParse implementation
bool success = Temperature.TryParse("25.5", null, out var parsedTemp);

// Parse implementation (throws if invalid)
var temp = Temperature.Parse("25.5", null);
  1. Default Values for Structs
// Static Empty property (can be renamed) is generated if AllowDefaultStructs is set to true
var zero = Percentage.Empty;              // 0.00%
var defaultTemp = default(Temperature);   // Same as Temperature.Empty

The source generator also implements other interfaces and functionality based on the underlying type:

  • Type conversion operators
  • Arithmetic operators
  • Hash code generation
  • And more

The source generator also implements a TypeConverter for integration with various frameworks:

// TypeConverter support (used by frameworks like ASP.NET Core)
TypeConverter converter = TypeDescriptor.GetConverter(typeof(Temperature));

// Convert to string
var value = (string?)converter.ConvertTo(Temperature.Create(25.0m), typeof(string));  // "25.0"

// Convert from string
var temp = (Temperature?)converter.ConvertFrom("25.0");  // Temperature(25.0)

Complex value objects

A complex value object is an immutable class or a readonly struct with a ComplexValueObjectAttribute. Complex value objects usually have multiple readonly fields/properties that together represent a single concept.

Although a complex value object can have 1 field/property (or no members at all), it won't get the same treatment as a simple value object.

Here's an example of a complex value object with validation:

[ComplexValueObject]
public partial class 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}'");
            return;
        }

        // Round to 2 decimal places
        lower = Math.Round(lower, 2);
        upper = Math.Round(upper, 2);
    }
}

The source generator provides the following functionality:

  1. Creation and Validation
// Using Create (throws ValidationException if invalid)
var boundary = Boundary.Create(lower: 1.234m, upper: 2.567m);  // Rounds to (1.23, 2.57)

// Using TryCreate (returns false if invalid)
if (Boundary.TryCreate(lower: 5, upper: 3, out var invalidBoundary))
{
    // Won't reach here - validation fails
}

// Using Validate (returns ValidationError if invalid)
var error = Boundary.Validate(lower: 5, upper: 3, out var boundaryWithError);
if (error != null)
{
    Console.WriteLine(error.ToString());  // "Lower boundary '5' must be less than or equal to upper boundary '3'"
}
  1. Equality Comparison
var boundary1 = Boundary.Create(lower: 1, upper: 2);
var boundary2 = Boundary.Create(lower: 1, upper: 2);

// Equality comparison (compares all properties)
bool areEqual = boundary1 == boundary2;         // true
bool areNotEqual = boundary1 != boundary2;      // false

// GetHashCode (uses all properties)
bool sameHash = boundary1.GetHashCode() == boundary2.GetHashCode();  // true
  1. String Representation
// ToString implementation
Console.WriteLine(boundary1);  // "{ Lower = 1, Upper = 2 }"

Validation

Value objects provide built-in validation through the ValidateFactoryArguments method. This method is called when creating instances and allows you to:

  • Validate input values
  • Normalize data (e.g., trim strings, round numbers)
  • Return descriptive error messages

Validation of the factory method arguments

Both simple and complex value objects have a partial method ValidateFactoryArguments to implement custom validation. The method should not throw exceptions but use the ValidationError type.

The parameters are passed-in "by-ref" to allow normalization (like removing whitespace or rounding numbers).

Here are examples of common validation patterns:

// Simple value object validation
[ValueObject<string>]
public partial class ProductName
{
    static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref string value)
    {
        // Null/empty check
        if (string.IsNullOrWhiteSpace(value))
        {
            value = null!; // Disable NRT warning since we return error
            validationError = new ValidationError("Product name cannot be empty");
            return;
        }

        // Normalization
        value = value.Trim();
        
        // Length validation
        if (value.Length < 3)
        {
            validationError = new ValidationError("Product name must be at least 3 characters");
            return;
        }
    }
}

// Complex value object validation
[ComplexValueObject]
public partial class Boundary
{
    public decimal Lower { get; }
    public decimal Upper { get; }

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

        // Normalization
        lower = Math.Round(lower, 2);
        upper = Math.Round(upper, 2);
    }
}

Validation of the constructor arguments

In addition to ValidateFactoryArguments, there is a ValidateConstructorArguments method that is called by the constructor. However, this method has limitations:

⚠️ Prefer ValidateFactoryArguments over ValidateConstructorArguments. Constructors can only throw exceptions, which leads to poor integration with frameworks like JSON serialization, ASP.NET Core model binding, and Entity Framework Core.

Here's how constructor validation works:

// Simple value object constructor validation
[ValueObject<string>]
public partial class ProductName
{
    static partial void ValidateConstructorArguments(ref string value)
    {
        // Constructor validation can only throw exceptions
        if (string.IsNullOrWhiteSpace(value))
            throw new ArgumentException("Product name cannot be empty");
            
        value = value.Trim();
    }
}

// Complex value object constructor validation
[ComplexValueObject]
public partial class Boundary
{
    public decimal Lower { get; }
    public decimal Upper { get; }

    static partial void ValidateConstructorArguments(
        ref decimal lower,
        ref decimal upper)
    {
        // Constructor validation is less flexible than factory validation
        if (lower > upper)
            throw new ArgumentException($"Lower boundary '{lower}' must be less than or equal to upper boundary '{upper}'");
    }
}

Customizing

Key member generation

The key member of a simple value object is generated by the source generator.
Use KeyMemberName, KeyMemberAccessModifier and KeyMemberKind to change the generation of the key member, or set SkipKeyMember to true to provide custom implementation.

Example: Let source generator generate property public DateOnly Date { get; } instead of field private readonly DateOnly _value; (Default).

[ValueObject<DateOnly>(KeyMemberName = "Date",
                       KeyMemberAccessModifier = ValueObjectAccessModifier.Public,
                       KeyMemberKind = ValueObjectMemberKind.Property)]
public readonly partial struct EndDate
{
}

Example of custom implementation:

[ValueObject<DateOnly>(SkipKeyMember = true,      // We implement the key member "Date" ourselves
                       KeyMemberName = "Date")]   // Source Generator needs to know the name we've chosen
public readonly partial struct OpenEndDate
{
   private readonly DateOnly? _date;

   private DateOnly Date
   {
      get => _date ?? DateOnly.MaxValue;
      init => _date = value;
   }
}

Custom equality comparer

By default, the source generator is using the default implementation of Equals and GetHashCode for all assignable properties and fields, except for strings.

If the member is a string, then the source generator is using StringComparer.OrdinalIgnoreCase. Additionally, the analyzer will warn you if you don't provide an equality comparer for a string-based value object.

Equality comparison of simple value objects

Use ValueObjectKeyMemberEqualityComparerAttribute<TComparerAccessor, TMember> to define an equality comparer for comparison of key members and for computation of the hash code. Use one of the predefined ComparerAccessors or implement a new one.

The example below changes the comparer from OrdinalIgnoreCase to Ordinal.

[ValueObject<string>]
[ValueObjectKeyMemberEqualityComparer<ComparerAccessors.StringOrdinal, string>]
public sealed partial class ProductName
{
}

Equality comparison of complex value objects

Use ValueObjectMemberEqualityComparerAttribute<TComparerAccessor, TMember> to change both, the equality comparer and the members being used for comparison and computation of the hash code.

[ComplexValueObject]
public sealed partial class Boundary
{
   // The equality comparison uses `Lower` only!
   [ValueObjectMemberEqualityComparer<ComparerAccessors.Default<decimal>, decimal>]
   public decimal Lower { get; }

   public decimal Upper { get; }
}

To use all assignable properties in comparison, either don't use ValueObjectMemberEqualityComparerAttribute at all or put it on all members.

[ComplexValueObject]
public sealed partial class Boundary
{
   [ValueObjectMemberEqualityComparer<ComparerAccessors.Default<decimal>, decimal>]
   public decimal Lower { get; }

   [ValueObjectMemberEqualityComparer<ComparerAccessors.Default<decimal>, decimal>]
   public decimal Upper { get; }
}

For complex value objects, you can also customize the string comparison behavior using the DefaultStringComparison property:

[ComplexValueObject(DefaultStringComparison = StringComparison.CurrentCulture)]
public partial class MyValueObject
{
    public string Property1 { get; }
    public string Property2 { get; }
}

Predefined and Custom Comparer-Accessors

Implement the interface IEqualityComparerAccessor<T> to create a new custom accessor. The accessor has 1 property that returns an instance of IEqualityComparer<T>. The generic type T is the type of the member to compare.

public interface IEqualityComparerAccessor<in T>
{
   static abstract IEqualityComparer<T> EqualityComparer { get; }
}

Implementation of an accessor for members of type string.

public class StringOrdinal : IEqualityComparerAccessor<string>
{
  public static IEqualityComparer<string> EqualityComparer => StringComparer.Ordinal;
}

Predefined accessors in static class ComparerAccessors:

// Predefined:
ComparerAccessors.StringOrdinal
ComparerAccessors.StringOrdinalIgnoreCase
ComparerAccessors.CurrentCulture
ComparerAccessors.CurrentCultureIgnoreCase
ComparerAccessors.InvariantCulture
ComparerAccessors.InvariantCultureIgnoreCase
ComparerAccessors.Default<T>; // e.g. ComparerAccessors.Default<string> or ComparerAccessors.Default<int>

Custom comparer

A custom implementation of IComparer<T> can be defined on simple value objects only.

Please note that this section is about implementation of IComparable<T> and IComparer<T>. Don't confuse the IComparer<T> with IEqualityComparer<T> which is being used for equality comparison and the computation of the hash code.

Use ValueObjectKeyMemberComparerAttribute to specify a comparer. Use one of the predefined ComparerAccessors or implement a new one (see below).

[ValueObject<string>]
[ValueObjectKeyMemberComparer<ComparerAccessors.StringOrdinal, string>]
public sealed partial class ProductName
{
}

Implement the interface IComparerAccessor<T> to create a new custom accessor. The accessor has 1 property that returns an instance of IComparer<T>. The generic type T is the type of the member to compare.

public interface IComparerAccessor<in T>
{
   static abstract IComparer<T> Comparer { get; }
}

Implementation of an accessor for members of type string.

public class StringOrdinal : IComparerAccessor<string>
{
    public static IComparer<string> Comparer => StringComparer.OrdinalIgnoreCase;
}

Predefined accessors in static class ComparerAccessors:

// Predefined:
ComparerAccessors.StringOrdinal
ComparerAccessors.StringOrdinalIgnoreCase
ComparerAccessors.CurrentCulture
ComparerAccessors.CurrentCultureIgnoreCase
ComparerAccessors.InvariantCulture
ComparerAccessors.InvariantCultureIgnoreCase
ComparerAccessors.Default<T>; // e.g. ComparerAccessors.Default<string> or ComparerAccessors.Default<int>

Custom type for validation errors

The default ValidationError class only carries a simple error message. For more complex validation scenarios, you can create a custom validation error type that carries additional information:

  1. Create a class implementing IValidationError<T>
  2. Apply ValueObjectValidationErrorAttribute<T> to your value object
  3. Use the custom error type in validation methods

⚠️ Custom validation types must implement ToString() for proper framework integration (JSON serialization, error messages, etc.)

Here's an example of a custom validation error with additional context:

// Custom validation error with additional information
public class BoundaryValidationError : IValidationError<BoundaryValidationError>
{
    public string Message { get; }
    public decimal? Lower { get; }
    public decimal? Upper { get; }

    // Constructor for custom validation scenarios
    public BoundaryValidationError(
        string message,
        decimal? lower,
        decimal? upper)
    {
        Message = message;
        Lower = lower;
        Upper = upper;
    }

    // Required factory method for generated code
    public static BoundaryValidationError Create(string message)
    {
        return new BoundaryValidationError(message, null, null);
    }

    // Required for framework integration
    public override string ToString()
    {
        return $"{Message} (Lower={Lower}, Upper={Upper})";
    }
}

// Using custom validation error
[ComplexValueObject]
[ValueObjectValidationError<BoundaryValidationError>]
public sealed partial class Boundary
{
    public decimal Lower { get; }
    public decimal Upper { get; }

    static partial void ValidateFactoryArguments(
        ref BoundaryValidationError? validationError,
        ref decimal lower,
        ref decimal upper)
    {
        if (lower > upper)
        {
            validationError = new BoundaryValidationError(
                "Lower boundary must be less than upper boundary",
                lower,
                upper);
            return;
        }

        // Normalize values
        lower = Math.Round(lower, 2);
        upper = Math.Round(upper, 2);
    }
}

Constructor access modifier

By default, value object constructors are private. You can change this using the ConstructorAccessModifier property:

⚠️ Consider carefully before making constructors public. Factory methods provide better validation and framework integration.

// Simple value object with public constructor
[ValueObject<string>(ConstructorAccessModifier = ValueObjectAccessModifier.Public)]
public sealed partial class ProductName
{
}

// Complex value object with public constructor
[ComplexValueObject(ConstructorAccessModifier = ValueObjectAccessModifier.Public)]
public sealed partial class Boundary
{
    public decimal Lower { get; }
    public decimal Upper { get; }
}

Factory Method Customization

The source generator creates factory methods for object creation and validation. You can customize these methods in several ways:

Renaming Factory Methods

You can change the default names of factory methods (Create and TryCreate):

// Simple value object with custom factory method names
[ValueObject<string>(
    CreateFactoryMethodName = "Parse",
    TryCreateFactoryMethodName = "TryParse")]
public sealed partial class ProductName
{
}

// Complex value object with custom factory method names
[ComplexValueObject(
    CreateFactoryMethodName = "FromRange",
    TryCreateFactoryMethodName = "TryFromRange")]
public sealed partial class Boundary
{
    public decimal Lower { get; }
    public decimal Upper { get; }
}

Disabling Factory Methods

⚠️ Disabling factory methods will prevent framework integration features like JSON serialization and model binding from working.

You can disable factory method generation entirely:

// Simple value object without factory methods
[ValueObject<string>(SkipFactoryMethods = true)]
public sealed partial class ProductName
{
}

// Complex value object without factory methods
[ComplexValueObject(SkipFactoryMethods = true)]
public sealed partial class Boundary
{
    public decimal Lower { get; }
    public decimal Upper { get; }
}

Null and Empty String Handling

Factory methods provide special handling for null and empty string values:

Null Value Handling

By default, factory methods reject null values. You can change this behavior:

// Allow null values to return null
[ValueObject<string>(
    NullInFactoryMethodsYieldsNull = true)]
public sealed partial class ProductName
{
}

// Usage
var name1 = ProductName.Create(null);     // Returns null
var name2 = ProductName.Create("Valid");  // Returns ProductName instance

Empty String Handling

For string value objects, you can configure empty string handling:

// Treat empty/whitespace strings as null
[ValueObject<string>(
    EmptyStringInFactoryMethodsYieldsNull = true)]
public sealed partial class ProductName
{
    public string Value { get; }
}

// Usage
var name1 = ProductName.Create("");        // Returns null
var name2 = ProductName.Create("   ");     // Returns null
var name3 = ProductName.Create("Valid");   // Returns ProductName instance

Operator Customization

Value objects support various operators and interfaces that can be customized or disabled:

Comparison Interfaces and Operators

Control implementation of comparison interfaces (IComparable, IComparable<T>) and operators:

// Disable IComparable/IComparable<T> implementation
[ValueObject<int>(
    SkipIComparable = true)]
public readonly partial struct Amount
{
}

// Configure comparison operators (>, >=, <, <=)
[ValueObject<int>(
    ComparisonOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads)]
public readonly partial struct Amount
{
}

⚠️ Setting ComparisonOperators affects EqualityComparisonOperators to ensure consistent behavior between comparison and equality operations.

Arithmetic Operators

Control implementation of arithmetic operators (+, -, *, /):

// Configure all arithmetic operators
[ValueObject<decimal>(
    // Enable key type overloads (e.g., Amount + decimal)
    AdditionOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads,      // +
    SubtractionOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads,   // -
    MultiplyOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads,      // *
    DivisionOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads)]     // /
public readonly partial struct Amount
{
}

// Disable specific operators
[ValueObject<decimal>(
    AdditionOperators = OperatorsGeneration.None,      // No + operator
    MultiplyOperators = OperatorsGeneration.None)]     // No * operator
public readonly partial struct Amount
{
}

Available operator modes:

  • None: Operator not generated
  • Default: Standard operators between value objects
  • DefaultWithKeyTypeOverloads: Also generates operators with key member type (e.g., Amount + decimal)

Parsing and Formatting

Value objects implement several interfaces for string handling that can be customized:

// Disable IParsable<T> implementation (affects string parsing)
[ValueObject<int>(SkipIParsable = true)]
public readonly partial struct Amount
{
}

// Disable IFormattable implementation (affects custom formatting)
[ValueObject<int>(SkipIFormattable = true)]
public readonly partial struct Amount
{
}

// Disable ToString override (affects string representation)
[ValueObject<int>(SkipToString = true)]
public readonly partial struct Amount
{
}

// Can also be used with complex value objects
[ComplexValueObject(SkipToString = true)]
public sealed partial class Boundary
{
    public decimal Lower { get; }
    public decimal Upper { get; }
}

Default Struct Handling

Value objects that are structs support additional customization options:

// Rename Empty property
[ValueObject<int>(
    AllowDefaultStructs = true,             // Allow default value
    DefaultInstancePropertyName = "Zero")]  // Changes the property name from "Empty" to "Zero"
public partial struct Amount
{
}

// Usage
var zero = Amount.Zero;  // Instead of Amount.Empty

// Allow default value
[ComplexValueObject(
    AllowDefaultStructs = true,                 // Allow default value
    DefaultInstancePropertyName = "Unbounded")] // Enables default(Boundary)
public partial struct Boundary
{
    public decimal Lower { get; }
    public decimal Upper { get; }
}

Type Conversion

Value objects support various conversion options:

Key Member Conversion

Simple value objects can control how they convert to and from their key member type using three properties:

  • ConversionToKeyMemberType: Controls conversion from value object to key member type (default: Implicit)
  • ConversionFromKeyMemberType: Controls conversion from key member type to value object (default: Explicit)
  • UnsafeConversionToKeyMemberType: Controls conversion from reference type value object to value type key member (default: Explicit)

Each property can be set to:

  • None: No conversion operator is generated
  • Implicit: Generates an implicit conversion operator
  • Explicit: Generates an explicit conversion operator requiring a cast
[ValueObject<int>(
    ConversionToKeyMemberType = ConversionOperatorsGeneration.Explicit,      // To key type
    ConversionFromKeyMemberType = ConversionOperatorsGeneration.Implicit,    // From key type
    UnsafeConversionToKeyMemberType = ConversionOperatorsGeneration.None)]  // Reference to value type
public partial struct Amount
{
}

Note: UnsafeConversionToKeyMemberType only applies when converting from reference type value objects to value type key members.

Custom Type Conversion

With ValueObjectFactory<T> you can implement additional methods to convert a Value Object from/to type T. This conversion can be one-way (T -> Value Object) or two-way (T <-> Value Object).

Conversion from a string allows ASP.NET Model Binding to bind both Simple and Complex Value Objects.

Applying ValueObjectFactoryAttribute<string> adds the interface IValueObjectFactory<Boundary, string> which forces you to implement a new Validate method overload for string conversion:

// One-way conversion (string -> Boundary)
[ComplexValueObject]
[ValueObjectFactory<string>]
public partial class Boundary
{
    public decimal Lower { get; }
    public decimal Upper { get; }

    // Required by IValueObjectFactory<Boundary, string>
    public static ValidationError? Validate(
        string? value,
        IFormatProvider? provider,
        out Boundary? item)
    {
        // Convert strings in format "lower:upper", examples:
        // "1.5:2.5" -> Boundary(Lower=1.5, Upper=2.5)
        // "10:20"   -> Boundary(Lower=10, Upper=20)
        // "-5:5"    -> Boundary(Lower=-5, Upper=5)

        item = null;
        if (value is null)
            return null;

        var parts = value.Split(":", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);

        if (parts.Length != 2)
            return new ValidationError("Invalid format. Expected 'lower:upper', e.g. '1.5:2.5'");

        if (!decimal.TryParse(parts[0], provider, out var lower) ||
            !decimal.TryParse(parts[1], provider, out var upper))
            return new ValidationError("Invalid numbers. Expected decimal values, e.g. '1.5:2.5'");

        // Delegate to regular validation
        return Validate(lower, upper, out item);
    }
}

Two-way conversion is required if the type T must be used for serialization/deserialization. Setting UseForSerialization = SerializationFrameworks.All adds the interface IValueObjectConvertable<string> which requires implementing ToValue(). The serialization frameworks (like System.Text.Json) specified by SerializationFrameworks will then prefer these methods over the default object serialization.

// Two-way conversion with serialization support
[ComplexValueObject]
[ValueObjectFactory<string>(UseForSerialization = SerializationFrameworks.All)]
public partial class Boundary
{
    public decimal Lower { get; }
    public decimal Upper { get; }

    // Required by IValueObjectFactory<Boundary, string>
    public static ValidationError? Validate(string? value, ...) { ... }

    // Required by IValueObjectConvertable<string>
    public string ToValue() => $"{Lower}:{Upper}";  // e.g. "1.5:2.5"
}

Usage examples:

var boundary1 = Boundary.Parse("1.5:2.5", null);   // Success: Lower=1.5, Upper=2.5
var boundary2 = Boundary.Parse("invalid", null);   // Fails: Invalid format
var boundary3 = Boundary.Parse("1.5:abc", null);   // Fails: Invalid numbers
var str = boundary1.ToValue();                // "1.5:2.5"

JSON serialization

Depending on the concrete JSON library you use, you need a different Nuget package:

  • For System.Text.Json: Thinktecture.Runtime.Extensions.Json
  • For Newtonsoft.Json: Thinktecture.Runtime.Extensions.Newtonsoft.Json

There are 2 options to make the Value Objects JSON convertible.

Option 1: Make project with Value Objects depend on corresponding Nuget package

The easiest way is to make Thinktecture.Runtime.Extensions.Json / Thinktecture.Runtime.Extensions.Newtonsoft.Json a dependency of the project(s) the value objects are in. The dependency doesn't have to be a direct one but can be transitive as well. Both Nuget packages lead to generation of JSON converters and flag the value object with a JsonConverterAttribute. This way the value objects can be converted to and from JSON without extra code.

Option 2: Register JSON converter with JSON serializer settings

For simple value objects only.

If making previously mentioned Nuget package a dependency of project(s) with value objects is not possible or desirable, then the other option is to register a JSON converter with JSON serializer settings. By using a JSON converter directly, the Nuget package can be installed in any project where the JSON settings are configured.

  • Use ValueObjectJsonConverterFactory with System.Text.Json
  • Use ValueObjectNewtonsoftJsonConverter with Newtonsoft.Json

An example for ASP.NET Core application using System.Text.Json:

var webHost = new HostBuilder()
              .ConfigureServices(collection =>
               {
                  collection.AddMvc()
                            .AddJsonOptions(options => options.JsonSerializerOptions
                                                              .Converters
                                                              .Add(new ValueObjectJsonConverterFactory()));
               })

An example for minimal apis:

var builder = WebApplication.CreateBuilder();

builder.Services
       .ConfigureHttpJsonOptions(options => options.SerializerOptions
                                                   .Converters
                                                   .Add(new ValueObjectJsonConverterFactory()));

The code for Newtonsoft.Json is almost identical:

var webHost = new HostBuilder()
              .ConfigureServices(collection =>
               {
                   collection.AddMvc()
                             .AddNewtonsoftJson(options => options.SerializerSettings
                                                                  .Converters
                                                                  .Add(new ValueObjectNewtonsoftJsonConverter()));
               })

MessagePack serialization

  • Required nuget package: Thinktecture.Runtime.Extensions.MessagePack

There are 2 options to make the value objects MessagePack serializable.

Option 1: Make project with Value Objects depend on Nuget package

The easiest way is to make Thinktecture.Runtime.Extensions.MessagePack a dependency of the project(s) the value objects are in. The dependency doesn't have to be a direct one but can be transitive as well. The Nuget package leads to generation of a MessagePack formatter and flags the value object with a MessagePackFormatterAttribute. This way the value object can be serialized to and from MessagePack without extra code.

Option 2: Register MessagePack FormatterResolver with MessagePack serializer options

For simple value objects only.

If making previously mentioned Nuget package a dependency of project(s) with value objects is not possible or desirable, then the other option is to register the MessagePack formatter with MessagePack serializer options. By using the ValueObjectMessageFormatterResolver directly, the Nuget package can be installed in any project where the MessagePack options are configured.

An example of a round-trip-serialization of the value object ProductName:

// Use "ValueObjectMessageFormatterResolver.Instance"
var resolver = CompositeResolver.Create(ValueObjectMessageFormatterResolver.Instance, StandardResolver.Instance);
var options = MessagePackSerializerOptions.Standard.WithResolver(resolver);

ProductName chocolate = ProductName.Create("Chocolate");

// Serialize to MessagePack
var bytes = MessagePackSerializer.Serialize(chocolate, options, CancellationToken.None);

// Deserialize from MessagePack
var deserializedChocolate = MessagePackSerializer.Deserialize<ProductName>(bytes, options, CancellationToken.None);

Support for Minimal Api Parameter Binding and ASP.NET Core Model Binding

Required nuget package: Thinktecture.Runtime.Extensions.AspNetCore

Having JSON convertible value objects is just half of the equation. If a value of a simple value object is received as a query parameter, then there is no JSON conversion in play but ASP.NET Core Model Binding. Besides model binding, i.e., conversion from query string to a value object, there is model validation as well.

ASP.NET Core Model Binding is for simple value objects only and for complex objects with ValueObjectFactoryAttribute<string> (see section Custom Type Conversion). A complex value object has more than 1 property/field, so, deserialization (without ValueObjectFactoryAttribute<string>) from a string to 2+ members is a case for JSON (de)serialization.

Minimal Api

The parameter binding of Minimal Apis in .NET 7 is still quite primitive in comparison to the model binding of MVC controllers. To make a type bindable it has to implement either TryParse or BindAsync. A simple Value Object implements TryParse (interface IParsable<T>) by default, so it can be used with Minimal Apis without any changes.

At the moment, the parameter binding (with TryParse and BindAsync) doesn't allow to pass custom validation errors to be returned to the client. The only information we can pass is an indication whether the parameter could be bound or not.

ASP.NET Core MVC (Controllers)

ASP.NET MVC gives us more control during model binding. For example, if we expect from client a ProductName and receive the value A, which is rejected by the validation, then the ASP.NET Core ModelState will be invalid. In this case we can reject (or let ApiControllerAttribute reject) the request.

By rejecting the request, the client gets the status code BadRequest (400) and the error:

{
  "productName": [
    "Product name cannot be 1 character long."
  ]
}

To help out the Model Binding we have to register the ValueObjectModelBinderProvider with ASP.NET Core. By using the custom model binder, the Nuget package can be installed in any project where ASP.NET Core is configured.

Place the "ValueObjectModelBinderProvider" before default providers, so they don't try to bind value objects.

var webHost = new HostBuilder()
              .ConfigureServices(collection =>
              {
                   collection.AddMvc(options => options.ModelBinderProviders
                                                       .Insert(0, new ValueObjectModelBinderProvider()));
              })

Support for Entity Framework Core

Optional nuget packages:
Thinktecture.Runtime.Extensions.EntityFrameworkCore6
Thinktecture.Runtime.Extensions.EntityFrameworkCore7
Thinktecture.Runtime.Extensions.EntityFrameworkCore8
Thinktecture.Runtime.Extensions.EntityFrameworkCore9

Starting with Entity Framework Core 2.1 we've got the feature Value Conversion. By providing a value converter, EF Core can convert a simple value object (like ProductName) to and from a primitive type (like string) when persisting the data and when reading the value from database.

Option 1: Manual registration of the ValueConverter

The registration of a value converter can be done manually by using one of the method overloads of HasConversion in OnModelCreating.

This approach is not recommended because "Create" can perform some heavy validations which will affect performance. In general, data loaded from the database doesn't have to validated because the database is the "source of truth". Use option 2 or 3 to register a value converter which (by default) uses the constructor directly.

// Entity
public class Product
{
   // other properties...

   public ProductName Name { get; private set; }
}

public class ProductsDbContext : DbContext
{
   protected override void OnModelCreating(ModelBuilder modelBuilder)
   {
      base.OnModelCreating(modelBuilder);

      modelBuilder.Entity<Product>(builder =>
      {
        builder.Property(p => p.Name)
               .HasConversion(name => (string)name,
                              s => ProductName.Create(s));
      });
   }
}

Entity Framework Core value conversion is for simple value objects only. Treating a complex value object as an owned entity or complex type is more suitable than pressing multiple members into 1 column.

// Entity
public class Product
{
   // other properties...

   public Boundary Boundary { get; private set; }
}

public class ProductsDbContext : DbContext
{
   protected override void OnModelCreating(ModelBuilder modelBuilder)
   {
      base.OnModelCreating(modelBuilder);

      modelBuilder.Entity<Product>(builder =>
      {
          builder.ComplexProperty(p => p.Boundary,
                                  boundaryBuilder =>
                                  {
                                     boundaryBuilder.Property(b => b.Lower).HasColumnName("Lower").HasPrecision(18, 2);
                                     boundaryBuilder.Property(b => b.Upper).HasColumnName("Upper").HasPrecision(18, 2);
                                  });
      });
   }
}

Option 2: Registration of the ValueConverter via extension method for ModelBuilder

Alternatively, you can install the appropriate Nuget package for EF Core 6, EF Core 7, EF Core 8 or EF Core 9 and use the extension method AddValueObjectConverters to register the value converters for you.

public class ProductsDbContext : DbContext
{
   protected override void OnModelCreating(ModelBuilder modelBuilder)
   {
      base.OnModelCreating(modelBuilder);

      modelBuilder.AddValueObjectConverters();
   }
}

You can provide a delegate to adjust the configuration of Smart Enums and Value Objects.

modelBuilder.AddValueObjectConverters(
             configureEnumsAndKeyedValueObjects: property =>
                                                 {
                                                    if (property.ClrType == typeof(ProductType))
                                                       property.SetMaxLength(20);
                                                 });

Option 3: Registration of the ValueConverter via extension method for EntityTypeBuilder

You can also configure value converters at the entity level using the AddValueObjectConverters extension method on EntityTypeBuilder. This approach allows you to apply value converters only to specific entities.

public class ProductsDbContext : DbContext
{
   protected override void OnModelCreating(ModelBuilder modelBuilder)
   {
      base.OnModelCreating(modelBuilder);

      modelBuilder.Entity<Product>(builder =>
      {
         builder.HasKey(p => p.Id);
         
         // Apply value converters to all Smart Enums and Value Objects in this entity
         builder.AddValueObjectConverters();
      });
   }
}

You can customize the conversion behavior with additional parameters:

builder.AddValueObjectConverters(
    validateOnWrite: true,                // Validate values before writing to database (validatable Smart Enum only)
    useConstructorForRead: true,          // Use constructor instead of factory method when reading
    addConvertersForOwnedTypes: true,     // Apply to owned entities recursively
    configureEnumsAndKeyedValueObjects: property =>
    {
        if (property.ClrType == typeof(ProductType))
            property.SetMaxLength(20);
    });

This method is also available for owned entities through the OwnedNavigationBuilder:

modelBuilder.Entity<Order>(builder =>
{
    builder.OwnsOne(o => o.ShippingAddress, addressBuilder =>
    {
        // Apply value converters to all Smart Enums and Value Objects in this owned entity
        addressBuilder.AddValueObjectConverters();
    });
});

Option 4: Registration of the ValueConverter via extension method for DbContextOptionsBuilder

The other options is to use the extension method UseValueObjectValueConverter for the DbContextOptionsBuilder.

services
   .AddDbContext<DemoDbContext>(builder => builder
                                           .UseValueObjectValueConverter(validateOnWrite: true,
                                                                         configureEnumsAndKeyedValueObjects: property =>
                                                                               {
                                                                                 if (property.ClrType == typeof(ProductType))
                                                                                    property.SetMaxLength(20);
                                                                               })

Logging

Logging can be activated in the csproj-file. Define the property ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath pointing to an existing(!) folder (like C:\temp\). You can provide a file name (like samples_logs.txt) which is being used as a template for creation of a unique log file name like samples_logs_20230322_220653_19c0d6c18ec14512a1acf97621912abb.txt.

Please note, that there will be more than 1 log file (per project) because IDEs (Rider/VS) usually create 1 Source Generator for constant running in the background, and 1 for each build/rebuild of a project. Unless, ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique is set to false.

With ThinktectureRuntimeExtensions_SourceGenerator_LogLevel you can specify one of the following log levels: Trace, Debug, Information (DEFAULT), Warning, Error.

<Project Sdk="Microsoft.NET.Sdk">
   <PropertyGroup>
      ...

      <ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath>C:\temp\samples_logs.txt</ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath>
      <ThinktectureRuntimeExtensions_SourceGenerator_LogLevel>information</ThinktectureRuntimeExtensions_SourceGenerator_LogLevel>
      <ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique>false</ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique> 
      
   </PropertyGroup>

If the logger throws an exception, for example due to insufficient file system access permissions, then the logger will try to write the exception into a temp file. You can find the file ThinktectureRuntimeExtensionsSourceGenerator.log in the temp folder of the user the IDE/CLI is running with.

Real-world use cases and ideas

I started to write down some examples I used in the past to show the developers the benefits of value objects and smart enums.

Open-ended End Date

There are multiple ways to implement an end date with open-end. All of them have their pros and cons.
Here are the most popular approaches I encountered in the past:

  1. Use nullable DateOnly? (or DateTime?)

    • PRO: Better semantics, i.e. null means there is no end date. The default value of DateOnly? is null as well, which results in expected behavior.
    • CON: (LINQ) queries must check for both null and a concrete date, i.e. query.Where(i => i.MyEndDate is null || i.MyEndDate > now). Using such query with a database usually results in worse performance because ||/OR prevents the database from using an appropriate index.
  2. Use DateOnly.MaxValue (or DateTime)

    • PRO: The condition in the (LINQ) query is straight-forward query.Where(i => i.MyEndDate > now). If this query is executed on a database then the database is able to use an appropriate index which result in better performance.
    • CON: Using a special value like DateOnly.MaxValue to represent an open-ended date results in worse semantics.
    • CON: The main culprit is the keyword default or the default value of a DateOnly (or DateTime), which is DateOnly.MinValue. If the property/field/variable is not assigned explicitly and stays DateOnly.MinValue, then this most likely will lead to an undesired behavior. In this situation I would like to have an open-ended end date instead of the date 0001-01-01, which is an invalid end date in the most use cases.

The desired solution must:

  • not require OR in queries to improve performance
  • have a default value which represents open-ended end date

An always-valid value object EndDate which is a readonly struct.

[ValueObject<DateOnly>(SkipKeyMember = true,                                                           // We implement the key member "Date" ourselves
                       KeyMemberName = "Date",                                                         // Source Generator needs to know the name we've chosen
                       DefaultInstancePropertyName = "Infinite",                                       // "EndDate.Infinite" represent an open-ended end date
                       EqualityComparisonOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads)] // for comparison with DateOnly without implicit cast
public readonly partial struct EndDate
{
   private readonly DateOnly? _date;

   // can be public as well
   private DateOnly Date
   {
      get => _date ?? DateOnly.MaxValue;
      init => _date = value;
   }

   // Further validation
   // static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref DateOnly date)
   // {
   //    validationError = date.Year switch
   //    {
   //       < 2000 => new ValidationError("The end date lies too far in the past."),
   //       >= 2050 => new ValidationError("The end date lies too far in the future."),
   //       _ => validationError
   //    };
   // }
}

Basic usage (see also ValueObjectDemos.cs) is virtually the same as with DateOnly or DateTime.

// Create an EndDate
DateOnly today = DateOnly.FromDateTime(DateTime.Now);
EndDate endDate = (EndDate)today; 
EndDate endDate = EndDate.Create(today); // alternative

// Compare the dates
var isTrue = EndDate.Infinite > endDate;

// Default value is equal to infinite date and equal to "DateOnly.MaxValue"
var defaultEndDate = default(EndDate);
var infiniteEndDate = EndDate.Infinite;

isTrue = infiniteEndDate == defaultEndDate;

// Get the actual date if needed
DateOnly dateOfDefaultDate = defaultEndDate;
DateOnly dateOfInfiniteDate = infiniteEndDate;

isTrue = dateOfDefaultDate == dateOfInfiniteDate;

// Compare DateOnly with EndDate
isTrue = EndDate.Infinite == dateOfDefaultDate

Use EndDate with Entity Framework Core (see also Product.cs, EF-Demos and Support for Entity Framework Core)

Please note that DateOnly is not supported in EF Core 7 but will be in EF Core 8. I use the library ErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly in my EF Core 7 demos.

// Entity
public class Product
{
   ...
   public EndDate EndDate { get; set; }
}

// query
var today = (EndDate)DateOnly.FromDateTime(DateTime.Today);

var products = await ctx.Products
                        .Where(p => p.EndDate >= today)
                        .ToListAsync();

Use EndDate with ASP.NET Core controllers (see also DemoController.cs) and minimal api (see also minimal api demo).

Read the section "Support for Minimal Api Parameter Binding and ASP.NET Core Model Binding" to get more information.

// Controller
[Route("api"), ApiController]
public class DemoController : Controller
{
   [HttpGet("enddate/{endDate}")]
   public IActionResult RoundTripGet(EndDate endDate)
   {
      if (!ModelState.IsValid)
         return BadRequest(ModelState);

      return Json(endDate);
   }

   [HttpPost("enddate")]
   public IActionResult RoundTripPost([FromBody] EndDate endDate)
   {
      if (!ModelState.IsValid)
         return BadRequest(ModelState);

      return Json(endDate);
   }
}

// Minimal api
var app = builder.Build();
var routeGroup = app.MapGroup("/api");

routeGroup.MapGet("enddate/{date}", (EndDate date) => date);
routeGroup.MapPost("enddate", ([FromBody] EndDate date) => date);

The response is the same in both cases.

GET api/enddate/2023-04-05
  and
POST api/enddate 
 with body "2023-04-05"

returns "2023-04-05"

(Always-positive) Amount

Value objects are excellent for checking some kind of invariants. In one of my use cases I had to perform a calculation of moderate complexity and the result and all partial results must always be positive. We could use a plain decimal and check the (partial) result after every(!) arithmetic operation, but it requires more code and is difficult to read and to maintain. Instead, we switched from decimal to a readonly struct Amount which checks the invariant automatically.

[ValueObject<decimal>(DefaultInstancePropertyName = "Zero",                                  // renames Amount.Empty to Amount.Zero
                      ComparisonOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads, // for comparison of amount with a decimal without implicit conversion: amount > 42m
                      AdditionOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads,   // for arithmetic operations of amount with a decimal without implicit conversion: amount + 42m
                      SubtractionOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads,
                      MultiplyOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads,
                      DivisionOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads)]
public readonly partial struct Amount
{
   static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref decimal value)
   {
      if (value < 0)
         validationError = new ValidationError("Amount must be positive.");
   }
}

The usage is the same as with a plain decimal.

// get an instance of amount with Create/TryCreate/Validate or an explicit cast
var amount = Amount.Create(1m);
var otherAmount = (Amount)2m;
var zero = Amount.Zero;

// equality comparisons
amount == zero; // false
amount > otherAmount; // false
amount > 42m; // false
amount.CompareTo(otherAmount); // -1

// arithmetic operations
amount + otherAmount; // 3
amount + 42m // 43
Clone this wiki locally