* [Requirements](#requirements) * [Getting started](#getting-started) * [Simple value objects](#simple-value-objects) * [Complex value objects](#complex-value-objects) * [Validation](#validation) * [Validation of the factory method arguments](#validation-of-the-factory-method-arguments) * [Validation of the constructor arguments](#validation-of-the-constructor-arguments) * [Customizing](#customizing) * [Key member generation](#key-member-generation) * [Custom equality comparer](#custom-equality-comparer) * [Custom comparer (simple value objects only)](#custom-comparer) * [Custom type for validation errors](#custom-type-for-validation-errors) * [Constructor access modifier](#constructor-access-modifier) * [Renaming factory methods](#renaming-factory-methods) * [Disabling factory methods](#disabling-factory-methods) * [Null value handling](#null-value-handling) * [Empty-String handling](#empty-string-handling) * [Comparison interfaces and operators](#comparison-interfaces-and-operators) * [Arithmetic operators](#arithmetic-operators) * [Parsing and formatting](#parsing-and-formatting) * [Default struct handling](#default-struct-handling) * [Key member conversion](#key-member-conversion) * [Custom type conversion](#custom-type-conversion) * [JSON serialization](#json-serialization) * [Option 1: Make project with Value Objects depend on corresponding Nuget package](#option-1-make-project-with-value-objects-depend-on-corresponding-nuget-package) * [Option 2: Register JSON converter with JSON serializer settings](#option-2-register-json-converter-with-json-serializer-settings) * [MessagePack serialization](#messagepack-serialization) * [Option 1: Make project with Value Objects depend on Nuget package](#option-1-make-project-with-value-objects-depend-on-nuget-package) * [Option 2: Register MessagePack FormatterResolver with MessagePack serializer options](#option-2-register-messagepack-formatterresolver-with-messagepack-serializer-options) * [Support for Minimal Api Parameter Binding and ASP.NET Core Model Binding](#support-for-minimal-api-parameter-binding-and-aspnet-core-model-binding) * [Minimal Api](#minimal-api) * [ASP.NET Core MVC (Controllers)](#aspnet-core-mvc-controllers) * [OpenAPI/Swashbuckle Integration](#openapiswashbuckle-integration) * [Support for Entity Framework Core](#support-for-entity-framework-core) * [Option 1: Manual registration of the ValueConverter](#option-1-manual-registration-of-the-valueconverter) * [Option 2: Registration of the ValueConverter via extension method for `ModelBuilder`](#option-2-registration-of-the-valueconverter-via-extension-method-for-modelbuilder) * [Option 3: Registration of the ValueConverter via extension method for `DbContextOptionsBuilder`](#option-3-registration-of-the-valueconverter-via-extension-method-for-dbcontextoptionsbuilder) * [Logging](#logging) * [Real-world use cases and ideas](#real-world-use-cases-and-ideas) * [ISBN](#isbn-international-standard-book-number) * [Open-ended End Date](#open-ended-end-date) * [Recurring Dates (Day-Month)](#recurring-dates-day-month) * [Period](#period) * [(Always-positive) Amount](#always-positive-amount) * [Monetary Amount with Specific Rounding](#monetary-amount-with-specific-rounding) * [FileUrn - Composite Identifier with String Serialization](#fileurn---composite-identifier-with-string-serialization) * [Jurisdiction](#jurisdiction) 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`. ## Articles * [Value Objects: Solving Primitive Obsession in .NET](https://www.thinktecture.com/en/net/value-objects-solving-primitive-obsession-in-net/) * [Handling Complexity: Introducing Complex Value Objects in .NET](https://www.thinktecture.com/en/net/handling-complexity-introducing-complex-value-objects-in-dotnet/) ## Requirements * C# 11 (or higher) for generated code * SDK 8.0.400 (or higher) for building projects ## Getting started Required Nuget package: [![Thinktecture.Runtime.Extensions](https://img.shields.io/nuget/v/Thinktecture.Runtime.Extensions.svg?maxAge=60&label=Thinktecture.Runtime.Extensions)](https://www.nuget.org/packages/Thinktecture.Runtime.Extensions/) ### Why Value Objects? Value objects help solve several common problems in software development: 1. **Type Safety**: Prevent mixing up different concepts that share the same primitive type ```csharp // 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] public partial struct CustomerId { } [ValueObject] public partial struct OrderId { } void ProcessOrder(CustomerId customerId, OrderId orderId) { ... } ProcessOrder(orderId, customerId); // Won't compile! ``` 2. **Validation**: Ensure data consistency through built-in validation ```csharp [ValueObject] 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 ``` 3. **Immutability**: Prevent accidental modifications and ensure thread safety ```csharp [ValueObject] 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](Smart-Enums) * Complex value objects are types with 2 or more fields/properties ### Simple value objects A simple value object has 1 field/property only, i.e., it is kind of *wrapper* for another (primitive) type. The main use case is to prevent creation of values/instances which are considered invalid according to some *rules*. In DDD (domain-driven design), working with primitive types, like `string`, directly is called **primitive obsession** and should be avoided. A value object can be an immutable `class` or a `readonly struct` flagged with `ValueObjectAttribute`. 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: ```csharp // Percentage that must be between 0 and 100 [ValueObject(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(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] public partial class ISBN { static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref string value) { if (string.IsNullOrWhiteSpace(value)) { validationError = new ValidationError("ISBN cannot be empty"); return; } // Remove any hyphens and whitespace value = value.Replace("-", "").Replace(" ", ""); if (!IsValidISBN(value)) { validationError = new ValidationError("Invalid ISBN format or checksum"); return; } } private static bool IsValidISBN(string isbn) { // ISBN-13 validation if (isbn.Length != 13 || !isbn.All(char.IsDigit)) return false; var sum = 0; for (var i = 0; i < 12; i++) { var digit = isbn[i] - '0'; sum += (i % 2 == 0) ? digit : digit * 3; } var checksum = (10 - (sum % 10)) % 10; return checksum == (isbn[12] - '0'); } } ``` After implementing a value object, the source generator provides the following functionality: 1. Creation and Validation ```csharp // 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" } ``` 2. Type Conversion ```csharp // 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 ``` 3. Equality and Comparison ```csharp 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 ``` 4. String Formatting ```csharp // 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" ``` 5. Parsing (useful for ASP.NET Core binding) ```csharp // TryParse implementation bool success = Temperature.TryParse("25.5", null, out var parsedTemp); // Parse implementation (throws if invalid) var temp = Temperature.Parse("25.5", null); ``` 6. Default Values for Structs ```csharp // 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: ```csharp // TypeConverter support (used by frameworks like ASP.NET Core) TypeConverter converter = TypeDescriptor.GetConverter(typeof(Temperature)); // Convert to string var value = (string?)converter.ConvertTo(Temperature.Create(25.0m), typeof(string)); // "25.0" // Convert from string var temp = (Temperature?)converter.ConvertFrom("25.0"); // Temperature(25.0) ``` ### Complex value objects A complex value object is an immutable `class` or a `readonly struct` with a `ComplexValueObjectAttribute`. Complex value objects usually have multiple readonly fields/properties that together represent a single concept. > Although a complex value object can have 1 field/property (or no members at all), it won't get the same treatment as a simple value object. Here's an example of a complex value object with validation: ```csharp [ComplexValueObject] public partial class Boundary { public decimal Lower { get; } public decimal Upper { get; } static partial void ValidateFactoryArguments( ref ValidationError? validationError, ref decimal lower, ref decimal upper) { if (lower > upper) { validationError = new ValidationError($"Lower boundary '{lower}' must be less than or equal to upper boundary '{upper}'"); return; } // Round to 2 decimal places lower = Math.Round(lower, 2); upper = Math.Round(upper, 2); } } ``` The source generator provides the following functionality: 1. Creation and Validation ```csharp // 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'" } ``` 2. Equality Comparison ```csharp 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 ``` 3. String Representation ```csharp // ToString implementation Console.WriteLine(boundary1); // "{ Lower = 1, Upper = 2 }" ``` ## Validation Value objects provide built-in validation through the `ValidateFactoryArguments` method. This method is called when creating instances and allows you to: - Validate input values - Normalize data (e.g., trim strings, round numbers) - Return descriptive error messages ### Validation of the factory method arguments Both simple and complex value objects have a `partial` method `ValidateFactoryArguments` to implement custom validation. The method should not throw exceptions but use the `ValidationError` type. > The parameters are passed-in "by-ref" to allow normalization (like removing whitespace or rounding numbers). Here are examples of common validation patterns: ```csharp // Simple value object validation [ValueObject] public partial class ProductName { static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref string value) { // Null/empty check if (string.IsNullOrWhiteSpace(value)) { value = null!; // Disable NRT warning since we return error validationError = new ValidationError("Product name cannot be empty"); return; } // Normalization value = value.Trim(); // Length validation if (value.Length < 3) { validationError = new ValidationError("Product name must be at least 3 characters"); return; } } } // Complex value object validation [ComplexValueObject] public partial class Boundary { public decimal Lower { get; } public decimal Upper { get; } static partial void ValidateFactoryArguments( ref ValidationError? validationError, ref decimal lower, ref decimal upper) { // Range validation if (lower > upper) { validationError = new ValidationError( $"Lower boundary '{lower}' must be less than or equal to upper boundary '{upper}'"); return; } // Normalization lower = Math.Round(lower, 2); upper = Math.Round(upper, 2); } } ``` ### Validation of the constructor arguments In addition to `ValidateFactoryArguments`, there is a `ValidateConstructorArguments` method that is called by the constructor. However, this method has limitations: > ⚠️ Prefer `ValidateFactoryArguments` over `ValidateConstructorArguments`. Constructors can only throw exceptions, which leads to poor integration with frameworks like JSON serialization, ASP.NET Core model binding, and Entity Framework Core. Here's how constructor validation works: ```csharp // Simple value object constructor validation [ValueObject] public partial class ProductName { static partial void ValidateConstructorArguments(ref string value) { // Constructor validation can only throw exceptions if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Product name cannot be empty"); value = value.Trim(); } } // Complex value object constructor validation [ComplexValueObject] public partial class Boundary { public decimal Lower { get; } public decimal Upper { get; } static partial void ValidateConstructorArguments( ref decimal lower, ref decimal upper) { // Constructor validation is less flexible than factory validation if (lower > upper) throw new ArgumentException($"Lower boundary '{lower}' must be less than or equal to upper boundary '{upper}'"); } } ``` ## Customizing ### Key member generation The key member of a simple value object is generated by the source generator. Use `KeyMemberName`, `KeyMemberAccessModifier` and `KeyMemberKind` to change the generation of the key member, or set `SkipKeyMember` to `true` to provide custom implementation. Example: Let source generator generate property `public DateOnly Date { get; }` instead of field `private readonly DateOnly _value;` (Default). ```csharp [ValueObject(KeyMemberName = "Date", KeyMemberAccessModifier = AccessModifier.Public, KeyMemberKind = MemberKind.Property)] public readonly partial struct OpenEndDate { } ``` Example of custom implementation: ```csharp [ValueObject(SkipKeyMember = true, // We implement the key member "Date" ourselves KeyMemberName = nameof(Date))] // Source Generator needs to know the name we've chosen public readonly partial struct OpenEndDate { private readonly DateOnly? _date; private DateOnly Date { get => _date ?? DateOnly.MaxValue; init => _date = value; } } ``` ### Custom equality comparer By default, the source generator is using the default implementation of `Equals` and `GetHashCode` for all *assignable* properties and fields, except for `strings`. If the member is a `string`, then the source generator is using `StringComparer.OrdinalIgnoreCase`. Additionally, the analyzer will warn you if you don't provide an equality comparer for a string-based value object. #### Equality comparison of simple value objects Use `KeyMemberEqualityComparerAttribute` 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`. ```C# [ValueObject] [KeyMemberEqualityComparer] public sealed partial class ProductName { } ``` #### Equality comparison of complex value objects Use `MemberEqualityComparerAttribute` to change both, the equality comparer and the members being used for comparison and computation of the hash code. ```C# [ComplexValueObject] public sealed partial class Boundary { // The equality comparison uses `Lower` only! [MemberEqualityComparer, 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. ```C# [ComplexValueObject] public sealed partial class Boundary { [MemberEqualityComparer, decimal>] public decimal Lower { get; } [MemberEqualityComparer, decimal>] public decimal Upper { get; } } ``` For complex value objects, you can also customize the string comparison behavior using the `DefaultStringComparison` property: ```csharp [ComplexValueObject(DefaultStringComparison = StringComparison.CurrentCulture)] public partial class MyValueObject { public string Property1 { get; } public string Property2 { get; } } ``` #### Predefined and Custom Comparer-Accessors Implement the interface `IEqualityComparerAccessor` to create a new custom accessor. The accessor has 1 property that returns an instance of `IEqualityComparer`. The generic type `T` is the type of the member to compare. ```C# public interface IEqualityComparerAccessor { static abstract IEqualityComparer EqualityComparer { get; } } ``` Implementation of an accessor for members of type `string`. ```csharp public class StringOrdinal : IEqualityComparerAccessor { public static IEqualityComparer EqualityComparer => StringComparer.Ordinal; } ``` Predefined accessors in static class `ComparerAccessors`: ```C# // Predefined: ComparerAccessors.StringOrdinal ComparerAccessors.StringOrdinalIgnoreCase ComparerAccessors.CurrentCulture ComparerAccessors.CurrentCultureIgnoreCase ComparerAccessors.InvariantCulture ComparerAccessors.InvariantCultureIgnoreCase ComparerAccessors.Default; // e.g. ComparerAccessors.Default or ComparerAccessors.Default ``` ### Custom comparer A custom implementation of `IComparer` can be defined on simple value objects only. > Please note that this section is about implementation of `IComparable` and `IComparer`. Don't confuse the `IComparer` with `IEqualityComparer` 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). ```C# [ValueObject] [KeyMemberComparer] public sealed partial class ProductName { } ``` Implement the interface `IComparerAccessor` to create a new custom accessor. The accessor has 1 property that returns an instance of `IComparer`. The generic type `T` is the type of the member to compare. ```csharp public interface IComparerAccessor { static abstract IComparer Comparer { get; } } ``` Implementation of an accessor for members of type `string`. ```csharp public class StringOrdinal : IComparerAccessor { public static IComparer Comparer => StringComparer.OrdinalIgnoreCase; } ``` Predefined accessors in static class `ComparerAccessors`: ```C# // Predefined: ComparerAccessors.StringOrdinal ComparerAccessors.StringOrdinalIgnoreCase ComparerAccessors.CurrentCulture ComparerAccessors.CurrentCultureIgnoreCase ComparerAccessors.InvariantCulture ComparerAccessors.InvariantCultureIgnoreCase ComparerAccessors.Default; // e.g. ComparerAccessors.Default or ComparerAccessors.Default ``` ### Custom type for validation errors The default `ValidationError` class only carries a simple error message. For more complex validation scenarios, you can create a custom validation error type that carries additional information: 1. Create a class implementing `IValidationError` 2. Apply `ValidationErrorAttribute` to your value object 3. Use the custom error type in validation methods > ⚠️ Custom validation types must implement `ToString()` for proper framework integration (JSON serialization, error messages, etc.) Here's an example of a custom validation error with additional context: ```csharp // Custom validation error with additional information public class BoundaryValidationError : IValidationError { 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] public sealed partial class Boundary { public decimal Lower { get; } public decimal Upper { get; } static partial void ValidateFactoryArguments( ref BoundaryValidationError? validationError, ref decimal lower, ref decimal upper) { if (lower > upper) { validationError = new BoundaryValidationError( "Lower boundary must be less than upper boundary", lower, upper); return; } // Normalize values lower = Math.Round(lower, 2); upper = Math.Round(upper, 2); } } ``` ### Constructor access modifier By default, value object constructors are `private`. You can change this using the `ConstructorAccessModifier` property: > ⚠️ Consider carefully before making constructors public. Factory methods provide better validation and framework integration. ```csharp // Simple value object with public constructor [ValueObject(ConstructorAccessModifier = AccessModifier.Public)] public sealed partial class ProductName { } // Complex value object with public constructor [ComplexValueObject(ConstructorAccessModifier = AccessModifier.Public)] public sealed partial class Boundary { public decimal Lower { get; } public decimal Upper { get; } } ``` ### Factory Method Customization The source generator creates factory methods for object creation and validation. You can customize these methods in several ways: #### Renaming Factory Methods You can change the default names of factory methods (`Create` and `TryCreate`): ```csharp // Simple value object with custom factory method names [ValueObject( CreateFactoryMethodName = "Parse", TryCreateFactoryMethodName = "TryParse")] public sealed partial class ProductName { } // Complex value object with custom factory method names [ComplexValueObject( CreateFactoryMethodName = "FromRange", TryCreateFactoryMethodName = "TryFromRange")] public sealed partial class Boundary { public decimal Lower { get; } public decimal Upper { get; } } ``` #### Disabling Factory Methods > ⚠️ Disabling factory methods will prevent framework integration features like JSON serialization and model binding from working. You can disable factory method generation entirely: ```csharp // Simple value object without factory methods [ValueObject(SkipFactoryMethods = true)] public sealed partial class ProductName { } // Complex value object without factory methods [ComplexValueObject(SkipFactoryMethods = true)] public sealed partial class Boundary { public decimal Lower { get; } public decimal Upper { get; } } ``` ### Null and Empty String Handling Factory methods provide special handling for null and empty string values: #### Null Value Handling By default, factory methods reject null values. You can change this behavior: ```csharp // Allow null values to return null [ValueObject( NullInFactoryMethodsYieldsNull = true)] public sealed partial class ProductName { } // Usage var name1 = ProductName.Create(null); // Returns null var name2 = ProductName.Create("Valid"); // Returns ProductName instance ``` #### Empty String Handling For string value objects, you can configure empty string handling: ```csharp // Treat empty/whitespace strings as null [ValueObject( EmptyStringInFactoryMethodsYieldsNull = true)] public sealed partial class ProductName { public string Value { get; } } // Usage var name1 = ProductName.Create(""); // Returns null var name2 = ProductName.Create(" "); // Returns null var name3 = ProductName.Create("Valid"); // Returns ProductName instance ``` ### Operator Customization Value objects support various operators and interfaces that can be customized or disabled: #### Comparison Interfaces and Operators Control implementation of comparison interfaces (`IComparable`, `IComparable`) and operators: ```csharp // Disable IComparable/IComparable implementation [ValueObject( SkipIComparable = true)] public readonly partial struct Amount { } // Configure comparison operators (>, >=, <, <=) [ValueObject( ComparisonOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads)] public readonly partial struct Amount { } ``` > ⚠️ Setting `ComparisonOperators` affects `EqualityComparisonOperators` to ensure consistent behavior between comparison and equality operations. #### Arithmetic Operators Control implementation of arithmetic operators (`+`, `-`, `*`, `/`): ```csharp // Configure all arithmetic operators [ValueObject( // 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( AdditionOperators = OperatorsGeneration.None, // No + operator MultiplyOperators = OperatorsGeneration.None)] // No * operator public readonly partial struct Amount { } ``` Available operator modes: - `None`: Operator not generated - `Default`: Standard operators between value objects - `DefaultWithKeyTypeOverloads`: Also generates operators with key member type (e.g., `Amount + decimal`) ### Parsing and Formatting Value objects implement several interfaces for string handling that can be customized: ```csharp // Disable IParsable implementation (affects string parsing) [ValueObject(SkipIParsable = true)] public readonly partial struct Amount { } // Disable IFormattable implementation (affects custom formatting) [ValueObject(SkipIFormattable = true)] public readonly partial struct Amount { } // Disable ToString override (affects string representation) [ValueObject(SkipToString = true)] public readonly partial struct Amount { } // Can also be used with complex value objects [ComplexValueObject(SkipToString = true)] public sealed partial class Boundary { public decimal Lower { get; } public decimal Upper { get; } } ``` ### Default Struct Handling Value objects that are structs support additional customization options: ```csharp // Rename Empty property [ValueObject( AllowDefaultStructs = true, // Allow default value DefaultInstancePropertyName = "Zero")] // Changes the property name from "Empty" to "Zero" public partial struct Amount { } // Usage var zero = Amount.Zero; // Instead of Amount.Empty // Allow default value [ComplexValueObject( AllowDefaultStructs = true, // Allow default value DefaultInstancePropertyName = "Unbounded")] // Enables default(Boundary) public partial struct Boundary { public decimal Lower { get; } public decimal Upper { get; } } ``` ### Type Conversion Value objects support various conversion options: #### Key Member Conversion Simple value objects can control how they convert to and from their key member type using three properties: * `ConversionToKeyMemberType`: Controls conversion from value object to key member type (default: `Implicit`) * `ConversionFromKeyMemberType`: Controls conversion from key member type to value object (default: `Explicit`) * `UnsafeConversionToKeyMemberType`: Controls conversion from reference type value object to value type key member (default: `Explicit`) Each property can be set to: * `None`: No conversion operator is generated * `Implicit`: Generates an implicit conversion operator * `Explicit`: Generates an explicit conversion operator requiring a cast ```csharp [ValueObject( ConversionToKeyMemberType = ConversionOperatorsGeneration.Explicit, // To key type ConversionFromKeyMemberType = ConversionOperatorsGeneration.Implicit, // From key type UnsafeConversionToKeyMemberType = ConversionOperatorsGeneration.None)] // Reference to value type public partial struct Amount { } ``` > Note: `UnsafeConversionToKeyMemberType` only applies when converting from reference type value objects to value type key members. #### Custom Type Conversion With `ObjectFactory` 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` adds the interface `IObjectFactory` which forces you to implement a new `Validate` method overload for string conversion: ```csharp // One-way conversion (string -> Boundary) [ComplexValueObject] [ObjectFactory] public partial class Boundary { public decimal Lower { get; } public decimal Upper { get; } // Required by IObjectFactory 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` which requires implementing `ToValue()`. The serialization frameworks (like `System.Text.Json`) specified by `SerializationFrameworks` will then prefer these methods over the default object serialization. ```csharp // Two-way conversion with serialization support [ComplexValueObject] [ObjectFactory(UseForSerialization = SerializationFrameworks.All)] public partial class Boundary { public decimal Lower { get; } public decimal Upper { get; } // Required by IObjectFactory public static ValidationError? Validate(string? value, ...) { ... } // Required by IConvertible public string ToValue() => $"{Lower}:{Upper}"; // e.g. "1.5:2.5" } ``` Usage examples: ```csharp 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" ``` Starting with version `9.4.0`, two new options are available for `ObjectFactoryAttribute`: * `UseForModelBinding = true`: This enables the Value Object to use the type `T` for ASP.NET Core Model Binding. * `UseWithEntityFramework = true`: This enables the Smart Enum to use the type `T` for persistence with Entity Framework Core. ```csharp [ObjectFactory( UseForSerialization = SerializationFrameworks.All, UseForModelBinding = true, UseWithEntityFramework = true)] public partial class Boundary { ... } ``` ## JSON serialization Depending on the concrete JSON library you use, you need a different Nuget package: * For `System.Text.Json`: [![Thinktecture.Runtime.Extensions.Json](https://img.shields.io/nuget/v/Thinktecture.Runtime.Extensions.Json.svg?maxAge=60&label=Thinktecture.Runtime.Extensions.Json)](https://www.nuget.org/packages/Thinktecture.Runtime.Extensions.Json/) * For `Newtonsoft.Json`: [![Thinktecture.Runtime.Extensions.Newtonsoft.Json](https://img.shields.io/nuget/v/Thinktecture.Runtime.Extensions.Newtonsoft.Json.svg?maxAge=60&label=Thinktecture.Runtime.Extensions.Newtonsoft.Json)](https://www.nuget.org/packages/Thinktecture.Runtime.Extensions.Newtonsoft.Json/) There are 2 options to make the Value Objects *JSON convertible*. ### Option 1: Make project with Value Objects depend on corresponding Nuget package The easiest way is to make [Thinktecture.Runtime.Extensions.Json](https://www.nuget.org/packages/Thinktecture.Runtime.Extensions.Json) / [Thinktecture.Runtime.Extensions.Newtonsoft.Json](https://www.nuget.org/packages/Thinktecture.Runtime.Extensions.Newtonsoft.Json) a dependency of the project(s) the value objects are in. The dependency doesn't have to be a direct one but can be transitive as well. Both Nuget packages lead to generation of JSON converters and flag the value object with a `JsonConverterAttribute`. This way the value objects can be converted to and from JSON without extra code. ### Option 2: Register JSON converter with JSON serializer settings > For simple value objects only. If making previously mentioned Nuget package a dependency of project(s) with value objects is not possible or desirable, then the other option is to register a JSON converter with *JSON serializer settings*. By using a JSON converter directly, the Nuget package can be installed in any project where the *JSON settings* are configured. * Use `ThinktectureJsonConverterFactory` with `System.Text.Json` * Use `ThinktectureNewtonsoftJsonConverterFactory` with `Newtonsoft.Json` An example for ASP.NET Core application using `System.Text.Json`: ```C# var webHost = new HostBuilder() .ConfigureServices(collection => { collection.AddMvc() .AddJsonOptions(options => options.JsonSerializerOptions .Converters .Add(new ThinktectureJsonConverterFactory())); }) ``` An example for minimal apis: ```C# var builder = WebApplication.CreateBuilder(); builder.Services .ConfigureHttpJsonOptions(options => options.SerializerOptions .Converters .Add(new ThinktectureJsonConverterFactory())); ``` The code for `Newtonsoft.Json` is almost identical: ```C# var webHost = new HostBuilder() .ConfigureServices(collection => { collection.AddMvc() .AddNewtonsoftJson(options => options.SerializerSettings .Converters .Add(new ThinktectureNewtonsoftJsonConverterFactory())); }) ``` ## MessagePack serialization * Required nuget package: [![Thinktecture.Runtime.Extensions.MessagePack](https://img.shields.io/nuget/v/Thinktecture.Runtime.Extensions.MessagePack.svg?maxAge=60&label=Thinktecture.Runtime.Extensions.MessagePack)](https://www.nuget.org/packages/Thinktecture.Runtime.Extensions.MessagePack/) There are 2 options to make the value objects *MessagePack serializable*. ### Option 1: Make project with Value Objects depend on Nuget package The easiest way is to make [Thinktecture.Runtime.Extensions.MessagePack](https://www.nuget.org/packages/Thinktecture.Runtime.Extensions.MessagePack) a dependency of the project(s) the value objects are in. The dependency doesn't have to be a direct one but can be transitive as well. The Nuget package leads to generation of a **MessagePack** formatter and flags the value object with a `MessagePackFormatterAttribute`. This way the value object can be serialized to and from **MessagePack** without extra code. ### Option 2: Register MessagePack FormatterResolver with MessagePack serializer options > For simple value objects only. If making previously mentioned Nuget package a dependency of project(s) with value objects is not possible or desirable, then the other option is to register the MessagePack formatter with *MessagePack serializer options*. By using the `ThinktectureMessageFormatterResolver` directly, the Nuget package can be installed in any project where the *MessagePack options* are configured. An example of a round-trip-serialization of the value object `ProductName`: ```C# // 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(bytes, options, CancellationToken.None); ``` ## Support for Minimal Api Parameter Binding and ASP.NET Core Model Binding Required nuget package: [![Thinktecture.Runtime.Extensions.AspNetCore](https://img.shields.io/nuget/v/Thinktecture.Runtime.Extensions.AspNetCore.svg?maxAge=60&label=Thinktecture.Runtime.Extensions.AspNetCore)](https://www.nuget.org/packages/Thinktecture.Runtime.Extensions.AspNetCore/) Having *JSON convertible* value objects is just half of the equation. If a value of a simple value object is received as a query parameter, then there is no JSON conversion in play but *ASP.NET Core Model Binding*. Besides model binding, i.e., conversion from query string to a value object, there is model validation as well. > ASP.NET Core Model Binding is for simple value objects only and for complex objects with `ObjectFactoryAttribute` (see section [Custom Type Conversion](#custom-type-conversion)). A complex value object has more than 1 property/field, so, deserialization (without `ObjectFactory`) from a `string` to 2+ members is a case for JSON (de)serialization. ### Minimal Api The parameter binding of Minimal Apis in .NET 7 is still quite primitive in comparison to the model binding of MVC controllers. To make a type bindable it has to implement either `TryParse` or `BindAsync`. A simple Value Object implements `TryParse` (interface `IParsable`) by default, so it can be used with Minimal Apis without any changes. At the moment, the parameter binding (with `TryParse` and `BindAsync`) doesn't allow to pass custom validation errors to be returned to the client. The only information we can pass is an indication whether the parameter could be bound or not. ### ASP.NET Core MVC (Controllers) ASP.NET MVC gives us more control during model binding. For example, if we expect from client a `ProductName` and receive the value `A`, which is rejected by the validation, then the ASP.NET Core `ModelState` will be invalid. In this case we can reject (or let [ApiControllerAttribute](https://docs.microsoft.com/en-us/aspnet/core/web-api/?view=aspnetcore-5.0#apicontroller-attribute) reject) the request. By rejecting the request, the client gets the status code `BadRequest (400)` and the error: ```JSON { "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. ```C# var webHost = new HostBuilder() .ConfigureServices(collection => { collection.AddMvc(options => options.ModelBinderProviders .Insert(0, new ThinktectureModelBinderProvider())); }) ``` ### OpenAPI/Swashbuckle Integration Value Objects can be integrated with Swashbuckle to provide OpenAPI documentation for Web APIs. **Required Package** [![Thinktecture.Runtime.Extensions.Swashbuckle](https://img.shields.io/nuget/v/Thinktecture.Runtime.Extensions.Swashbuckle.svg?maxAge=60&label=Thinktecture.Runtime.Extensions.Swashbuckle)](https://www.nuget.org/packages/Thinktecture.Runtime.Extensions.Swashbuckle/) #### Configuration To enable OpenAPI support for Value Objects, register the Thinktecture OpenAPI filters with dependency injection: ```csharp services.AddEndpointsApiExplorer() .AddSwaggerGen(options => options.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" })) .AddThinktectureOpenApiFilters(); ``` You can customize the OpenAPI schema generation with options: ```csharp services.AddThinktectureOpenApiFilters(options => { // Configure the required member evaluator options.RequiredMemberEvaluator = RequiredMemberEvaluator.All }); ``` #### Available Options **Required member evaluator** The `RequiredMemberEvaluator` option determines what members are considered required in the OpenAPI schema. The available options are: - `Default`: The member is considered required, if: - it is a struct value object with `AllowDefaultStructs` equals to `false` (which is the default) - it is a non-nullable reference type. - `All`: All members are flagged as required. - `None`: Members are not flagged as required. - `FromDependencyInjection`: Resolves implementation of `IRequiredMemberEvaluator` from dependency injection ## Support for Entity Framework Core Optional nuget packages: [![Thinktecture.Runtime.Extensions.EntityFrameworkCore7](https://img.shields.io/nuget/v/Thinktecture.Runtime.Extensions.EntityFrameworkCore7.svg?maxAge=60&label=Thinktecture.Runtime.Extensions.EntityFrameworkCore7)](https://www.nuget.org/packages/Thinktecture.Runtime.Extensions.EntityFrameworkCore7/) [![Thinktecture.Runtime.Extensions.EntityFrameworkCore8](https://img.shields.io/nuget/v/Thinktecture.Runtime.Extensions.EntityFrameworkCore8.svg?maxAge=60&label=Thinktecture.Runtime.Extensions.EntityFrameworkCore8)](https://www.nuget.org/packages/Thinktecture.Runtime.Extensions.EntityFrameworkCore8/) [![Thinktecture.Runtime.Extensions.EntityFrameworkCore9](https://img.shields.io/nuget/v/Thinktecture.Runtime.Extensions.EntityFrameworkCore9.svg?maxAge=60&label=Thinktecture.Runtime.Extensions.EntityFrameworkCore9)](https://www.nuget.org/packages/Thinktecture.Runtime.Extensions.EntityFrameworkCore9/) Starting with Entity Framework Core 2.1 we've got the feature [Value Conversion](https://docs.microsoft.com/en-us/ef/core/modeling/value-conversions). By providing a *value converter*, EF Core can convert a simple value object (like `ProductName`) to and from a primitive type (like `string`) when persisting the data and when reading the value from database. ### Option 1: Manual registration of the ValueConverter The registration of a value converter can be done manually by using one of the method overloads of `HasConversion` in `OnModelCreating`. > This approach is not recommended because "Create" can perform some *heavy* validations which will affect performance. In general, data loaded from the database doesn't have to validated because the database is the "source of truth". Use option 2 or 3 to register a value converter which (by default) uses the constructor directly. ```C# // 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(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](https://docs.microsoft.com/en-us/ef/core/modeling/owned-entities) or [complex type](https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-8.0/whatsnew#value-objects-using-complex-types) is more suitable than pressing multiple members into 1 column. ```C# // 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(builder => { builder.ComplexProperty(p => p.Boundary, boundaryBuilder => { boundaryBuilder.Property(b => b.Lower).HasColumnName("Lower").HasPrecision(18, 2); boundaryBuilder.Property(b => b.Upper).HasColumnName("Upper").HasPrecision(18, 2); }); }); } } ``` ### Option 2: Registration of the ValueConverter via extension method for `ModelBuilder` Alternatively, you can install the appropriate Nuget package for [EF Core 7](https://www.nuget.org/packages/Thinktecture.Runtime.Extensions.EntityFrameworkCore7/), [EF Core 8](https://www.nuget.org/packages/Thinktecture.Runtime.Extensions.EntityFrameworkCore8/) or [EF Core 9](https://www.nuget.org/packages/Thinktecture.Runtime.Extensions.EntityFrameworkCore9/) and use the extension method `AddThinktectureValueConverters` to register the value converters for you. ```C# 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](Smart-Enums) and Value Objects. ```C# modelBuilder.AddThinktectureValueConverters( configureEnumsAndKeyedValueObjects: property => { if (property.ClrType == typeof(ProductType)) property.SetMaxLength(20); }); ``` ### Option 3: Registration of the ValueConverter via extension method for `EntityTypeBuilder` You can also configure value converters at the entity level using the `AddThinktectureValueConverters` extension method on `EntityTypeBuilder`. This approach allows you to apply value converters only to specific entities. ```C# public class ProductsDbContext : DbContext { protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity(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: ```C# builder.AddThinktectureValueConverters( useConstructorForRead: true, // Use constructor instead of factory method when reading addConvertersForOwnedTypes: true, // Apply to owned entities recursively configureEnumsAndKeyedValueObjects: property => { if (property.ClrType == typeof(ProductType)) property.SetMaxLength(20); }); ``` This method is also available for owned entities through the `OwnedNavigationBuilder`: ```C# modelBuilder.Entity(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`: ```C# modelBuilder.Entity(builder => { builder.ComplexProperty(p => p.Boundary, boundaryBuilder => { boundaryBuilder.Property(b => b.Lower).HasColumnName("Lower").HasPrecision(18, 2); boundaryBuilder.Property(b => b.Upper).HasColumnName("Upper").HasPrecision(18, 2); // Apply value converters to any Smart Enums or Value Objects within this complex type boundaryBuilder.AddThinktectureValueConverters(); }); }); ``` ### Option 4: Registration of the ValueConverter via extension method for `DbContextOptionsBuilder` The other options is to use the extension method `UseThinktectureValueConverters` for the `DbContextOptionsBuilder`. ```csharp services .AddDbContext(builder => builder .UseThinktectureValueConverters(configureEnumsAndKeyedValueObjects: property => { if (property.ClrType == typeof(ProductType)) property.SetMaxLength(20); }) ``` ## Logging Logging can be activated in the csproj-file. Define the property `ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath` pointing to an existing(!) folder (like `C:\temp\`). You can provide a file name (like `samples_logs.txt`) which is being used as a template for creation of a unique log file name like `samples_logs_20230322_220653_19c0d6c18ec14512a1acf97621912abb.txt`. > Please note, that there will be more than 1 log file (per project) because IDEs (Rider/VS) usually create 1 Source Generator for constant running in the background, and 1 for each build/rebuild of a project. Unless, `ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique` is set to `false`. With `ThinktectureRuntimeExtensions_SourceGenerator_LogLevel` you can specify one of the following log levels: `Trace`, `Debug`, `Information` (DEFAULT), `Warning`, `Error`. ```xml ... C:\temp\samples_logs.txt information false ``` If the logger throws an exception, for example due to insufficient file system access permissions, then the logger will try to write the exception into a temp file. You can find the file `ThinktectureRuntimeExtensionsSourceGenerator.log` in the temp folder of the user the IDE/CLI is running with. ## Real-world use cases and ideas Here are some examples I used in the past to show the developers the benefits of value objects. ### ISBN (International Standard Book Number) ISBN with strict validation rules. ```csharp [ValueObject] public partial class ISBN { static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref string value) { if (string.IsNullOrWhiteSpace(value)) { validationError = new ValidationError("ISBN cannot be empty"); return; } // Remove any hyphens and whitespace value = value.Replace("-", "").Replace(" ", ""); if (!IsValidISBN(value)) { validationError = new ValidationError("Invalid ISBN format or checksum"); return; } } private static bool IsValidISBN(string isbn) { // ISBN-13 validation if (isbn.Length != 13 || !isbn.All(char.IsDigit)) return false; var sum = 0; for (var i = 0; i < 12; i++) { var digit = isbn[i] - '0'; sum += (i % 2 == 0) ? digit : digit * 3; } var checksum = (10 - (sum % 10)) % 10; return checksum == (isbn[12] - '0'); } } ``` ### Open-ended End Date There are multiple ways to implement an *end date* with open-end. All of them have their pros and cons. Here are the most popular approaches I encountered in the past: 1) Use nullable `DateOnly?` (or `DateTime?`) * **PRO**: Better semantics, i.e. `null` means there is no end date. The default value of `DateOnly?` is `null` as well, which results in expected behavior. * **CON**: (LINQ) queries must check for both `null` and a concrete date, i.e. `query.Where(i => i.MyEndDate is null || i.MyEndDate > now)`. Using such query with a database usually results in worse performance because `||`/`OR` prevents the database from using an appropriate index. 2) Use `DateOnly.MaxValue` (or `DateTime`) * **PRO**: The condition in the (LINQ) query is straight-forward `query.Where(i => i.MyEndDate > now)`. If this query is executed on a database then the database is able to use an appropriate index which result in better performance. * **CON**: Using a *special value* like `DateOnly.MaxValue` to represent an open-ended date results in worse semantics. * **CON**: The main culprit is the keyword `default` or the default value of a `DateOnly` (or `DateTime`), which is `DateOnly.MinValue`. If the property/field/variable is not assigned explicitly and stays `DateOnly.MinValue`, then this most likely will lead to an undesired behavior. In this situation I would like to have an open-ended end date instead of the date `0001-01-01`, which is an invalid end date in the most use cases. The desired solution must: * not require `OR` in queries to improve performance * have a default value which represents open-ended end date An always-valid value object `OpenEndDate` which is a `readonly struct`. ```csharp [ValueObject(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; } /// /// Gets a value indicating whether the current date represents December 31st of any year. /// public bool IsEndOfYear => this != Infinite && Date is (_, 12, 31); /// /// Creates an open-ended date with the specified year, month and day. /// public static OpenEndDate Create(int year, int month, int day) { return Create(new DateOnly(year, month, day)); } /// /// Creates an open-ended date from . /// 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."); } /// /// Adjusts the current date to the last day of the month. /// 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); } /// /// Converts a nullable DateOnly to an open-ended date. /// 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](https://github.com/PawelGerr/Thinktecture.Runtime.Extensions/blob/master/samples/BasicSamples/ValueObjects/ValueObjectDemos.cs)) is virtually the same as with `DateOnly` or `DateTime`. ```csharp // 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](https://github.com/PawelGerr/Thinktecture.Runtime.Extensions/blob/master/samples/EntityFrameworkCore.Samples/Product.cs), [EF-Demos](https://github.com/PawelGerr/Thinktecture.Runtime.Extensions/blob/master/samples/Thinktecture.Runtime.Extensions.EntityFrameworkCore.Samples/Program.cs#L70) and [Support for Entity Framework Core](#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](https://github.com/ErikEJ/EFCore.SqlServer.DateOnlyTimeOnly) in my EF Core 7 demos. ```csharp // 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](https://github.com/PawelGerr/Thinktecture.Runtime.Extensions/blob/master/samples/AspNetCore.Samples/Controllers/DemoController.cs)) and minimal api (see also [minimal api demo](https://github.com/PawelGerr/Thinktecture.Runtime.Extensions/blob/master/samples/Thinktecture.Runtime.Extensions.AspNetCore.Samples/Program.cs#L181)). Read the section "[Support for Minimal Api Parameter Binding and ASP.NET Core Model Binding](#support-for-minimal-api-parameter-binding-and-aspnet-core-model-binding)" to get more information. ```csharp // Controller [Route("api"), ApiController] public class DemoController : Controller { [HttpGet("enddate/{endDate}")] public IActionResult RoundTripGet(OpenEndDate endDate) { if (!ModelState.IsValid) return BadRequest(ModelState); return Json(endDate); } [HttpPost("enddate")] public IActionResult RoundTripPost([FromBody] OpenEndDate endDate) { if (!ModelState.IsValid) return BadRequest(ModelState); return Json(endDate); } } // Minimal api var app = builder.Build(); var routeGroup = app.MapGroup("/api"); routeGroup.MapGet("enddate/{date}", (OpenEndDate date) => date); routeGroup.MapPost("enddate", ([FromBody] OpenEndDate date) => date); ``` The response is the same in both cases. ``` GET api/enddate/2023-04-05 and POST api/enddate with body "2023-04-05" returns "2023-04-05" ``` ### Recurring Dates (Day-Month) A date with a day and month but no year. This is useful for representing recurring events like birthdays or anniversaries. > In this use case the year is not relevant. For use cases where the year is unknown, use [Partially-Known Date](./Discriminated-Unions#partially-known-date). ```csharp /// /// Represents a day and month without a year, useful for recurring dates like birthdays or anniversaries. /// /// /// Use as the underlying type /// * to ensure that the day and month are valid /// * for comparison purposes /// * for easier EF Core support /// [ValueObject( 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 ```csharp // 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**: ```csharp public class Product { public int Id { get; set; } public DayMonth ScheduledDeliveryDate { get; set; } } ``` leads to the following Table (SQL Server): ```sql CREATE TABLE [Products] ( [Id] uniqueidentifier NOT NULL, [ScheduledDeliveryDate] date NOT NULL, -- other columns... ); ``` Querying the database: ```csharp var deliveryDate = DayMonth.Create(1, 15); var products = await ctx.Products .Where(p => p.ScheduledDeliveryDate == deliveryDate) .ToListAsync(); ``` Generated SQL: ```sql SELECT [p].[Id], [p].[ScheduledDeliveryDate], -- other columns FROM [Products] AS [p] WHERE [p].[ScheduledDeliveryDate] = @__deliveryDate_0 ``` ### Period A time period with a definite start date and an open-ended end date. ```csharp /// /// Represents a time period with a definite start date and an open-ended end date. /// [ComplexValueObject] public partial struct Period { /// /// The definite start date of the period. /// public DateOnly From { get; } /// /// The open-ended end date of the period. /// 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: ```csharp // Creating Period instances var startDate = new DateOnly(2023, 1, 1); var endDate = OpenEndDate.Create(2023, 12, 31); var period = Period.Create(startDate, endDate); // Validation examples try { var invalidPeriod = Period.Create( new DateOnly(2023, 12, 31), OpenEndDate.Create(2023, 1, 1) ); // Throws ValidationException } catch (ValidationException ex) { Console.WriteLine(ex.Message); // "From must be earlier than Until" } // Equality comparison var samePeriod = Period.Create(startDate, endDate); var areEqual = period == samePeriod; // True logger.Information("period == samePeriod: {AreEqual}", areEqual); // Checking if period intersects with another period var otherPeriod = Period.Create(new DateOnly(2023, 6, 1), OpenEndDate.Create(2024, 6, 1)); var intersects = period.IntersectsWith(otherPeriod); // true logger.Information("period.IntersectsWith(otherPeriod): {Intersects}", intersects); ``` ### (Always-positive) Amount Value objects are excellent for checking some kind of invariants. In one of my use cases I had to perform a calculation of moderate complexity and the result and all partial results must always be positive. We could use a plain `decimal` and check the (partial) result after every(!) arithmetic operation, but it requires more code and is difficult to read and to maintain. Instead, we switched from decimal to a `readonly struct Amount` which checks the invariant automatically. ```csharp [ValueObject(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`. ```csharp // get an instance of amount with Create/TryCreate/Validate or an explicit cast var amount = Amount.Create(1m); var otherAmount = (Amount)2m; var zero = Amount.Zero; // equality comparisons amount == zero; // false amount > otherAmount; // false amount > 42m; // false amount.CompareTo(otherAmount); // -1 // arithmetic operations amount + otherAmount; // 3 amount + 42m // 43 ``` ### Monetary Amount with Specific Rounding In some cases we need to round the amount to a specific number of decimal places. The `Money` value object is a good candidate for this. It is always positive and rounded to 2 decimal places. The rounding strategy can be specified via a `MoneyRoundingStrategy` which is a `SmartEnum`. ```csharp /// /// Represents a monetary amount that is always positive and rounded to 2 decimal places. /// /// /// 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. /// [ValueObject( AllowDefaultStructs = true, DefaultInstancePropertyName = "Zero", MultiplyOperators = OperatorsGeneration.None, DivisionOperators = OperatorsGeneration.None)] public readonly partial struct Money : IMultiplyOperators // 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: ```csharp // Creating monetary amounts var price = Money.Create(19.999m, MoneyRoundingStrategy.Down); // 19.99 (rounds down) var roundedUp = Money.Create(19.991m, MoneyRoundingStrategy.Up); // 20.00 (rounds up) var zero = Money.Zero; // 0.00 // Arithmetic operations Money sum = price + roundedUp; // 39.99 Money difference = roundedUp - price; // 0.01 Money doubled = price * 2; // 39.98 (multiplication with int) Money tripled = 3 * price; // 59.97 (multiplication with int) // Division or multiplication with decimal need special handling var multiplicationResult = Money.Create(price * 1.234m); // 24.66766 => 24.67 logger.Information("[Decimal] price * 1.234m: {Result}", price * 1.234m); logger.Information("[Money] Money.Create(price * 1.234m, MoneyRoundingStrategy.Default): {Result}", multiplicationResult); // Comparison logger.Information("roundedUp > price: {IsGreater}", roundedUp > price); // true ``` ### FileUrn - Composite Identifier with String Serialization The `FileUrn` value object represents a file location combining a file store identifier and a store-specific URN. It is designed to be used in scenarios where you need to uniquely identify files across different storage systems. The serialization to/from string allows easy storage and transmission of the file location. ```csharp /// /// Represents a file location combining a file store identifier and a store-specific URN. /// [ComplexValueObject( ConstructorAccessModifier = AccessModifier.Public, DefaultStringComparison = StringComparison.OrdinalIgnoreCase)] [ObjectFactory(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(); } /// /// Construction of a from . /// 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); } /// /// Conversion/serialization to . /// public string ToValue() { return $"{FileStore}:{Urn}"; } } ``` Usage: ```csharp // 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(json); logger.Information("Deserialized FileUrn: {Deserialized}", deserialized); // blob storage:containers/documents/contract.pdf ``` ### Jurisdiction Combination of value objects and union types. Jurisdictions can be a country, a federal state or a district. The `Unknown` type is used to represent an unknown jurisdiction. ```csharp [Union] public abstract partial class Jurisdiction { [ValueObject(KeyMemberName = "IsoCode")] [KeyMemberEqualityComparer] // case-insensitive comparison [KeyMemberComparer] 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."); } } /// /// Let's assume that the federal state is represented by an number. /// [ValueObject(KeyMemberName = "Number")] public partial class FederalState : Jurisdiction; [ValueObject(KeyMemberName = "Name")] [KeyMemberEqualityComparer] // case-insensitive comparison [KeyMemberComparer] public partial class District : Jurisdiction; /// /// The complex type adds appropriate equality comparison(i.e. it checks for type only). /// [ComplexValueObject] public partial class Unknown : Jurisdiction { public static readonly Unknown Instance = new(); } } ``` Usage: ```csharp // 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)](./Smart-Enums#discriminator-in-a-json-converter).