-
Notifications
You must be signed in to change notification settings - Fork 1
Value Objects
- Requirements
- Getting started
- Validation
-
Customizing
- Key member generation
- Custom equality comparer
- Custom comparer (simple value objects only)
- Custom type for validation errors
- Constructor access modifier
- Renaming factory methods
- Disabling factory methods
- Null value handling
- Empty-String handling
- Comparison interfaces and operators
- Arithmetic operators
- Parsing and formatting
- Default struct handling
- Key member conversion
- Custom type conversion
- JSON serialization
- MessagePack serialization
- Support for Minimal Api Parameter Binding and ASP.NET Core Model Binding
- Support for Entity Framework Core
- Logging
- Real-world use cases and ideas
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
.
- C# 11 (or higher) for generated code
- SDK 8.0.400 (or higher) for building projects
Value objects help solve several common problems in software development:
-
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!
-
Validation: Ensure data consistency through built-in validation
[ValueObject<string>] public partial class EmailAddress { static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref string value) { if (string.IsNullOrWhiteSpace(value)) { validationError = new ValidationError("Email cannot be empty"); return; } if (!value.Contains("@")) { validationError = new ValidationError("Invalid email format"); return; } value = value.Trim().ToLowerInvariant(); } } // Usage - validation is automatic var email = EmailAddress.Create("user@example.com"); // Success var invalid = EmailAddress.Create("not-an-email"); // Throws ValidationException
-
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
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:
- Creation and Validation
// Using Create (throws ValidationException if invalid)
var percentage = Percentage.Create(75.5m); // Success: 75.50%
var temp = Temperature.Create(-300); // Throws: below absolute zero
// Using TryCreate (returns false if invalid)
if (Percentage.TryCreate(150, out var invalidPercentage))
{
// Won't reach here - validation fails
}
// Using Validate (returns ValidationError if invalid)
var error = Temperature.Validate(-300, null, out var invalidTemp);
if (error != null)
{
Console.WriteLine(error.ToString()); // "Temperature cannot be below absolute zero"
}
- 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
- 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
- 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"
- 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);
- 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)
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:
- Creation and Validation
// Using Create (throws ValidationException if invalid)
var boundary = Boundary.Create(lower: 1.234m, upper: 2.567m); // Rounds to (1.23, 2.57)
// Using TryCreate (returns false if invalid)
if (Boundary.TryCreate(lower: 5, upper: 3, out var invalidBoundary))
{
// Won't reach here - validation fails
}
// Using Validate (returns ValidationError if invalid)
var error = Boundary.Validate(lower: 5, upper: 3, out var boundaryWithError);
if (error != null)
{
Console.WriteLine(error.ToString()); // "Lower boundary '5' must be less than or equal to upper boundary '3'"
}
- 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
- String Representation
// ToString implementation
Console.WriteLine(boundary1); // "{ Lower = 1, Upper = 2 }"
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
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);
}
}
In addition to ValidateFactoryArguments
, there is a ValidateConstructorArguments
method that is called by the constructor. However, this method has limitations:
⚠️ PreferValidateFactoryArguments
overValidateConstructorArguments
. 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}'");
}
}
The key member of a simple value object is generated by the source generator.
Use KeyMemberName
, KeyMemberAccessModifier
and KeyMemberKind
to change the generation of the key member, or set SkipKeyMember
to true
to provide custom implementation.
Example: Let source generator generate property public DateOnly Date { get; }
instead of field private readonly DateOnly _value;
(Default).
[ValueObject<DateOnly>(KeyMemberName = "Date",
KeyMemberAccessModifier = ValueObjectAccessModifier.Public,
KeyMemberKind = ValueObjectMemberKind.Property)]
public readonly partial struct EndDate
{
}
Example of custom implementation:
[ValueObject<DateOnly>(SkipKeyMember = true, // We implement the key member "Date" ourselves
KeyMemberName = "Date")] // Source Generator needs to know the name we've chosen
public readonly partial struct OpenEndDate
{
private readonly DateOnly? _date;
private DateOnly Date
{
get => _date ?? DateOnly.MaxValue;
init => _date = value;
}
}
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.
Use ValueObjectKeyMemberEqualityComparerAttribute<TComparerAccessor, TMember>
to define an equality comparer for comparison of key members and for computation of the hash code. Use one of the predefined ComparerAccessors
or implement a new one.
The example below changes the comparer from OrdinalIgnoreCase
to Ordinal
.
[ValueObject<string>]
[ValueObjectKeyMemberEqualityComparer<ComparerAccessors.StringOrdinal, string>]
public sealed partial class ProductName
{
}
Use ValueObjectMemberEqualityComparerAttribute<TComparerAccessor, TMember>
to change both, the equality comparer and the members being used for comparison and computation of the hash code.
[ComplexValueObject]
public sealed partial class Boundary
{
// The equality comparison uses `Lower` only!
[ValueObjectMemberEqualityComparer<ComparerAccessors.Default<decimal>, decimal>]
public decimal Lower { get; }
public decimal Upper { get; }
}
To use all assignable properties in comparison, either don't use ValueObjectMemberEqualityComparerAttribute
at all or put it on all members.
[ComplexValueObject]
public sealed partial class Boundary
{
[ValueObjectMemberEqualityComparer<ComparerAccessors.Default<decimal>, decimal>]
public decimal Lower { get; }
[ValueObjectMemberEqualityComparer<ComparerAccessors.Default<decimal>, decimal>]
public decimal Upper { get; }
}
For complex value objects, you can also customize the string comparison behavior using the DefaultStringComparison
property:
[ComplexValueObject(DefaultStringComparison = StringComparison.CurrentCulture)]
public partial class MyValueObject
{
public string Property1 { get; }
public string Property2 { get; }
}
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>
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>
andIComparer<T>
. Don't confuse theIComparer<T>
withIEqualityComparer<T>
which is being used for equality comparison and the computation of the hash code.
Use ValueObjectKeyMemberComparerAttribute
to specify a comparer. Use one of the predefined ComparerAccessors
or implement a new one (see below).
[ValueObject<string>]
[ValueObjectKeyMemberComparer<ComparerAccessors.StringOrdinal, string>]
public sealed partial class ProductName
{
}
Implement the interface IComparerAccessor<T>
to create a new custom accessor. The accessor has 1 property that returns an instance of IComparer<T>
. The generic type T
is the type of the member to compare.
public interface IComparerAccessor<in T>
{
static abstract IComparer<T> Comparer { get; }
}
Implementation of an accessor for members of type string
.
public class StringOrdinal : IComparerAccessor<string>
{
public static IComparer<string> Comparer => StringComparer.OrdinalIgnoreCase;
}
Predefined accessors in static class ComparerAccessors
:
// Predefined:
ComparerAccessors.StringOrdinal
ComparerAccessors.StringOrdinalIgnoreCase
ComparerAccessors.CurrentCulture
ComparerAccessors.CurrentCultureIgnoreCase
ComparerAccessors.InvariantCulture
ComparerAccessors.InvariantCultureIgnoreCase
ComparerAccessors.Default<T>; // e.g. ComparerAccessors.Default<string> or ComparerAccessors.Default<int>
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
ValueObjectValidationErrorAttribute<T>
to your value object - Use the custom error type in validation methods
⚠️ Custom validation types must implementToString()
for proper framework integration (JSON serialization, error messages, etc.)
Here's an example of a custom validation error with additional context:
// Custom validation error with additional information
public class BoundaryValidationError : IValidationError<BoundaryValidationError>
{
public string Message { get; }
public decimal? Lower { get; }
public decimal? Upper { get; }
// Constructor for custom validation scenarios
public BoundaryValidationError(
string message,
decimal? lower,
decimal? upper)
{
Message = message;
Lower = lower;
Upper = upper;
}
// Required factory method for generated code
public static BoundaryValidationError Create(string message)
{
return new BoundaryValidationError(message, null, null);
}
// Required for framework integration
public override string ToString()
{
return $"{Message} (Lower={Lower}, Upper={Upper})";
}
}
// Using custom validation error
[ComplexValueObject]
[ValueObjectValidationError<BoundaryValidationError>]
public sealed partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
static partial void ValidateFactoryArguments(
ref BoundaryValidationError? validationError,
ref decimal lower,
ref decimal upper)
{
if (lower > upper)
{
validationError = new BoundaryValidationError(
"Lower boundary must be less than upper boundary",
lower,
upper);
return;
}
// Normalize values
lower = Math.Round(lower, 2);
upper = Math.Round(upper, 2);
}
}
By default, value object constructors are private
. You can change this using the ConstructorAccessModifier
property:
⚠️ Consider carefully before making constructors public. Factory methods provide better validation and framework integration.
// Simple value object with public constructor
[ValueObject<string>(ConstructorAccessModifier = ValueObjectAccessModifier.Public)]
public sealed partial class ProductName
{
}
// Complex value object with public constructor
[ComplexValueObject(ConstructorAccessModifier = ValueObjectAccessModifier.Public)]
public sealed partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
}
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; }
}
⚠️ 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; }
}
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 instance
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
Value 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
{
}
⚠️ SettingComparisonOperators
affectsEqualityComparisonOperators
to ensure consistent behavior between comparison and equality operations.
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:
// 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; }
}
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; }
}
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
)
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.
With ValueObjectFactory<T>
you can implement additional methods to convert a Value Object from/to type T
. This conversion can be one-way (T
-> Value Object) or two-way (T
<-> Value Object).
Conversion from a
string
allows ASP.NET Model Binding to bind both Simple and Complex Value Objects.
Applying ValueObjectFactoryAttribute<string>
adds the interface IValueObjectFactory<Boundary, string>
which forces you to implement a new Validate
method overload for string conversion:
// One-way conversion (string -> Boundary)
[ComplexValueObject]
[ValueObjectFactory<string>]
public partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
// Required by IValueObjectFactory<Boundary, string>
public static ValidationError? Validate(
string? value,
IFormatProvider? provider,
out Boundary? item)
{
// Convert strings in format "lower:upper", examples:
// "1.5:2.5" -> Boundary(Lower=1.5, Upper=2.5)
// "10:20" -> Boundary(Lower=10, Upper=20)
// "-5:5" -> Boundary(Lower=-5, Upper=5)
item = null;
if (value is null)
return null;
var parts = value.Split(":", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2)
return new ValidationError("Invalid format. Expected 'lower:upper', e.g. '1.5:2.5'");
if (!decimal.TryParse(parts[0], provider, out var lower) ||
!decimal.TryParse(parts[1], provider, out var upper))
return new ValidationError("Invalid numbers. Expected decimal values, e.g. '1.5:2.5'");
// Delegate to regular validation
return Validate(lower, upper, out item);
}
}
Two-way conversion is required if the type T
must be used for serialization/deserialization. Setting UseForSerialization = SerializationFrameworks.All
adds the interface IValueObjectConvertable<string>
which requires implementing ToValue()
. The serialization frameworks (like System.Text.Json
) specified by SerializationFrameworks
will then prefer these methods over the default object serialization.
// Two-way conversion with serialization support
[ComplexValueObject]
[ValueObjectFactory<string>(UseForSerialization = SerializationFrameworks.All)]
public partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
// Required by IValueObjectFactory<Boundary, string>
public static ValidationError? Validate(string? value, ...) { ... }
// Required by IValueObjectConvertable<string>
public string ToValue() => $"{Lower}:{Upper}"; // e.g. "1.5:2.5"
}
Usage examples:
var boundary1 = Boundary.Parse("1.5:2.5", null); // Success: Lower=1.5, Upper=2.5
var boundary2 = Boundary.Parse("invalid", null); // Fails: Invalid format
var boundary3 = Boundary.Parse("1.5:abc", null); // Fails: Invalid numbers
var str = boundary1.ToValue(); // "1.5:2.5"
Depending on the concrete JSON library you use, you need a different Nuget package:
There are 2 options to make the Value Objects JSON convertible.
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.
For simple value objects only.
If making previously mentioned Nuget package a dependency of project(s) with value objects is not possible or desirable, then the other option is to register a JSON converter with JSON serializer settings. By using a JSON converter directly, the Nuget package can be installed in any project where the JSON settings are configured.
- Use
ValueObjectJsonConverterFactory
withSystem.Text.Json
- Use
ValueObjectNewtonsoftJsonConverter
withNewtonsoft.Json
An example for ASP.NET Core application using System.Text.Json
:
var webHost = new HostBuilder()
.ConfigureServices(collection =>
{
collection.AddMvc()
.AddJsonOptions(options => options.JsonSerializerOptions
.Converters
.Add(new ValueObjectJsonConverterFactory()));
})
An example for minimal apis:
var builder = WebApplication.CreateBuilder();
builder.Services
.ConfigureHttpJsonOptions(options => options.SerializerOptions
.Converters
.Add(new ValueObjectJsonConverterFactory()));
The code for Newtonsoft.Json
is almost identical:
var webHost = new HostBuilder()
.ConfigureServices(collection =>
{
collection.AddMvc()
.AddNewtonsoftJson(options => options.SerializerSettings
.Converters
.Add(new ValueObjectNewtonsoftJsonConverter()));
})
There are 2 options to make the value objects MessagePack serializable.
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.
For simple value objects only.
If making previously mentioned Nuget package a dependency of project(s) with value objects is not possible or desirable, then the other option is to register the MessagePack formatter with MessagePack serializer options. By using the ValueObjectMessageFormatterResolver
directly, the Nuget package can be installed in any project where the MessagePack options are configured.
An example of a round-trip-serialization of the value object ProductName
:
// Use "ValueObjectMessageFormatterResolver.Instance"
var resolver = CompositeResolver.Create(ValueObjectMessageFormatterResolver.Instance, StandardResolver.Instance);
var options = MessagePackSerializerOptions.Standard.WithResolver(resolver);
ProductName chocolate = ProductName.Create("Chocolate");
// Serialize to MessagePack
var bytes = MessagePackSerializer.Serialize(chocolate, options, CancellationToken.None);
// Deserialize from MessagePack
var deserializedChocolate = MessagePackSerializer.Deserialize<ProductName>(bytes, options, CancellationToken.None);
Having JSON convertible value objects is just half of the equation. If a value of a simple value object is received as a query parameter, then there is no JSON conversion in play but ASP.NET Core Model Binding. Besides model binding, i.e., conversion from query string to a value object, there is model validation as well.
ASP.NET Core Model Binding is for simple value objects only and for complex objects with
ValueObjectFactoryAttribute<string>
(see section Custom Type Conversion). A complex value object has more than 1 property/field, so, deserialization (withoutValueObjectFactoryAttribute<string>
) from astring
to 2+ members is a case for JSON (de)serialization.
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 MVC gives us more control during model binding. For example, if we expect from client a ProductName
and receive the value A
, which is rejected by the validation, then the ASP.NET Core ModelState
will be invalid. In this case we can reject (or let ApiControllerAttribute reject) the request.
By rejecting the request, the client gets the status code BadRequest (400)
and the error:
{
"productName": [
"Product name cannot be 1 character long."
]
}
To help out the Model Binding we have to register the ValueObjectModelBinderProvider
with ASP.NET Core. By using the custom model binder, the Nuget package can be installed in any project where ASP.NET Core is configured.
Place the "ValueObjectModelBinderProvider" before default providers, so they don't try to bind value objects.
var webHost = new HostBuilder()
.ConfigureServices(collection =>
{
collection.AddMvc(options => options.ModelBinderProviders
.Insert(0, new ValueObjectModelBinderProvider()));
})
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.
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);
});
});
}
}
Alternatively, you can install the appropriate Nuget package for EF Core 6, EF Core 7, EF Core 8 or EF Core 9 and use the extension method AddValueObjectConverters
to register the value converters for you.
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddValueObjectConverters();
}
}
You can provide a delegate to adjust the configuration of Smart Enums and Value Objects.
modelBuilder.AddValueObjectConverters(
configureEnumsAndKeyedValueObjects: property =>
{
if (property.ClrType == typeof(ProductType))
property.SetMaxLength(20);
});
You can also configure value converters at the entity level using the AddValueObjectConverters
extension method on EntityTypeBuilder
. This approach allows you to apply value converters only to specific entities.
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Product>(builder =>
{
builder.HasKey(p => p.Id);
// Apply value converters to all Smart Enums and Value Objects in this entity
builder.AddValueObjectConverters();
});
}
}
You can customize the conversion behavior with additional parameters:
builder.AddValueObjectConverters(
validateOnWrite: true, // Validate values before writing to database (validatable Smart Enum only)
useConstructorForRead: true, // Use constructor instead of factory method when reading
addConvertersForOwnedTypes: true, // Apply to owned entities recursively
configureEnumsAndKeyedValueObjects: property =>
{
if (property.ClrType == typeof(ProductType))
property.SetMaxLength(20);
});
This method is also available for owned entities through the OwnedNavigationBuilder
:
modelBuilder.Entity<Order>(builder =>
{
builder.OwnsOne(o => o.ShippingAddress, addressBuilder =>
{
// Apply value converters to all Smart Enums and Value Objects in this owned entity
addressBuilder.AddValueObjectConverters();
});
});
The other options is to use the extension method UseValueObjectValueConverter
for the DbContextOptionsBuilder
.
services
.AddDbContext<DemoDbContext>(builder => builder
.UseValueObjectValueConverter(validateOnWrite: true,
configureEnumsAndKeyedValueObjects: property =>
{
if (property.ClrType == typeof(ProductType))
property.SetMaxLength(20);
})
Logging 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 tofalse
.
With ThinktectureRuntimeExtensions_SourceGenerator_LogLevel
you can specify one of the following log levels: Trace
, Debug
, Information
(DEFAULT), Warning
, Error
.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
...
<ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath>C:\temp\samples_logs.txt</ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath>
<ThinktectureRuntimeExtensions_SourceGenerator_LogLevel>information</ThinktectureRuntimeExtensions_SourceGenerator_LogLevel>
<ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique>false</ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique>
</PropertyGroup>
If the logger throws an exception, for example due to insufficient file system access permissions, then the logger will try to write the exception into a temp file. You can find the file ThinktectureRuntimeExtensionsSourceGenerator.log
in the temp folder of the user the IDE/CLI is running with.
I started to write down some examples I used in the past to show the developers the benefits of value objects and smart enums.
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:
-
Use nullable
DateOnly?
(orDateTime?
)-
PRO: Better semantics, i.e.
null
means there is no end date. The default value ofDateOnly?
isnull
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.
-
PRO: Better semantics, i.e.
-
Use
DateOnly.MaxValue
(orDateTime
)-
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 aDateOnly
(orDateTime
), which isDateOnly.MinValue
. If the property/field/variable is not assigned explicitly and staysDateOnly.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 date0001-01-01
, which is an invalid end date in the most use cases.
-
PRO: The condition in the (LINQ) query is straight-forward
The desired solution must:
- not require
OR
in queries to improve performance - have a default value which represents open-ended end date
An always-valid value object EndDate
which is a readonly struct
.
[ValueObject<DateOnly>(SkipKeyMember = true, // We implement the key member "Date" ourselves
KeyMemberName = "Date", // Source Generator needs to know the name we've chosen
DefaultInstancePropertyName = "Infinite", // "EndDate.Infinite" represent an open-ended end date
EqualityComparisonOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads)] // for comparison with DateOnly without implicit cast
public readonly partial struct EndDate
{
private readonly DateOnly? _date;
// can be public as well
private DateOnly Date
{
get => _date ?? DateOnly.MaxValue;
init => _date = value;
}
// Further validation
// static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref DateOnly date)
// {
// validationError = date.Year switch
// {
// < 2000 => new ValidationError("The end date lies too far in the past."),
// >= 2050 => new ValidationError("The end date lies too far in the future."),
// _ => validationError
// };
// }
}
Basic usage (see also ValueObjectDemos.cs) is virtually the same as with DateOnly
or DateTime
.
// Create an EndDate
DateOnly today = DateOnly.FromDateTime(DateTime.Now);
EndDate endDate = (EndDate)today;
EndDate endDate = EndDate.Create(today); // alternative
// Compare the dates
var isTrue = EndDate.Infinite > endDate;
// Default value is equal to infinite date and equal to "DateOnly.MaxValue"
var defaultEndDate = default(EndDate);
var infiniteEndDate = EndDate.Infinite;
isTrue = infiniteEndDate == defaultEndDate;
// Get the actual date if needed
DateOnly dateOfDefaultDate = defaultEndDate;
DateOnly dateOfInfiniteDate = infiniteEndDate;
isTrue = dateOfDefaultDate == dateOfInfiniteDate;
// Compare DateOnly with EndDate
isTrue = EndDate.Infinite == dateOfDefaultDate
Use EndDate
with Entity Framework Core (see also Product.cs, EF-Demos and Support for Entity Framework Core)
Please note that
DateOnly
is not supported in EF Core 7 but will be in EF Core 8. I use the library ErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly in my EF Core 7 demos.
// Entity
public class Product
{
...
public EndDate EndDate { get; set; }
}
// query
var today = (EndDate)DateOnly.FromDateTime(DateTime.Today);
var products = await ctx.Products
.Where(p => p.EndDate >= today)
.ToListAsync();
Use EndDate
with ASP.NET Core controllers (see also DemoController.cs) and minimal api (see also minimal api demo).
Read the section "Support for Minimal Api Parameter Binding and ASP.NET Core Model Binding" to get more information.
// Controller
[Route("api"), ApiController]
public class DemoController : Controller
{
[HttpGet("enddate/{endDate}")]
public IActionResult RoundTripGet(EndDate endDate)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
return Json(endDate);
}
[HttpPost("enddate")]
public IActionResult RoundTripPost([FromBody] EndDate endDate)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
return Json(endDate);
}
}
// Minimal api
var app = builder.Build();
var routeGroup = app.MapGroup("/api");
routeGroup.MapGet("enddate/{date}", (EndDate date) => date);
routeGroup.MapPost("enddate", ([FromBody] EndDate date) => date);
The response is the same in both cases.
GET api/enddate/2023-04-05
and
POST api/enddate
with body "2023-04-05"
returns "2023-04-05"
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