Skip to content

Value Objects

Pawel Gerr edited this page Apr 29, 2025 · 40 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("[email protected]");  // 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 is not 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 is not 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 = AccessModifier.Public,
                       KeyMemberKind = MemberKind.Property)]
public readonly partial struct OpenEndDate
{
}

Example of custom implementation:

[ValueObject<DateOnly>(SkipKeyMember = true,            // We implement the key member "Date" ourselves
                       KeyMemberName = nameof(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 KeyMemberEqualityComparerAttribute<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>]
[KeyMemberEqualityComparer<ComparerAccessors.StringOrdinal, string>]
public sealed partial class ProductName
{
}

Equality comparison of complex value objects

Use MemberEqualityComparerAttribute<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!
   [MemberEqualityComparer<ComparerAccessors.Default<decimal>, decimal>]
   public decimal Lower { get; }

   public decimal Upper { get; }
}

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

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

   [MemberEqualityComparer<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 KeyMemberComparerAttribute to specify a comparer. Use one of the predefined ComparerAccessors or implement a new one (see below).

[ValueObject<string>]
[KeyMemberComparer<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 ValidationErrorAttribute<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]
[ValidationError<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 = AccessModifier.Public)]
public sealed partial class ProductName
{
}

// Complex value object with public constructor
[ComplexValueObject(ConstructorAccessModifier = AccessModifier.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 ObjectFactory<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 ObjectFactoryAttribute<string> adds the interface IObjectFactory<Boundary, string> which forces you to implement a new Validate method overload for string conversion:

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

    // Required by IObjectFactory<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 IConvertible<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]
[ObjectFactory<string>(UseForSerialization = SerializationFrameworks.All)]
public partial class Boundary
{
    public decimal Lower { get; }
    public decimal Upper { get; }

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

    // Required by IConvertible<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 ThinktectureJsonConverterFactory with System.Text.Json
  • Use ThinktectureNewtonsoftJsonConverterFactory 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 ThinktectureJsonConverterFactory()));
               })

An example for minimal apis:

var builder = WebApplication.CreateBuilder();

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

The code for Newtonsoft.Json is almost identical:

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

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 ThinktectureMessageFormatterResolver 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 "ThinktectureMessageFormatterResolver"
var resolver = CompositeResolver.Create(ThinktectureMessageFormatterResolver.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 ObjectFactoryAttribute<string> (see section Custom Type Conversion). A complex value object has more than 1 property/field, so, deserialization (without ObjectFactory<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 ThinktectureModelBinderProvider 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 "ThinktectureModelBinderProvider" 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 ThinktectureModelBinderProvider()));
              })

OpenAPI/Swashbuckle Integration

Value Objects can be integrated with Swashbuckle to provide OpenAPI documentation for Web APIs.

Required Package Thinktecture.Runtime.Extensions.Swashbuckle

Configuration

To enable OpenAPI support for Value Objects, register the Thinktecture OpenAPI filters with dependency injection:

services.AddEndpointsApiExplorer()
        .AddSwaggerGen(options => options.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" }))
        .AddThinktectureOpenApiFilters();

You can customize the OpenAPI schema generation with options:

services.AddThinktectureOpenApiFilters(options => 
{
    // Configure the required member evaluator
    options.RequiredMemberEvaluator = RequiredMemberEvaluator.All
});

Available Options

Required member evaluator

The RequiredMemberEvaluator option determines what members are considered required in the OpenAPI schema. The available options are:

  • Default: The member is considered required, if:
    • it is a struct value object with AllowDefaultStructs equals to false (which is the default)
    • it is a non-nullable reference type.
  • All: All members are flagged as required.
  • None: Members are not flagged as required.
  • FromDependencyInjection: Resolves implementation of IRequiredMemberEvaluator from dependency injection

Support for Entity Framework Core

Optional nuget packages:
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 7, EF Core 8 or EF Core 9 and use the extension method AddThinktectureValueConverters to register the value converters for you.

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

      modelBuilder.AddThinktectureValueConverters();
   }
}

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

modelBuilder.AddThinktectureValueConverters(
             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 AddThinktectureValueConverters 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.AddThinktectureValueConverters();
      });
   }
}

You can customize the conversion behavior with additional parameters:

builder.AddThinktectureValueConverters(
    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.AddThinktectureValueConverters();
    });
});

Method overload for ComplexPropertyBuilder:

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);
        
        // Apply value converters to any Smart Enums or Value Objects within this complex type
        boundaryBuilder.AddThinktectureValueConverters();
    });
});

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

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

services
   .AddDbContext<DemoDbContext>(builder => builder
                                           .UseThinktectureValueConverters(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>
</Project>

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

Here are some examples I used in the past to show the developers the benefits of value objects.

ISBN (International Standard Book Number)

ISBN with strict validation rules.

[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');
    }
}

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 OpenEndDate which is 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",                                      // "OpenEndDate.Infinite" represent an open-ended end date
                       EqualityComparisonOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads, // for comparison with DateOnly without implicit cast
                       ComparisonOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads,
                       AllowDefaultStructs = true,
                       SkipToString = true)]
public partial struct OpenEndDate
{
   private readonly DateOnly? _date;

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

   /// <summary>
   /// Gets a value indicating whether the current date represents December 31st of any year.
   /// </summary>
   public bool IsEndOfYear => this != Infinite && Date is (_, 12, 31);

   /// <summary>
   /// Creates an open-ended date with the specified year, month and day.
   /// </summary>
   public static OpenEndDate Create(int year, int month, int day)
   {
      return Create(new DateOnly(year, month, day));
   }
   
   /// <summary>
   /// Creates an open-ended date from <see cref="DateTime"/>.
   /// </summary>
   public static OpenEndDate Create(DateTime dateTime)
   {
      return Create(dateTime.Year, dateTime.Month, dateTime.Day);
   }

   static partial void ValidateFactoryArguments(
      ref ValidationError? validationError,
      ref DateOnly date
   )
   {
      if (date == DateOnly.MinValue)
         validationError = new ValidationError("The end date cannot be DateOnly.MinValue.");
   }

   /// <summary>
   /// Adjusts the current date to the last day of the month.
   /// </summary>
   public OpenEndDate MoveToEndOfMonth()
   {
      if (this == Infinite)
         return this;

      var days = DateTime.DaysInMonth(Date.Year, Date.Month);

      return days == Date.Day
                ? this
                : Create(Date.Year, Date.Month, days);
   }

   /// <summary>
   /// Converts a nullable DateOnly to an open-ended date.
   /// </summary>
   public static explicit operator OpenEndDate(DateOnly? date) =>
      date is null ? Infinite : Create(date.Value);

   public override string ToString() =>
      this == Infinite ? "Infinite" : Date.ToString("O", CultureInfo.InvariantCulture);
}

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

// Creating OpenEndDate
var specificDate = OpenEndDate.Create(2023, 12, 31);
var infiniteDate = OpenEndDate.Infinite;
var defaultDate = default(OpenEndDate); // Same as Infinite

// Conversion from nullable DateOnly
DateOnly? nullableDate = null;
var fromNullable = (OpenEndDate)nullableDate; // Infinite

// Date manipulation
var endOfMonth = new OpenEndDate(2023, 3, 15).MoveToEndOfMonth(); // 2023-03-31

// Compare the dates
var isLater = infiniteDate > specificDate; // true
var isEqual = infiniteEndDate == defaultEndDate; // true

// String representation
Console.WriteLine(specificDate); // "2023-12-31"
Console.WriteLine(infiniteDate); // "Infinite"

Use OpenEndDate 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 OpenEndDate EndDate { get; set; }
}

// query
var today = OpenEndDate.Create(DateTime.Today);

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

Use OpenEndDate 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(OpenEndDate endDate)
   {
      if (!ModelState.IsValid)
         return BadRequest(ModelState);

      return Json(endDate);
   }

   [HttpPost("enddate")]
   public IActionResult RoundTripPost([FromBody] OpenEndDate 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}", (OpenEndDate date) => date);
routeGroup.MapPost("enddate", ([FromBody] OpenEndDate 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"

Recurring Dates (Day-Month)

A date with a day and month but no year. This is useful for representing recurring events like birthdays or anniversaries.

In this use case the year is not relevant. For use cases where the year is unknown, use Partially-Known Date.

/// <summary>
/// Represents a day and month without a year, useful for recurring dates like birthdays or anniversaries.
/// </summary>
/// <remarks>
/// Use <see cref="DateOnly"/> as the underlying type
/// * to ensure that the day and month are valid
/// * for comparison purposes
/// * for easier EF Core support
/// </remarks>
[ValueObject<DateOnly>(
   ConversionFromKeyMemberType = ConversionOperatorsGeneration.Implicit, // Cast from DateOnly to DayMonth
   ConversionToKeyMemberType = ConversionOperatorsGeneration.None,       // No cast from DayMonth to DateOnly
   SkipToString = true)]
public readonly partial struct DayMonth
{
   // Use year 2000 because it is a leap year and for correct comparisons
   private const int _REFERENCE_YEAR = 2000;

   public int Day => _value.Day;
   public int Month => _value.Month;

   public static DayMonth Create(int month, int day)
   {
      var date = CreateDate(month, day);
      return new DayMonth(date);
   }

   static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref DateOnly value)
   {
      if (value.Year != _REFERENCE_YEAR)
         value = new DateOnly(_REFERENCE_YEAR, value.Month, value.Day);
   }

   private static DateOnly CreateDate(int month, int day)
   {
      try
      {
         return new DateOnly(_REFERENCE_YEAR, month, day);
      }
      catch (Exception ex)
      {
         throw new ValidationException($"Invalid day '{day}' or month '{month}'.", ex);
      }
   }

   public override string ToString()
   {
      return _value.ToString("M");
   }
}

Usage

// Creating DayMonth instances
var birthday = DayMonth.Create(5, 15); // May 15th
var leapDay = DayMonth.Create(2, 29);  // February 29th (works because we use leap year 2000)

// Accessing components
var day = birthday.Day;     // 15
var month = birthday.Month; // 5

var date = new DateOnly(2020, 5, 15);
DayMonth dayMonthFromDate = date;
logger.Information("DayMonth from DateOnly: {DayMonth}", dayMonthFromDate); // May 15th

// Comparing dates
var sameDay = DayMonth.Create(5, 15);
logger.Information("birthday == sameDay: {IsEqual}", birthday == sameDay);                            // true
logger.Information("birthday < DayMonth.Create(6, 1): {IsBefore}", birthday < DayMonth.Create(6, 1)); // true

Usage with Entity Framework Core:

public class Product
{
   public int Id { get; set; }
   public DayMonth ScheduledDeliveryDate { get; set; }
}

leads to the following Table (SQL Server):

CREATE TABLE [Products]
(
    [Id]                    uniqueidentifier NOT NULL,
    [ScheduledDeliveryDate] date             NOT NULL,
    -- other columns...
);

Querying the database:

var deliveryDate = DayMonth.Create(1, 15);
var products = await ctx.Products
                        .Where(p => p.ScheduledDeliveryDate == deliveryDate)
                        .ToListAsync();

Generated SQL:

SELECT [p].[Id], [p].[ScheduledDeliveryDate], -- other columns
FROM [Products] AS [p]
WHERE [p].[ScheduledDeliveryDate] = @__deliveryDate_0

Period

A time period with a definite start date and an open-ended end date.

/// <summary>
/// Represents a time period with a definite start date and an open-ended end date.
/// </summary>
[ComplexValueObject]
public partial struct Period
{
   /// <summary>
   /// The definite start date of the period.
   /// </summary>
   public DateOnly From { get; }

   /// <summary>
   /// The open-ended end date of the period.
   /// </summary>
   public OpenEndDate Until { get; }

   static partial void ValidateFactoryArguments(
      ref ValidationError? validationError,
      ref DateOnly from,
      ref OpenEndDate until
   )
   {
      if (from >= until)
         validationError = new ValidationError("From must be earlier than Until");
   }

   public bool IntersectsWith(Period other)
   {
      return From <= other.Until && other.From <= Until;
   }
}

Usage:

// Creating Period instances
var startDate = new DateOnly(2023, 1, 1);
var endDate = OpenEndDate.Create(2023, 12, 31);
var period = Period.Create(startDate, endDate);

// Validation examples
try
{
   var invalidPeriod = Period.Create(
      new DateOnly(2023, 12, 31),
      OpenEndDate.Create(2023, 1, 1)
   ); // Throws ValidationException
}
catch (ValidationException ex)
{
   Console.WriteLine(ex.Message); // "From must be earlier than Until"
}

// Equality comparison
var samePeriod = Period.Create(startDate, endDate);
var areEqual = period == samePeriod; // True
logger.Information("period == samePeriod: {AreEqual}", areEqual);

// Checking if period intersects with another period
var otherPeriod = Period.Create(new DateOnly(2023, 6, 1),
                                OpenEndDate.Create(2024, 6, 1));
var intersects = period.IntersectsWith(otherPeriod); // true
logger.Information("period.IntersectsWith(otherPeriod): {Intersects}", intersects);

(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

Monetary Amount with Specific Rounding

In some cases we need to round the amount to a specific number of decimal places. The Money value object is a good candidate for this. It is always positive and rounded to 2 decimal places. The rounding strategy can be specified via a MoneyRoundingStrategy which is a SmartEnum.

/// <summary>
/// Represents a monetary amount that is always positive and rounded to 2 decimal places.
/// </summary>
/// <remarks>
/// Multiplication and division need special handling because they can lead to more than 2 decimal places.
/// In that case the developer has to decide the rounding strategy.
/// </remarks>
[ValueObject<decimal>(
   AllowDefaultStructs = true,
   DefaultInstancePropertyName = "Zero",
   MultiplyOperators = OperatorsGeneration.None,
   DivisionOperators = OperatorsGeneration.None)]
public readonly partial struct Money
   : IMultiplyOperators<Money, int, Money> // Multiplication with int don't lead to more than 2 decimal places
{
   static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref decimal value)
   {
      if (value < 0)
      {
         validationError = new ValidationError("Amount cannot be negative");
         return;
      }

      value = MoneyRoundingStrategy.Default.Round(value);
   }

   public static Money? Create(decimal? amount, MoneyRoundingStrategy roundingStrategy)
   {
      return amount is null ? null : Create(amount.Value, roundingStrategy);
   }

   public static Money Create(decimal amount, MoneyRoundingStrategy roundingStrategy)
   {
      return Create(roundingStrategy.Round(amount));
   }

   public static Money operator *(Money left, int right)
   {
      return Create(left._value * right);
   }

   public static Money operator *(int right, Money left)
   {
      return Create(left._value * right);
   }
}

[SmartEnum]
public partial class MoneyRoundingStrategy
{
   public static readonly MoneyRoundingStrategy Default = new(d => decimal.Round(d, 2));
   public static readonly MoneyRoundingStrategy Up = new(d => decimal.Round(d, 2, MidpointRounding.ToPositiveInfinity));
   public static readonly MoneyRoundingStrategy Down = new(d => decimal.Round(d, 2, MidpointRounding.ToNegativeInfinity));

   [UseDelegateFromConstructor]
   public partial decimal Round(decimal value);
}

Usage:

// Creating monetary amounts
var price = Money.Create(19.999m, MoneyRoundingStrategy.Down);   // 19.99 (rounds down)
var roundedUp = Money.Create(19.991m, MoneyRoundingStrategy.Up); // 20.00 (rounds up)
var zero = Money.Zero;                                           // 0.00

// Arithmetic operations
Money sum = price + roundedUp;        // 39.99
Money difference = roundedUp - price; // 0.01
Money doubled = price * 2;            // 39.98 (multiplication with int)
Money tripled = 3 * price;            // 59.97 (multiplication with int)

// Division or multiplication with decimal need special handling
var multiplicationResult = Money.Create(price * 1.234m); // 24.66766 => 24.67
logger.Information("[Decimal] price * 1.234m: {Result}", price * 1.234m);
logger.Information("[Money] Money.Create(price * 1.234m, MoneyRoundingStrategy.Default): {Result}", multiplicationResult);

// Comparison
logger.Information("roundedUp > price: {IsGreater}", roundedUp > price); // true

FileUrn - Composite Identifier with String Serialization

The FileUrn value object represents a file location combining a file store identifier and a store-specific URN. It is designed to be used in scenarios where you need to uniquely identify files across different storage systems. The serialization to/from string allows easy storage and transmission of the file location.

/// <summary>
/// Represents a file location combining a file store identifier and a store-specific URN.
/// </summary>
[ComplexValueObject(
   ConstructorAccessModifier = AccessModifier.Public,
   DefaultStringComparison = StringComparison.OrdinalIgnoreCase)]
[ObjectFactory<string>(UseForSerialization = SerializationFrameworks.All)] // (de)serialization to/from string
public partial class FileUrn
{
   public string FileStore { get; }
   public string Urn { get; }

   static partial void ValidateFactoryArguments(
      ref ValidationError? validationError,
      ref string fileStore,
      ref string urn)
   {
      if (string.IsNullOrWhiteSpace(fileStore))
      {
         validationError = new ValidationError("FileStore cannot be empty");
         fileStore = string.Empty;
         urn = string.Empty;
         return;
      }

      if (string.IsNullOrWhiteSpace(urn))
      {
         validationError = new ValidationError("Urn cannot be empty");
         fileStore = string.Empty;
         urn = string.Empty;
         return;
      }

      fileStore = fileStore.Trim();
      urn = urn.Trim();
   }

   /// <summary>
   /// Construction of a <see cref="FileUrn"/> from <see cref="string"/>.
   /// </summary>
   public static ValidationError? Validate(
      string? value,
      IFormatProvider? provider,
      out FileUrn? item)
   {
      if (string.IsNullOrWhiteSpace(value))
      {
         item = null;
         return null;
      }

      // Format: "fileStore:urn"
      var separatorIndex = value.IndexOf(':');

      if (separatorIndex <= 0 || separatorIndex == value.Length - 1)
      {
         item = null;
         return new ValidationError("Invalid FileUrn format. Expected 'fileStore:urn'");
      }

      var fileStore = value[..separatorIndex];
      var urn = value[(separatorIndex + 1)..];

      return Validate(fileStore, urn, out item);
   }

   /// <summary>
   /// Conversion/serialization to <see cref="string"/>.
   /// </summary>
   public string ToValue()
   {
      return $"{FileStore}:{Urn}";
   }
}

Usage:

// Creating a FileUrn from its components
var documentLocation = FileUrn.Create("blob storage", "containers/documents/contract.pdf");
var imageLocation = FileUrn.Create("local file system", "images/profile/user123.jpg");

// Parsing from string
var parsed = FileUrn.Parse("blob storage:containers/documents/contract.pdf", null);       // IParsable.Parse
logger.Information("parsed file urn: {Parsed}", parsed);                                  // { FileStore = blob storage, Urn = containers/documents/contract.pdf }
logger.Information("documentLocation == parsed: {AreEqual}", documentLocation == parsed); // true

// Validation
try
{
   var invalid = FileUrn.Parse("invalid-format", null);
}
catch (FormatException ex)
{
   logger.Information(ex.Message); // "Invalid FileUrn format. Expected 'fileStore:urn'"
}

// Serialization
var json = JsonSerializer.Serialize(documentLocation);
logger.Information("Serialized JSON: {Json}", json); // "blob storage:containers/documents/contract.pdf"

var deserialized = JsonSerializer.Deserialize<FileUrn>(json);
logger.Information("Deserialized FileUrn: {Deserialized}", deserialized); // blob storage:containers/documents/contract.pdf

Jurisdiction

Combination of value objects and union types. Jurisdictions can be a country, a federal state or a district. The Unknown type is used to represent an unknown jurisdiction.

[Union]
public abstract partial class Jurisdiction
{
   [ValueObject<string>(KeyMemberName = "IsoCode")]
   [KeyMemberEqualityComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>] // case-insensitive comparison
   [KeyMemberComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>]
   public partial class Country : Jurisdiction
   {
      static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref string isoCode)
      {
         if (string.IsNullOrWhiteSpace(isoCode))
         {
            validationError = new ValidationError("ISO code is required.");
            isoCode = string.Empty;
            return;
         }

         isoCode = isoCode.Trim();

         if (isoCode.Length != 2)
            validationError = new ValidationError("ISO code must be exactly 2 characters long.");
      }
   }

   /// <summary>
   /// Let's assume that the federal state is represented by an number.
   /// </summary>
   [ValueObject<int>(KeyMemberName = "Number")]
   public partial class FederalState : Jurisdiction;

   [ValueObject<string>(KeyMemberName = "Name")]
   [KeyMemberEqualityComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>] // case-insensitive comparison
   [KeyMemberComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>]
   public partial class District : Jurisdiction;

   /// <summary>
   /// The complex type adds appropriate equality comparison(i.e. it checks for type only).
   /// </summary>
   [ComplexValueObject]
   public partial class Unknown : Jurisdiction
   {
      public static readonly Unknown Instance = new();
   }
}

Usage:

// Creating different jurisdictions
var district = Jurisdiction.District.Create("District 42");
var country = Jurisdiction.Country.Create("DE");
var unknown = Jurisdiction.Unknown.Instance;

// Comparing jurisdictions
var district42 = Jurisdiction.District.Create("DISTRICT 42");
logger.Information("district == district42: {IsEqual}", district == district42); // true

var district43 = Jurisdiction.District.Create("District 43");
logger.Information("district == district43: {IsEqual}", district == district43); // false

logger.Information("unknown == Jurisdiction.Unknown.Instance: {IsEqual}", unknown == Jurisdiction.Unknown.Instance); // true
      
// Validation examples
try
{
   var invalidJuristiction = Jurisdiction.Country.Create("DEU"); // Throws ValidationException
}
catch (ValidationException ex)
{
   logger.Information(ex.Message); // "ISO code must be exactly 2 characters long."
}

var description = district.Switch(
   country: c => $"Country: {c}",
   federalState: s => $"Federal state: {s}",
   district: d => $"District: {d}",
   unknown: _ => "Unknown"
);

logger.Information(description);

If the Jurisdiction must be serialized as JSON then it requires a custom JSON converter. The converter needs to know the type of the object to be serialized. This can be achieved by using a Smart Enum as a discriminator (JurisdictionJsonConverter).

Clone this wiki locally