Skip to content

Value Objects Customization

Pawel Gerr edited this page Mar 2, 2026 · 2 revisions

Value Objects are highly customizable thanks to Roslyn Source Generators.

This page covers all customization options available for Simple ([ValueObject<TKey>]) and Complex ([ComplexValueObject]) Value Objects. Each section shows the relevant attribute properties and usage examples.

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 uses 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 (properties with a getter that can be set via the constructor) 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; }
}

To exclude a property from generated equality, factory methods, and other generated code, use the [IgnoreMember] attribute:

[ComplexValueObject]
public partial class MyValueObject
{
    public string Name { get; }

    [IgnoreMember]
    public string DisplayLabel { 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

Note: This section covers IComparable<T> and IComparer<T> (for ordering). Do not confuse IComparer<T> with IEqualityComparer<T> (for equality and hash codes).

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

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.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 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.)

The following example demonstrates all three steps:

// 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

Warning: Setting SkipFactoryMethods = true has wide-reaching implications beyond the factory methods themselves. The following features are also disabled:

  • TypeConverter attribute — not emitted on the type.
  • IObjectFactory<T> interface — not implemented.
  • Conversion operator from key type — not generated (it relies on the factory method internally).
  • IParsable<T> and ISpanParsable<T> — forced to skip.
  • Arithmetic operators (addition, subtraction, multiplication, division) — forced to None.
  • Serialization converters (System.Text.Json, Newtonsoft.Json, MessagePack) — not generated.

As a result, framework integration features like JSON serialization, model binding, and EF Core value conversion will not work out of the box. If you still need serialization support, you can add an [ObjectFactory<T>(UseForSerialization = ...)] attribute to provide a custom factory that the serialization converters will use instead.

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; }
}

Setting Dependencies

Several attribute settings have cascading effects on other settings. Individual sections throughout this page also note these cascades where relevant.

Setting Cascading Effect
SkipFactoryMethods = true Skips IParsable, ISpanParsable; sets arithmetic operators to None; suppresses TypeConverter, IObjectFactory<T>, key conversion operator, and serialization converters
SkipIParsable = true Skips ISpanParsable (inherits from IParsable<T>)
SkipEqualityComparison = true Sets ComparisonOperators and EqualityComparisonOperators to None
EqualityComparisonOperators = None Sets ComparisonOperators to None (comparison requires equality)
ComparisonOperators > EqualityComparisonOperators EqualityComparisonOperators coerced upward to match
EmptyStringInFactoryMethodsYieldsNull = true Sets NullInFactoryMethodsYieldsNull = true

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
{
}

// 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.

EqualityComparisonOperators can also be configured directly to control generation of equality operators (==, !=):

[ValueObject<int>(
    EqualityComparisonOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads)]
public readonly partial struct Amount
{
}

Skipping Equality Comparison

You can completely disable generation of equality comparison members using SkipEqualityComparison:

// For simple (keyed) value objects
[ValueObject<int>(SkipEqualityComparison = true)]
public readonly partial struct Amount
{
}

// For complex value objects
[ComplexValueObject(SkipEqualityComparison = true)]
public readonly partial struct DateRange
{
    public DateOnly Start { get; }
    public DateOnly End { get; }
}

When SkipEqualityComparison is set to true, the source generator will not generate:

  • Equals method overrides (both Equals(object?) and Equals(T))
  • GetHashCode method override
  • Equality operators (== and !=)
  • IEquatable<T> interface implementation
  • IEqualityOperators<T, T, bool> interface implementation

Use with caution: Setting SkipEqualityComparison to true also sets ComparisonOperators and EqualityComparisonOperators to None, effectively disabling all comparison and equality operators.

Use cases:

  • When you need custom equality logic that differs from structural equality
  • When integrating with systems that require reference equality
  • When implementing custom equality comparers that cannot be expressed with the library's attributes

Important: After setting SkipEqualityComparison = true, you are responsible for implementing custom equality members if needed.

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:

IParsable and ISpanParsable

Keyed value objects automatically implement parsing interfaces based on their key type:

IParsable: Automatically implemented when the key type implements IParsable<TKey> or is string, providing Parse and TryParse methods for string-based parsing.

ISpanParsable: Automatically implemented when the key type implements ISpanParsable<TKey>, providing zero-allocation parsing using ReadOnlySpan<char> for high-performance scenarios:

[ValueObject<int>]
public readonly partial struct CustomerId
{
}

// ISpanParsable<T> implementation
// Zero-allocation parsing for high-performance scenarios
ReadOnlySpan<char> span = "12345".AsSpan();
bool success = CustomerId.TryParse(span, null, out CustomerId? id);

// Works with numeric types (int, long, decimal, etc.)
[ValueObject<decimal>]
public readonly partial struct Amount
{
}

ReadOnlySpan<char> amountSpan = "99.99".AsSpan();
Amount? amount = Amount.Parse(amountSpan, null);

// Works with DateTime, Guid, and other ISpanParsable types
[ValueObject<DateTime>]
public readonly partial struct OrderDate
{
}

[ValueObject<Guid>]
public readonly partial struct ProductId
{
}

Supported key types for ISpanParsable: All built-in .NET types that implement ISpanParsable<T>, including:

  • Numeric types: int, long, short, byte, sbyte, uint, ulong, ushort, float, double, decimal
  • Date/Time types: DateTime, DateTimeOffset, TimeSpan, DateOnly, TimeOnly
  • Other types: Guid, Version, IPAddress, and more

Disabling parsing and formatting

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

Note: Setting SkipIParsable = true implicitly disables both IParsable<T> and ISpanParsable<T> because ISpanParsable<T> inherits from IParsable<T>.

// Disable ISpanParsable<T> only, while keeping IParsable<T>
[ValueObject<int>(SkipISpanParsable = 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

By default, struct value objects disallow default(T) and the parameterless constructor. The source generator implements IDisallowDefaultValue, and analyzer rule TTRESG047 produces a compile-time error when a violation is detected:

[ValueObject<int>]
public partial struct Amount
{
}

// TTRESG047: "The 'default' expression is not allowed for type 'Amount'"
var a = default(Amount);  // ❌ Error
Amount b = new();         // ❌ Error

Opting In with AllowDefaultStructs

Set AllowDefaultStructs = true to allow default values. When enabled, the source generator produces a static Empty property (initialized to default), similar to Guid.Empty:

[ValueObject<int>(AllowDefaultStructs = true)]
public partial struct Amount
{
}

// Usage
var zero = Amount.Empty;    // static property, equivalent to default(Amount)
Amount a = default;         // No warning

Use DefaultInstancePropertyName to rename the generated 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

The same options work for complex value object structs:

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

Constraints

AllowDefaultStructs must remain false (the default) when:

  • The key member type is a reference type — the default value would be null, which is invalid. Analyzer rule TTRESG057 enforces this.
  • Any member itself disallows default values (i.e., another struct type implementing IDisallowDefaultValue). Analyzer rule TTRESG058 enforces this.

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). For example, converting a class ProductName (reference type) to its int key (value type), which would return default if the instance is null.

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 ObjectFactoryAttribute<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 [ObjectFactory<string>] adds the interface IObjectFactory<Boundary, string>, requiring you to implement a Validate method:

[ComplexValueObject]
[ObjectFactory<string>]
public partial class Boundary
{
    public decimal Lower { get; }
    public decimal Upper { get; }

    public static ValidationError? Validate(
        string? value,
        IFormatProvider? provider,
        out Boundary? item)
    {
        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'");

        return Validate(lower, upper, out item);
    }
}

The ObjectFactoryAttribute<T> also supports framework integration flags to control how the factory is used across different frameworks:

[ComplexValueObject]
[ObjectFactory<string>(
   UseForSerialization = SerializationFrameworks.All, // JSON, MessagePack serialization
   UseForModelBinding = true,                        // ASP.NET Core model binding
   UseWithEntityFramework = true)]                   // EF Core value conversion
public partial class Boundary
{
    // ...
}

Additionally, the attribute supports HasCorrespondingConstructor (for EF Core to bypass validation on load), multiple object factories on a single type, and zero-allocation JSON with ReadOnlySpan<char> on .NET 9+. See the Object Factories page for full details.

Clone this wiki locally