-
Notifications
You must be signed in to change notification settings - Fork 1
Version 8.x.x 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
- OpenAPI/Swashbuckle Integration
- 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("[email protected]"); // 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 is not 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 is not 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 = 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 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 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 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; }
}
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 KeyMemberComparerAttribute
to specify a comparer. Use one of the predefined ComparerAccessors
or implement a new one (see below).
[ValueObject<string>]
[KeyMemberComparer<ComparerAccessors.StringOrdinal, string>]
public sealed partial class ProductName
{
}
Implement the interface IComparerAccessor<T>
to create a new custom accessor. The accessor has 1 property that returns an instance of IComparer<T>
. The generic type T
is the type of the member to compare.
public interface IComparerAccessor<in T>
{
static abstract IComparer<T> Comparer { get; }
}
Implementation of an accessor for members of type string
.
public class StringOrdinal : IComparerAccessor<string>
{
public static IComparer<string> Comparer => StringComparer.OrdinalIgnoreCase;
}
Predefined accessors in static class ComparerAccessors
:
// Predefined:
ComparerAccessors.StringOrdinal
ComparerAccessors.StringOrdinalIgnoreCase
ComparerAccessors.CurrentCulture
ComparerAccessors.CurrentCultureIgnoreCase
ComparerAccessors.InvariantCulture
ComparerAccessors.InvariantCultureIgnoreCase
ComparerAccessors.Default<T>; // e.g. ComparerAccessors.Default<string> or ComparerAccessors.Default<int>
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 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]
[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; }
}
⚠️ 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 ObjectFactory<T>
you can implement additional methods to convert a Value Object from/to type T
. This conversion can be one-way (T
-> Value Object) or two-way (T
<-> Value Object).
Conversion from a
string
allows ASP.NET Model Binding to bind both Simple and Complex Value Objects.
Applying ObjectFactoryAttribute<string>
adds the interface IObjectFactory<Boundary, string>
which forces you to implement a new Validate
method overload for string conversion:
// One-way conversion (string -> Boundary)
[ComplexValueObject]
[ObjectFactory<string>]
public partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
// Required by IObjectFactory<Boundary, string>
public static ValidationError? Validate(
string? value,
IFormatProvider? provider,
out Boundary? item)
{
// Convert strings in format "lower:upper", examples:
// "1.5:2.5" -> Boundary(Lower=1.5, Upper=2.5)
// "10:20" -> Boundary(Lower=10, Upper=20)
// "-5:5" -> Boundary(Lower=-5, Upper=5)
item = null;
if (value is null)
return null;
var parts = value.Split(":", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2)
return new ValidationError("Invalid format. Expected 'lower:upper', e.g. '1.5:2.5'");
if (!decimal.TryParse(parts[0], provider, out var lower) ||
!decimal.TryParse(parts[1], provider, out var upper))
return new ValidationError("Invalid numbers. Expected decimal values, e.g. '1.5:2.5'");
// Delegate to regular validation
return Validate(lower, upper, out item);
}
}
Two-way conversion is required if the type T
must be used for serialization/deserialization. Setting UseForSerialization = SerializationFrameworks.All
adds the interface IConvertible<string>
which requires implementing ToValue()
. The serialization frameworks (like System.Text.Json
) specified by SerializationFrameworks
will then prefer these methods over the default object serialization.
// Two-way conversion with serialization support
[ComplexValueObject]
[ObjectFactory<string>(UseForSerialization = SerializationFrameworks.All)]
public partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
// Required by IObjectFactory<Boundary, string>
public static ValidationError? Validate(string? value, ...) { ... }
// Required by IConvertible<string>
public string ToValue() => $"{Lower}:{Upper}"; // e.g. "1.5:2.5"
}
Usage examples:
var boundary1 = Boundary.Parse("1.5:2.5", null); // Success: Lower=1.5, Upper=2.5
var boundary2 = Boundary.Parse("invalid", null); // Fails: Invalid format
var boundary3 = Boundary.Parse("1.5:abc", null); // Fails: Invalid numbers
var str = boundary1.ToValue(); // "1.5:2.5"
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
ThinktectureJsonConverterFactory
withSystem.Text.Json
- Use
ThinktectureNewtonsoftJsonConverterFactory
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 ThinktectureJsonConverterFactory()));
})
An example for minimal apis:
var builder = WebApplication.CreateBuilder();
builder.Services
.ConfigureHttpJsonOptions(options => options.SerializerOptions
.Converters
.Add(new ThinktectureJsonConverterFactory()));
The code for Newtonsoft.Json
is almost identical:
var webHost = new HostBuilder()
.ConfigureServices(collection =>
{
collection.AddMvc()
.AddNewtonsoftJson(options => options.SerializerSettings
.Converters
.Add(new ThinktectureNewtonsoftJsonConverterFactory()));
})
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 ThinktectureMessageFormatterResolver
directly, the Nuget package can be installed in any project where the MessagePack options are configured.
An example of a round-trip-serialization of the value object ProductName
:
// Use "ThinktectureMessageFormatterResolver"
var resolver = CompositeResolver.Create(ThinktectureMessageFormatterResolver.Instance, StandardResolver.Instance);
var options = MessagePackSerializerOptions.Standard.WithResolver(resolver);
ProductName chocolate = ProductName.Create("Chocolate");
// Serialize to MessagePack
var bytes = MessagePackSerializer.Serialize(chocolate, options, CancellationToken.None);
// Deserialize from MessagePack
var deserializedChocolate = MessagePackSerializer.Deserialize<ProductName>(bytes, options, CancellationToken.None);
Having JSON convertible value objects is just half of the equation. If a value of a simple value object is received as a query parameter, then there is no JSON conversion in play but ASP.NET Core Model Binding. Besides model binding, i.e., conversion from query string to a value object, there is model validation as well.
ASP.NET Core Model Binding is for simple value objects only and for complex objects with
ObjectFactoryAttribute<string>
(see section Custom Type Conversion). A complex value object has more than 1 property/field, so, deserialization (withoutObjectFactory<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 ThinktectureModelBinderProvider
with ASP.NET Core. By using the custom model binder, the Nuget package can be installed in any project where ASP.NET Core is configured.
Place the "ThinktectureModelBinderProvider" before default providers, so they don't try to bind value objects.
var webHost = new HostBuilder()
.ConfigureServices(collection =>
{
collection.AddMvc(options => options.ModelBinderProviders
.Insert(0, new ThinktectureModelBinderProvider()));
})
Value Objects can be integrated with Swashbuckle to provide OpenAPI documentation for Web APIs.
To enable OpenAPI support for Value Objects, register the Thinktecture OpenAPI filters with dependency injection:
services.AddEndpointsApiExplorer()
.AddSwaggerGen(options => options.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" }))
.AddThinktectureOpenApiFilters();
You can customize the OpenAPI schema generation with options:
services.AddThinktectureOpenApiFilters(options =>
{
// Configure the required member evaluator
options.RequiredMemberEvaluator = RequiredMemberEvaluator.All
});
Required member evaluator
The RequiredMemberEvaluator
option determines what members are considered required in the OpenAPI schema. The available options are:
-
Default
: The member is considered required, if:- it is a struct value object with
AllowDefaultStructs
equals tofalse
(which is the default) - it is a non-nullable reference type.
- it is a struct value object with
-
All
: All members are flagged as required. -
None
: Members are not flagged as required. -
FromDependencyInjection
: Resolves implementation ofIRequiredMemberEvaluator
from dependency injection
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 AddThinktectureValueConverters
to register the value converters for you.
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddThinktectureValueConverters();
}
}
You can provide a delegate to adjust the configuration of Smart Enums and Value Objects.
modelBuilder.AddThinktectureValueConverters(
configureEnumsAndKeyedValueObjects: property =>
{
if (property.ClrType == typeof(ProductType))
property.SetMaxLength(20);
});
You can also configure value converters at the entity level using the AddThinktectureValueConverters
extension method on EntityTypeBuilder
. This approach allows you to apply value converters only to specific entities.
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Product>(builder =>
{
builder.HasKey(p => p.Id);
// Apply value converters to all Smart Enums and Value Objects in this entity
builder.AddThinktectureValueConverters();
});
}
}
You can customize the conversion behavior with additional parameters:
builder.AddThinktectureValueConverters(
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.AddThinktectureValueConverters();
});
});
Method overload for ComplexPropertyBuilder
:
modelBuilder.Entity<Product>(builder =>
{
builder.ComplexProperty(p => p.Boundary, boundaryBuilder =>
{
boundaryBuilder.Property(b => b.Lower).HasColumnName("Lower").HasPrecision(18, 2);
boundaryBuilder.Property(b => b.Upper).HasColumnName("Upper").HasPrecision(18, 2);
// Apply value converters to any Smart Enums or Value Objects within this complex type
boundaryBuilder.AddThinktectureValueConverters();
});
});
The other options is to use the extension method UseThinktectureValueConverters
for the DbContextOptionsBuilder
.
services
.AddDbContext<DemoDbContext>(builder => builder
.UseThinktectureValueConverters(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>
</Project>
If the logger throws an exception, for example due to insufficient file system access permissions, then the logger will try to write the exception into a temp file. You can find the file ThinktectureRuntimeExtensionsSourceGenerator.log
in the temp folder of the user the IDE/CLI is running with.
Here are some examples I used in the past to show the developers the benefits of value objects.
ISBN with strict validation rules.
[ValueObject<string>]
public partial class ISBN
{
static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref string value)
{
if (string.IsNullOrWhiteSpace(value))
{
validationError = new ValidationError("ISBN cannot be empty");
return;
}
// Remove any hyphens and whitespace
value = value.Replace("-", "").Replace(" ", "");
if (!IsValidISBN(value))
{
validationError = new ValidationError("Invalid ISBN format or checksum");
return;
}
}
private static bool IsValidISBN(string isbn)
{
// ISBN-13 validation
if (isbn.Length != 13 || !isbn.All(char.IsDigit))
return false;
var sum = 0;
for (var i = 0; i < 12; i++)
{
var digit = isbn[i] - '0';
sum += (i % 2 == 0) ? digit : digit * 3;
}
var checksum = (10 - (sum % 10)) % 10;
return checksum == (isbn[12] - '0');
}
}
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 OpenEndDate
which is a readonly struct
.
[ValueObject<DateOnly>(SkipKeyMember = true, // We implement the key member "Date" ourselves
KeyMemberName = nameof(Date), // Source Generator needs to know the name we've chosen
DefaultInstancePropertyName = "Infinite", // "OpenEndDate.Infinite" represent an open-ended end date
EqualityComparisonOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads, // for comparison with DateOnly without implicit cast
ComparisonOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads,
AllowDefaultStructs = true,
SkipToString = true)]
public partial struct OpenEndDate
{
private readonly DateOnly? _date;
// can be public as well
private DateOnly Date
{
get => _date ?? DateOnly.MaxValue;
init => _date = value;
}
/// <summary>
/// Gets a value indicating whether the current date represents December 31st of any year.
/// </summary>
public bool IsEndOfYear => this != Infinite && Date is (_, 12, 31);
/// <summary>
/// Creates an open-ended date with the specified year, month and day.
/// </summary>
public static OpenEndDate Create(int year, int month, int day)
{
return Create(new DateOnly(year, month, day));
}
/// <summary>
/// Creates an open-ended date from <see cref="DateTime"/>.
/// </summary>
public static OpenEndDate Create(DateTime dateTime)
{
return Create(dateTime.Year, dateTime.Month, dateTime.Day);
}
static partial void ValidateFactoryArguments(
ref ValidationError? validationError,
ref DateOnly date
)
{
if (date == DateOnly.MinValue)
validationError = new ValidationError("The end date cannot be DateOnly.MinValue.");
}
/// <summary>
/// Adjusts the current date to the last day of the month.
/// </summary>
public OpenEndDate MoveToEndOfMonth()
{
if (this == Infinite)
return this;
var days = DateTime.DaysInMonth(Date.Year, Date.Month);
return days == Date.Day
? this
: Create(Date.Year, Date.Month, days);
}
/// <summary>
/// Converts a nullable DateOnly to an open-ended date.
/// </summary>
public static explicit operator OpenEndDate(DateOnly? date) =>
date is null ? Infinite : Create(date.Value);
public override string ToString() =>
this == Infinite ? "Infinite" : Date.ToString("O", CultureInfo.InvariantCulture);
}
Basic usage (see also ValueObjectDemos.cs) is virtually the same as with DateOnly
or DateTime
.
// Creating OpenEndDate
var specificDate = OpenEndDate.Create(2023, 12, 31);
var infiniteDate = OpenEndDate.Infinite;
var defaultDate = default(OpenEndDate); // Same as Infinite
// Conversion from nullable DateOnly
DateOnly? nullableDate = null;
var fromNullable = (OpenEndDate)nullableDate; // Infinite
// Date manipulation
var endOfMonth = new OpenEndDate(2023, 3, 15).MoveToEndOfMonth(); // 2023-03-31
// Compare the dates
var isLater = infiniteDate > specificDate; // true
var isEqual = infiniteEndDate == defaultEndDate; // true
// String representation
Console.WriteLine(specificDate); // "2023-12-31"
Console.WriteLine(infiniteDate); // "Infinite"
Use OpenEndDate
with Entity Framework Core (see also Product.cs, EF-Demos and Support for Entity Framework Core)
Please note that
DateOnly
is not supported in EF Core 7 but will be in EF Core 8. I use the library ErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly in my EF Core 7 demos.
// Entity
public class Product
{
...
public OpenEndDate EndDate { get; set; }
}
// query
var today = OpenEndDate.Create(DateTime.Today);
var products = await ctx.Products
.Where(p => p.EndDate >= today)
.ToListAsync();
Use OpenEndDate
with ASP.NET Core controllers (see also DemoController.cs) and minimal api (see also minimal api demo).
Read the section "Support for Minimal Api Parameter Binding and ASP.NET Core Model Binding" to get more information.
// Controller
[Route("api"), ApiController]
public class DemoController : Controller
{
[HttpGet("enddate/{endDate}")]
public IActionResult RoundTripGet(OpenEndDate endDate)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
return Json(endDate);
}
[HttpPost("enddate")]
public IActionResult RoundTripPost([FromBody] OpenEndDate endDate)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
return Json(endDate);
}
}
// Minimal api
var app = builder.Build();
var routeGroup = app.MapGroup("/api");
routeGroup.MapGet("enddate/{date}", (OpenEndDate date) => date);
routeGroup.MapPost("enddate", ([FromBody] OpenEndDate date) => date);
The response is the same in both cases.
GET api/enddate/2023-04-05
and
POST api/enddate
with body "2023-04-05"
returns "2023-04-05"
A date with a day and month but no year. This is useful for representing recurring events like birthdays or anniversaries.
In this use case the year is not relevant. For use cases where the year is unknown, use Partially-Known Date.
/// <summary>
/// Represents a day and month without a year, useful for recurring dates like birthdays or anniversaries.
/// </summary>
/// <remarks>
/// Use <see cref="DateOnly"/> as the underlying type
/// * to ensure that the day and month are valid
/// * for comparison purposes
/// * for easier EF Core support
/// </remarks>
[ValueObject<DateOnly>(
ConversionFromKeyMemberType = ConversionOperatorsGeneration.Implicit, // Cast from DateOnly to DayMonth
ConversionToKeyMemberType = ConversionOperatorsGeneration.None, // No cast from DayMonth to DateOnly
SkipToString = true)]
public readonly partial struct DayMonth
{
// Use year 2000 because it is a leap year and for correct comparisons
private const int _REFERENCE_YEAR = 2000;
public int Day => _value.Day;
public int Month => _value.Month;
public static DayMonth Create(int month, int day)
{
var date = CreateDate(month, day);
return new DayMonth(date);
}
static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref DateOnly value)
{
if (value.Year != _REFERENCE_YEAR)
value = new DateOnly(_REFERENCE_YEAR, value.Month, value.Day);
}
private static DateOnly CreateDate(int month, int day)
{
try
{
return new DateOnly(_REFERENCE_YEAR, month, day);
}
catch (Exception ex)
{
throw new ValidationException($"Invalid day '{day}' or month '{month}'.", ex);
}
}
public override string ToString()
{
return _value.ToString("M");
}
}
Usage
// Creating DayMonth instances
var birthday = DayMonth.Create(5, 15); // May 15th
var leapDay = DayMonth.Create(2, 29); // February 29th (works because we use leap year 2000)
// Accessing components
var day = birthday.Day; // 15
var month = birthday.Month; // 5
var date = new DateOnly(2020, 5, 15);
DayMonth dayMonthFromDate = date;
logger.Information("DayMonth from DateOnly: {DayMonth}", dayMonthFromDate); // May 15th
// Comparing dates
var sameDay = DayMonth.Create(5, 15);
logger.Information("birthday == sameDay: {IsEqual}", birthday == sameDay); // true
logger.Information("birthday < DayMonth.Create(6, 1): {IsBefore}", birthday < DayMonth.Create(6, 1)); // true
Usage with Entity Framework Core:
public class Product
{
public int Id { get; set; }
public DayMonth ScheduledDeliveryDate { get; set; }
}
leads to the following Table (SQL Server):
CREATE TABLE [Products]
(
[Id] uniqueidentifier NOT NULL,
[ScheduledDeliveryDate] date NOT NULL,
-- other columns...
);
Querying the database:
var deliveryDate = DayMonth.Create(1, 15);
var products = await ctx.Products
.Where(p => p.ScheduledDeliveryDate == deliveryDate)
.ToListAsync();
Generated SQL:
SELECT [p].[Id], [p].[ScheduledDeliveryDate], -- other columns
FROM [Products] AS [p]
WHERE [p].[ScheduledDeliveryDate] = @__deliveryDate_0
A time period with a definite start date and an open-ended end date.
/// <summary>
/// Represents a time period with a definite start date and an open-ended end date.
/// </summary>
[ComplexValueObject]
public partial struct Period
{
/// <summary>
/// The definite start date of the period.
/// </summary>
public DateOnly From { get; }
/// <summary>
/// The open-ended end date of the period.
/// </summary>
public OpenEndDate Until { get; }
static partial void ValidateFactoryArguments(
ref ValidationError? validationError,
ref DateOnly from,
ref OpenEndDate until
)
{
if (from >= until)
validationError = new ValidationError("From must be earlier than Until");
}
public bool IntersectsWith(Period other)
{
return From <= other.Until && other.From <= Until;
}
}
Usage:
// Creating Period instances
var startDate = new DateOnly(2023, 1, 1);
var endDate = OpenEndDate.Create(2023, 12, 31);
var period = Period.Create(startDate, endDate);
// Validation examples
try
{
var invalidPeriod = Period.Create(
new DateOnly(2023, 12, 31),
OpenEndDate.Create(2023, 1, 1)
); // Throws ValidationException
}
catch (ValidationException ex)
{
Console.WriteLine(ex.Message); // "From must be earlier than Until"
}
// Equality comparison
var samePeriod = Period.Create(startDate, endDate);
var areEqual = period == samePeriod; // True
logger.Information("period == samePeriod: {AreEqual}", areEqual);
// Checking if period intersects with another period
var otherPeriod = Period.Create(new DateOnly(2023, 6, 1),
OpenEndDate.Create(2024, 6, 1));
var intersects = period.IntersectsWith(otherPeriod); // true
logger.Information("period.IntersectsWith(otherPeriod): {Intersects}", intersects);
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
In some cases we need to round the amount to a specific number of decimal places.
The Money
value object is a good candidate for this.
It is always positive and rounded to 2 decimal places.
The rounding strategy can be specified via a MoneyRoundingStrategy
which is a SmartEnum
.
/// <summary>
/// Represents a monetary amount that is always positive and rounded to 2 decimal places.
/// </summary>
/// <remarks>
/// Multiplication and division need special handling because they can lead to more than 2 decimal places.
/// In that case the developer has to decide the rounding strategy.
/// </remarks>
[ValueObject<decimal>(
AllowDefaultStructs = true,
DefaultInstancePropertyName = "Zero",
MultiplyOperators = OperatorsGeneration.None,
DivisionOperators = OperatorsGeneration.None)]
public readonly partial struct Money
: IMultiplyOperators<Money, int, Money> // Multiplication with int don't lead to more than 2 decimal places
{
static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref decimal value)
{
if (value < 0)
{
validationError = new ValidationError("Amount cannot be negative");
return;
}
value = MoneyRoundingStrategy.Default.Round(value);
}
public static Money? Create(decimal? amount, MoneyRoundingStrategy roundingStrategy)
{
return amount is null ? null : Create(amount.Value, roundingStrategy);
}
public static Money Create(decimal amount, MoneyRoundingStrategy roundingStrategy)
{
return Create(roundingStrategy.Round(amount));
}
public static Money operator *(Money left, int right)
{
return Create(left._value * right);
}
public static Money operator *(int right, Money left)
{
return Create(left._value * right);
}
}
[SmartEnum]
public partial class MoneyRoundingStrategy
{
public static readonly MoneyRoundingStrategy Default = new(d => decimal.Round(d, 2));
public static readonly MoneyRoundingStrategy Up = new(d => decimal.Round(d, 2, MidpointRounding.ToPositiveInfinity));
public static readonly MoneyRoundingStrategy Down = new(d => decimal.Round(d, 2, MidpointRounding.ToNegativeInfinity));
[UseDelegateFromConstructor]
public partial decimal Round(decimal value);
}
Usage:
// Creating monetary amounts
var price = Money.Create(19.999m, MoneyRoundingStrategy.Down); // 19.99 (rounds down)
var roundedUp = Money.Create(19.991m, MoneyRoundingStrategy.Up); // 20.00 (rounds up)
var zero = Money.Zero; // 0.00
// Arithmetic operations
Money sum = price + roundedUp; // 39.99
Money difference = roundedUp - price; // 0.01
Money doubled = price * 2; // 39.98 (multiplication with int)
Money tripled = 3 * price; // 59.97 (multiplication with int)
// Division or multiplication with decimal need special handling
var multiplicationResult = Money.Create(price * 1.234m); // 24.66766 => 24.67
logger.Information("[Decimal] price * 1.234m: {Result}", price * 1.234m);
logger.Information("[Money] Money.Create(price * 1.234m, MoneyRoundingStrategy.Default): {Result}", multiplicationResult);
// Comparison
logger.Information("roundedUp > price: {IsGreater}", roundedUp > price); // true
The FileUrn
value object represents a file location combining a file store identifier and a store-specific URN.
It is designed to be used in scenarios where you need to uniquely identify files across different storage systems.
The serialization to/from string allows easy storage and transmission of the file location.
/// <summary>
/// Represents a file location combining a file store identifier and a store-specific URN.
/// </summary>
[ComplexValueObject(
ConstructorAccessModifier = AccessModifier.Public,
DefaultStringComparison = StringComparison.OrdinalIgnoreCase)]
[ObjectFactory<string>(UseForSerialization = SerializationFrameworks.All)] // (de)serialization to/from string
public partial class FileUrn
{
public string FileStore { get; }
public string Urn { get; }
static partial void ValidateFactoryArguments(
ref ValidationError? validationError,
ref string fileStore,
ref string urn)
{
if (string.IsNullOrWhiteSpace(fileStore))
{
validationError = new ValidationError("FileStore cannot be empty");
fileStore = string.Empty;
urn = string.Empty;
return;
}
if (string.IsNullOrWhiteSpace(urn))
{
validationError = new ValidationError("Urn cannot be empty");
fileStore = string.Empty;
urn = string.Empty;
return;
}
fileStore = fileStore.Trim();
urn = urn.Trim();
}
/// <summary>
/// Construction of a <see cref="FileUrn"/> from <see cref="string"/>.
/// </summary>
public static ValidationError? Validate(
string? value,
IFormatProvider? provider,
out FileUrn? item)
{
if (string.IsNullOrWhiteSpace(value))
{
item = null;
return null;
}
// Format: "fileStore:urn"
var separatorIndex = value.IndexOf(':');
if (separatorIndex <= 0 || separatorIndex == value.Length - 1)
{
item = null;
return new ValidationError("Invalid FileUrn format. Expected 'fileStore:urn'");
}
var fileStore = value[..separatorIndex];
var urn = value[(separatorIndex + 1)..];
return Validate(fileStore, urn, out item);
}
/// <summary>
/// Conversion/serialization to <see cref="string"/>.
/// </summary>
public string ToValue()
{
return $"{FileStore}:{Urn}";
}
}
Usage:
// Creating a FileUrn from its components
var documentLocation = FileUrn.Create("blob storage", "containers/documents/contract.pdf");
var imageLocation = FileUrn.Create("local file system", "images/profile/user123.jpg");
// Parsing from string
var parsed = FileUrn.Parse("blob storage:containers/documents/contract.pdf", null); // IParsable.Parse
logger.Information("parsed file urn: {Parsed}", parsed); // { FileStore = blob storage, Urn = containers/documents/contract.pdf }
logger.Information("documentLocation == parsed: {AreEqual}", documentLocation == parsed); // true
// Validation
try
{
var invalid = FileUrn.Parse("invalid-format", null);
}
catch (FormatException ex)
{
logger.Information(ex.Message); // "Invalid FileUrn format. Expected 'fileStore:urn'"
}
// Serialization
var json = JsonSerializer.Serialize(documentLocation);
logger.Information("Serialized JSON: {Json}", json); // "blob storage:containers/documents/contract.pdf"
var deserialized = JsonSerializer.Deserialize<FileUrn>(json);
logger.Information("Deserialized FileUrn: {Deserialized}", deserialized); // blob storage:containers/documents/contract.pdf
Combination of value objects and union types.
Jurisdictions can be a country, a federal state or a district. The Unknown
type is used to represent an unknown jurisdiction.
[Union]
public abstract partial class Jurisdiction
{
[ValueObject<string>(KeyMemberName = "IsoCode")]
[KeyMemberEqualityComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>] // case-insensitive comparison
[KeyMemberComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>]
public partial class Country : Jurisdiction
{
static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref string isoCode)
{
if (string.IsNullOrWhiteSpace(isoCode))
{
validationError = new ValidationError("ISO code is required.");
isoCode = string.Empty;
return;
}
isoCode = isoCode.Trim();
if (isoCode.Length != 2)
validationError = new ValidationError("ISO code must be exactly 2 characters long.");
}
}
/// <summary>
/// Let's assume that the federal state is represented by an number.
/// </summary>
[ValueObject<int>(KeyMemberName = "Number")]
public partial class FederalState : Jurisdiction;
[ValueObject<string>(KeyMemberName = "Name")]
[KeyMemberEqualityComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>] // case-insensitive comparison
[KeyMemberComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>]
public partial class District : Jurisdiction;
/// <summary>
/// The complex type adds appropriate equality comparison(i.e. it checks for type only).
/// </summary>
[ComplexValueObject]
public partial class Unknown : Jurisdiction
{
public static readonly Unknown Instance = new();
}
}
Usage:
// Creating different jurisdictions
var district = Jurisdiction.District.Create("District 42");
var country = Jurisdiction.Country.Create("DE");
var unknown = Jurisdiction.Unknown.Instance;
// Comparing jurisdictions
var district42 = Jurisdiction.District.Create("DISTRICT 42");
logger.Information("district == district42: {IsEqual}", district == district42); // true
var district43 = Jurisdiction.District.Create("District 43");
logger.Information("district == district43: {IsEqual}", district == district43); // false
logger.Information("unknown == Jurisdiction.Unknown.Instance: {IsEqual}", unknown == Jurisdiction.Unknown.Instance); // true
// Validation examples
try
{
var invalidJuristiction = Jurisdiction.Country.Create("DEU"); // Throws ValidationException
}
catch (ValidationException ex)
{
logger.Information(ex.Message); // "ISO code must be exactly 2 characters long."
}
var description = district.Switch(
country: c => $"Country: {c}",
federalState: s => $"Federal state: {s}",
district: d => $"District: {d}",
unknown: _ => "Unknown"
);
logger.Information(description);
If the Jurisdiction
must be serialized as JSON then it requires a custom JSON converter.
The converter needs to know the type of the object to be serialized. This can be achieved by using a Smart Enum as a discriminator (JurisdictionJsonConverter).