-
Notifications
You must be signed in to change notification settings - Fork 5
Value Objects Customization
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
- Custom Equality Comparer
- Custom Comparer (simple value objects only)
- Custom Type for Validation Errors
- Constructor Access Modifier
- Factory Method Customization
- Setting Dependencies
- Null and Empty String Handling
- Operator Customization
- Parsing and Formatting
- Default Struct Handling
- Type Conversion
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;
}
}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.
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
{
}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; }
}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>Note: This section covers
IComparable<T>andIComparer<T>(for ordering). Do not confuseIComparer<T>withIEqualityComparer<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>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:
- Create a class implementing
IValidationError<T> - Apply
ValidationErrorAttribute<T>to your value object - 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);
}
}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; }
}The source generator creates factory methods for object creation and validation. You can customize these methods in several ways:
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; }
}Warning: Setting
SkipFactoryMethods = truehas wide-reaching implications beyond the factory methods themselves. The following features are also disabled:
-
TypeConverterattribute — 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>andISpanParsable<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; }
}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
|
Factory methods provide special handling for null and empty string values:
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 instanceFor 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 instanceValue objects support various operators and interfaces that can be customized or disabled:
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
ComparisonOperatorsaffectsEqualityComparisonOperatorsto 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
{
}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:
-
Equalsmethod overrides (bothEquals(object?)andEquals(T)) -
GetHashCodemethod override - Equality operators (
==and!=) -
IEquatable<T>interface implementation -
IEqualityOperators<T, T, bool>interface implementation
Use with caution: Setting
SkipEqualityComparisontotruealso setsComparisonOperatorsandEqualityComparisonOperatorstoNone, 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.
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)
Value objects implement several interfaces for string handling that can be customized:
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
// Disable IParsable<T> and ISpanParsable<T> implementations (affects string parsing)
[ValueObject<int>(SkipIParsable = true)]
public readonly partial struct Amount
{
}Note: Setting
SkipIParsable = trueimplicitly disables bothIParsable<T>andISpanParsable<T>becauseISpanParsable<T>inherits fromIParsable<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; }
}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(); // ❌ ErrorSet 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 warningUse 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.EmptyThe 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; }
}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.
Value objects support various conversion options:
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 aclass ProductName(reference type) to itsintkey (value type), which would returndefaultif the instance isnull.
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:
UnsafeConversionToKeyMemberTypeonly applies when converting from reference type value objects to value type key members.
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.
- Home
- Smart Enums
- Value Objects
- Discriminated Unions
- Object Factories
- Analyzer Diagnostics
- Source Generator Configuration
- Convenience methods and classes
- Migrations
- Version 7
- Version 8