-
Notifications
You must be signed in to change notification settings - Fork 1
Version 7.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
- Rename factory methods
- Skip factory methods generation
- Null in factory methods yields null
- Empty-String in factory methods yields null
- Skip implementation of
IComparable
/IComparable<T>
- Implementation of addition operators
- Implementation of subtraction operators
- Implementation of multiply operators
- Implementation of division operators
- Implementation of comparison operators
- Skip implementation of
IParsable<T>
- Skip implementation of
IFormattable
- Changing the name of static property
Empty
- Convert from/to any specific type
- JSON serialization
- MessagePack serialization
- Support for Minimal Api Parameter Binding and ASP.NET Core Model Binding
- Support for Entity Framework Core
- Logging
- Real-world use cases and ideas
This library provides an easy way for implementation of simple and complex Value Objects. The library comes with some Roslyn Analyzers and Code Fixes to guide the software developers through the implementation. Furthermore, additional Nuget packages add support for System.Text.Json
, Newtonsoft.Json
, MessagePack
, Entity Framework Core
and ASP.NET Core Model Binding
.
- C# 11 (or higher) for generated code
- SDK 7.0.401 (or higher) for building projects
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 property or field of type T of a simple value object will be called the key member from now on.
Most simple value objects with a key member of type string
and another one (which is a struct
) with an int
.
[ValueObject<string>]
public sealed partial class ProductName
{
}
[ValueObject<int>]
public readonly partial struct Amount
{
}
After the implementation of the ProductName
, a Roslyn source generator kicks in and implements the rest. Following API is available from now on.
// Factory method for creation of new instances.
// Throws ValidationException if the validation fails (if we had any)
ProductName apple = ProductName.Create("Apple");
// Alternatively, using an explicit cast, which behaves the same way as calling "ProductName.Create"
ProductName apple = (ProductName)"Apple";
-----------
// The same as above but returns a bool instead of throwing an exception (dictionary-style)
bool created = ProductName.TryCreate("Chocolate", out ProductName? chocolate);
-----------
// Similar to TryCreate but returns a ValidationError instead of a boolean.
ValidationError? validationError = ProductName.Validate("Chocolate", null, out var chocolate);
if (validationError is null)
{
logger.Information("Product name {Name} created", chocolate);
}
else
{
logger.Warning("Failed to create product name. Validation result: {validationError}", validationError.ToString());
}
-----------
// Implicit conversion to the type of the key member
string valueOfTheProductName = apple; // "Apple"
-----------
// Equality comparison compares the key member using default comparer by default.
// Key members of type `string` are compared with 'StringComparer.OrdinalIgnoreCase' by default.
bool equal = apple.Equals(apple);
-----------
// Equality comparison operators: '==' and '!='
bool equal = apple == apple;
bool notEqual = apple != apple;
-----------
// Hash code: combined hash code of type and key member.
// Strings are using 'StringComparer.OrdinalIgnoreCase' by default.
int hashCode = apple.GetHashCode();
-----------
// 'ToString' implementation return the string representation of the key member
string value = apple.ToString(); // "Apple"
------------
// Implements IParsable<T> which is especially helpful with minimal apis.
bool success = ProductName.TryParse("New product name", null, out var productName);
ProductName productName = ProductName.Parse("New product name", null);
------------
// Implements "IFormattable" if the key member is an "IFormattable".
Amount amount = Amount.Create(42);
string formattedValue = amount.ToString("000", CultureInfo.InvariantCulture); // "042"
------------
// Implements "IComparable<ProductName>" if the key member is an "IComparable",
// or if custom comparer is provided.
Amount amount = Amount.Create(1);
Amount otherAmount = Amount.Create(2);
int comparison = amount.CompareTo(otherAmount); // -1
------------
// Implements comparison operators (<,<=,>,>=) if the key member has comparison operators itself.
bool isBigger = amount > otherAmount;
// Implements comparison operators to compare the value object with an instance of key-member-type,
// if "ComparisonOperators" is set "OperatorsGeneration.DefaultWithKeyTypeOverloads"
bool isBigger = amount > 2;
------------
// Implements addition / subtraction / multiplication / division if the key member supports corresponding operators
Amount sum = amount + otherAmount;
// Implements operators that accept an instance of key-member-type,
// if the "OperatorsGeneration" is set "DefaultWithKeyTypeOverloads"
Amount sum = amount + 2;
------------
// Provides a static default value "Empty" (similar to "Guid.Empty"), if the value object is a struct
Amount defaultValue = Amount.Empty; // same as "Amount defaultValue = default;"
Additionally, the source generator implements a TypeConverter
which is used by some libraries/frameworks like JSON serialization or ASP.NET Core Model Binding.
TypeConverter typeConverter = TypeDescriptor.GetConverter(typeof(ProductName));
string value = (string)typeConverter.ConvertTo(apple, typeof(string)); // "Apple"
ProductName productName = (ProductName)typeConverter.ConvertFrom("Apple");
A complex value object is an immutable class
or a readonly struct
with a ComplexValueObjectAttribute
. Complex value object usually has multiple readonly fields/properties.
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.
A simple example would be a Boundary
with 2 properties. One property is the lower boundary and the other is the upper boundary. Yet again, we skip the validation at the moment.
[ComplexValueObject]
public sealed partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
}
The rest is implemented by a Roslyn source generator, providing the following API:
// Factory method for creation of new instances.
// Throws ValidationException if the validation fails (if we had any)
Boundary boundary = Boundary.Create(lower: 1, upper: 2);
-----------
// the same as above but returns a bool instead of throwing an exception (dictionary-style)
bool created = Boundary.TryCreate(lower: 1, upper: 2, out Boundary? boundary);
-----------
// similar to TryCreate but returns a ValidationError instead of a boolean.
ValidationError? validationError = Boundary.Validate(lower: 1, upper: 2, out Boundary? boundary);
if (validationError is null)
{
logger.Information("Boundary {Boundary} created", boundary);
}
else
{
logger.Warning("Failed to create boundary. Validation result: {validationError}", validationError.ToString());
}
-----------
// Equality comparison compares the members using default or custom comparers.
// Strings are compared with 'StringComparer.OrdinalIgnoreCase' by default.
bool equal = boundary.Equals(boundary);
-----------
// Equality comparison with '==' and '!='
bool equal = boundary == boundary;
bool notEqual = boundary != boundary;
-----------
// Hash code of the members according default or custom comparers
int hashCode = boundary.GetHashCode();
-----------
// 'ToString' implementation
string value = boundary.ToString(); // "{ Lower = 1, Upper = 2 }"
Until now, the value objects were more or less simple classes without any validation.
Both, the simple and complex value objects have a partial
method ValidateFactoryArguments
to implement custom validation in. The implementation of ValidateFactoryArguments
should not throw exceptions but use the ValidationError
.
The parameters are passed-in "by-ref" in order to adjust them (like removing leading/trailing whitespaces).
[ValueObject<string>]
public sealed partial class ProductName
{
static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref string value)
{
if (String.IsNullOrWhiteSpace(value))
{
value = null!; // Disable NRT compiler warning because we return an error anyway
validationError = new ValidationError("Product name cannot be empty.");
return;
}
if (value.Length == 1)
{
validationError = new ValidationError("Product name cannot be 1 character long.");
return;
}
value = value.Trim();
}
}
The implementation of ValidateFactoryArguments
of a complex value object looks very similar.
[ComplexValueObject]
public sealed 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)
return;
validationError = new ValidationError($"Lower boundary '{lower}' must be less than upper boundary '{upper}'");
}
}
In addition to the partial
method ValidateFactoryArguments
for validation of factory method arguments there is another partial
method ValidateConstructorArguments
. The method ValidateConstructorArguments
is being called in the private
constructor implemented by the Roslyn source generator.
I highly recommend to prefer
ValidateFactoryArguments
overValidateConstructorArguments
because a constructor has no other options as to throw an exception, which will result in worse integration with the libraries and frameworks, like JSON serialization, ASP.NET Core model binding/validation and Entity Framework Core.
[ValueObject<string>]
public sealed partial class ProductName
{
static partial void ValidateConstructorArguments(ref string value)
{
// do something
}
}
And the ValidateConstructorArguments
of a complex value object Boundary
.
[ComplexValueObject]
public sealed partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
static partial void ValidateConstructorArguments(ref decimal lower, ref decimal upper)
{
// do something
}
}
The key member of a simple value object is generated by the source generator.
Use KeyMemberName
, KeyMemberAccessModifier
and KeyMemberKind
to change the generation of the key member, or set SkipKeyMember
to true
to provide custom implementation.
Example: Let source generator generate property public DateOnly Date { get; }
instead of field private readonly DateOnly _value;
(Default).
[ValueObject<DateOnly>(KeyMemberName = "Date",
KeyMemberAccessModifier = ValueObjectAccessModifier.Public,
KeyMemberKind = ValueObjectMemberKind.Property)]
public readonly partial struct EndDate
{
}
Example of custom implementation:
[ValueObject<DateOnly>(SkipKeyMember = true, // We implement the key member "Date" ourselves
KeyMemberName = "Date")] // Source Generator needs to know the name we've chosen
public readonly partial struct EndDate
{
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
.
The reason strings are not using default implementation is, because I encountered very few use cases where the comparison must be performed case-sensitive. Case-sensitive string comparisons, encountered in the past, were almost all bugs because the developers have forgotten to pass appropriate (case-insensitive) comparer.
Use ValueObjectKeyMemberEqualityComparerAttribute<TComparerAccessor, TMember>
to define an equality comparer for comparison of key members and for computation of the hash code. Use one of the predefined ComparerAccessors
or implement a new one (see few sections below).
The example below changes the comparer from OrdinalIngoreCase
to Ordinal
.
[ValueObject<string>]
[ValueObjectKeyMemberEqualityComparer<ComparerAccessors.StringOrdinal, string>]
public sealed partial class ProductName
{
}
Use ValueObjectMemberEqualityComparerAttribute<TComparerAccessor, TMember>
to change both, the equality comparer and the members being used for comparison and computation of the hash code.
[ComplexValueObject]
public sealed partial class Boundary
{
// The equality comparison uses `Lower` only!
[ValueObjectMemberEqualityComparer<ComparerAccessors.Default<decimal>, decimal>]
public decimal Lower { get; }
public decimal Upper { get; }
}
To use all assignable properties in comparison, either don't use ValueObjectMemberEqualityComparerAttribute
at all or put it on all members.
[ComplexValueObject]
public sealed partial class Boundary
{
[ValueObjectMemberEqualityComparer<ComparerAccessors.Default<decimal>, decimal>]
public decimal Lower { get; }
[ValueObjectMemberEqualityComparer<ComparerAccessors.Default<decimal>, decimal>]
public decimal Upper { get; }
}
Implement the interface IEqualityComparerAccessor<T>
to create a new custom accessor. The accessor has 1 property that returns an instance of IEqualityComparer<T>
. The generic type T
is the type of the member to compare.
public interface IEqualityComparerAccessor<in T>
{
static abstract IEqualityComparer<T> EqualityComparer { get; }
}
Implementation of an accessor for members of type string
.
public class StringOrdinal : IEqualityComparerAccessor<string>
{
public static IEqualityComparer<string> EqualityComparer => StringComparer.Ordinal;
}
Predefined accessors in static class ComparerAccessors
:
// Predefined:
ComparerAccessors.StringOrdinal
ComparerAccessors.StringOrdinalIgnoreCase
ComparerAccessors.CurrentCulture
ComparerAccessors.CurrentCultureIgnoreCase
ComparerAccessors.InvariantCulture
ComparerAccessors.InvariantCultureIgnoreCase
ComparerAccessors.Default<T>; // e.g. ComparerAccessors.Default<string> or ComparerAccessors.Default<int>
A custom implementation of IComparer<T>
can be defined on simple value objects only.
Please note that this section is about implementation of
IComparable<T>
andIComparer<T>
. Don't confuse theIComparer<T>
withIEqualityComparer<T>
which is being used for equality comparison and the computation of the hash code.
Use ValueObjectKeyMemberComparerAttribute
to specify a comparer. Use one of the predefined ComparerAccessors
of implement a new one (see below).
[ValueObject<string>]
[ValueObjectKeyMemberComparer<ComparerAccessors.StringOrdinal, string>]
public sealed partial class ProductName
{
}
Implement the interface IComparerAccessor<T>
to create a new custom accessor. The accessor has 1 property that returns an instance of IComparer<T>
. The generic type T
is the type of the member to compare.
public interface IComparerAccessor<in T>
{
static abstract IComparer<T> Comparer { get; }
}
Implementation of an accessor for members of type string
.
public class StringOrdinal : IComparerAccessor<string>
{
public static IComparer<string> Comparer => StringComparer.OrdinalIgnoreCase;
}
Predefined accessors in static class ComparerAccessors
:
// Predefined:
ComparerAccessors.StringOrdinal
ComparerAccessors.StringOrdinalIgnoreCase
ComparerAccessors.CurrentCulture
ComparerAccessors.CurrentCultureIgnoreCase
ComparerAccessors.InvariantCulture
ComparerAccessors.InvariantCultureIgnoreCase
ComparerAccessors.Default<T>; // e.g. ComparerAccessors.Default<string> or ComparerAccessors.Default<int>
The class ValidationError
is being used to pass the validation error from the method Validate
to its caller. The ValidationError
accepts nothing else besides an error message. If you want to pass more information to the caller then use ValueObjectValidationErrorAttribute<T>
to define your own class to be used for carrying validation error(s).
In order to use the custom type with ValueObjectValidationErrorAttribute<T>
, you must create a class that implements the interface IValidationError<T>
. This interface requires an implementation of the factory method static T Create(string message)
. The factory method will be used in generated code. Add additional constructors or factory methods for usage in your code as you desire.
Important! Custom type must implement
ToString()
because it is being used by some components, like JSON-(de)serializer, to get the actual error message.
Here is an example of a custom validation error that accepts an error message and the parameters that were validated.
public class BoundaryValidationError : IValidationError<BoundaryValidationError>
{
public string Message { get; }
public decimal? Lower { get; }
public decimal? Upper { get; }
// for use in custom code
public BoundaryValidationError(
string message,
decimal? lower,
decimal? upper)
{
Message = message;
Lower = lower;
Upper = upper;
}
// mainly for generated code
public static BoundaryValidationError Create(string message)
{
return new BoundaryValidationError(message, null, null);
}
// generated code will use ToString() to get the error message
public override string ToString()
{
return $"{Message} (Lower={Lower}|Upper={Upper})";
}
}
Apply the attribute to Value Object and start using the BoundaryValidationError
in your validations.
[ComplexValueObject]
[ValueObjectValidationError<BoundaryValidationError>]
public sealed partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
static partial void ValidateFactoryArguments(ref BoundaryValidationError? validationError, ref decimal lower, ref decimal upper)
{
if (lower <= upper)
return;
validationError = new BoundaryValidationError("Lower boundary must be less than upper boundary.", lower, upper);
}
}
The constructors of value objects are private
by default. Use ConstructorAccessModifier
to change the access modifier.
[ValueObject<string>(ConstructorAccessModifier = ValueObjectAccessModifier.Public)]
public sealed partial class ProductName
{
}
-----------------------
[ComplexValueObject(ConstructorAccessModifier = ValueObjectAccessModifier.Public)]
public sealed partial class Boundary
{
}
It is possible to change the name of the factory methods Create
and TryCreate
.
[ValueObject<string>(CreateFactoryMethodName = "Create",
TryCreateFactoryMethodName = "TryCreate")]
public sealed partial class ProductName
{
}
------------
[ComplexValueObject(CreateFactoryMethodName = "Create",
TryCreateFactoryMethodName = "TryCreate")]
public sealed partial class BoundaryWithCustomFactoryNames
{
public decimal Lower { get; }
public decimal Upper { get; }
}
It is possible to skip the generation of the factory methods Create
/TryCreate
/Validate
but this comes with a price. Some features like JSON (de)serialization or ASP.NET Core model binding depend on the factory methods. If there are no factory methods then neither JSON converter nor ASP.NET Core model binder are going to be implemented.
[ValueObject<string>(SkipFactoryMethods = true)]
public sealed partial class ProductName
{
}
------------
[ComplexValueObject(SkipFactoryMethods = true)]
public sealed partial class BoundaryWithCustomFactoryNames
{
public decimal Lower { get; }
public decimal Upper { get; }
}
By default, providing null
to methods Create
and TryCreate
of a simple value object is not allowed. If property NullInFactoryMethodsYieldsNull
is set to true
, then providing a null
will return null
.
[ValueObject<string>(NullInFactoryMethodsYieldsNull = true)]
public sealed partial class ProductName
{
}
Similar as with NullInFactoryMethodsYieldsNull described above, but for empty strings.
If this property is set to true
then the factory methods Create
and TryCreate
will return null
if they are provided null
, an empty string or a string containing white spaces only.
[ValueObject<string>(EmptyStringInFactoryMethodsYieldsNull = true)]
public sealed partial class ProductName
{
public string Value { get; }
}
Use ValueObjectAttribute<T>
to set SkipIComparable
to true
to disable the implementation of IComparable
and IComparable<T>
.
[ValueObject<int>(SkipIComparable = true)]
public readonly partial struct Amount
{
}
Use ValueObjectAttribute<T>
to set AdditionOperators
to OperatorsGeneration.None
to disable the implementation of addition operators: +
.
Set the property to OperatorsGeneration.DefaultWithKeyTypeOverloads
to generate additional operators to be able to perform addition of a Value Object with a value of the key-member-type.
[ValueObject<int>(AdditionOperators = OperatorsGeneration.None)]
public readonly partial struct Amount
{
}
Use ValueObjectAttribute<T>
to set SubtractionOperators
to OperatorsGeneration.None
to disable the implementation of addition operators: -
.
Set the property to OperatorsGeneration.DefaultWithKeyTypeOverloads
to generate additional operators to be able to perform subtraction of a Value Object with a value of the key-member-type.
[ValueObject<int>(SubtractionOperators = OperatorsGeneration.None)]
public readonly partial struct Amount
{
}
Use ValueObjectAttribute<T>
to set MultiplyOperators
to OperatorsGeneration.None
to disable the implementation of addition operators: *
.
Set the property to OperatorsGeneration.DefaultWithKeyTypeOverloads
to generate additional operators to be able to perform multiplication of a Value Object with a value of the key-member-type.
[ValueObject<int>(MultiplyOperators = OperatorsGeneration.None)]
public readonly partial struct Amount
{
}
Use ValueObjectAttribute<T>
to set DivisionOperators
to OperatorsGeneration.None
to disable the implementation of addition operators: /
.
Set the property to OperatorsGeneration.DefaultWithKeyTypeOverloads
to generate additional operators to be able to perform division of a Value Object with a value of the key-member-type.
[ValueObject<int>(DivisionOperators = OperatorsGeneration.None)]
public readonly partial struct Amount
{
}
Use ValueObjectAttribute<T>
to set ComparisonOperators
to OperatorsGeneration.None
to disable the implementation of comparison operators: >
, >=
, <
, <=
.
Set the property to OperatorsGeneration.DefaultWithKeyTypeOverloads
to generate additional operators to be able to compare a Value Object with a value of the key-member-type.
[ValueObject<int>(ComparisonOperators = OperatorsGeneration.None)]
public readonly partial struct Amount
{
}
Use ValueObjectAttribute<T>
to set SkipIParsable
to true
to disable the implementation of IParsable<T>
.
[ValueObject<int>(SkipIParsable = true)]
public readonly partial struct Amount
{
}
Use ValueObjectAttribute<T>
to set SkipIFormattable
to true
to disable the implementation of IFormattable
.
[ValueObject<int>(SkipIFormattable = true)]
public readonly partial struct Amount
{
}
Use ValueObjectAttribute<T>
or ComplexValueObjectAttribute
to set SkipToString
to true
to disable the implementation of the method ToString()
.
[ValueObject<int>(SkipToString = true)]
public readonly partial struct Amount
{
}
---------
[ComplexValueObject(SkipToString = true)]
public sealed partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
}
Use ValueObjectAttribute<T>
or ComplexValueObjectAttribute
to change the name via DefaultInstancePropertyName
.
For
structs
only.
[ValueObject<int>(DefaultInstancePropertyName = "Zero")]
public readonly partial struct Amount
{
}
// Usage
var zero = Amount.Zero; // instead of Amount.Empty
-------------
[ComplexValueObject(DefaultInstancePropertyName = "Unbounded")]
public readonly partial struct BoundaryWithCustomFactoryNames
{
public decimal Lower { get; }
public decimal Upper { get; }
}
With ValueObjectFactory<T>
you can implement additional methods to be able 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.
Example: Given is a complex Value Object Boundary
having 2 decimals.
The Value Object Boundary
must be convertible from specific strings, e.g. the value 1:2
should return an instance with Lower=1
and Upper=2
.
By applying ValueObjectFactoryAttribute<string>
the source generator adds the interface IValueObjectFactory<Boundary, string>
which forces you to implement new method Validate(string, IFormatProvider, out Boundary)
. With the new method Validate
we have a one-way conversion, i.e. from string
to Boundary
.
[ComplexValueObject]
[ValueObjectFactory<string>]
public sealed partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
public static ValidationError? Validate(string? value, IFormatProvider? provider, out Boundary? item)
{
// splits strings like "1:2" and creates new instance of "Boundary"
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.");
if (!Decimal.TryParse(parts[0], provider, out var lower) || !Decimal.TryParse(parts[1], provider, out var upper))
return new ValidationError("The provided values are not numbers.");
// Delegate the call to "regular" implementation for further validation.
// Alternatively, create a new instance here if there are no further validation.
return Validate(lower, upper, out item);
}
}
Two-way conversion is required if the type T
must be used for serialization/deserialization. This can be achieved by using the property UseForSerialization = SerializationFrameworks.All
. This leads to implementation of the interface IValueObjectConvertable<string>
which consists of the method T ToValue()
. The serialization frameworks (like System.Text.Json
) specified by SerializationFrameworks
are starting to prefer the newly implemented methods instead of using the regular serialization, i.e. serializing the complex value object as an object with 2 properties.
[ComplexValueObject]
[ValueObjectFactory<string>(UseForSerialization = SerializationFrameworks.All)]
public sealed partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
public static ValidationError? Validate(string? value, IFormatProvider? provider, out Boundary? item)
{
...
}
public string ToValue()
{
return $"{Lower}:{Upper}";
}
}
Depending on the concrete JSON library you use, you need a different Nuget package:
There are 2 options to make the Value Objects JSON convertible.
The easiest way is to make Thinktecture.Runtime.Extensions.Json / Thinktecture.Runtime.Extensions.Newtonsoft.Json a dependency of the project(s) the value objects are in. The dependency doesn't have to be a direct one but can be transitive as well.
Both Nuget packages lead to generation of JSON converters and flag the value object with a JsonConverterAttribute
. This way the value objects can be converted to and from JSON without extra code.
For simple value objects only.
If making previously mentioned Nuget package a dependency of project(s) with value objects is not possible or desirable, then the other option is to register a JSON converter with JSON serializer settings. By using a JSON converter directly, the Nuget package can be installed in any project where the JSON settings are configured.
- Use
ValueObjectJsonConverterFactory
withSystem.Text.Json
- Use
ValueObjectNewtonsoftJsonConverter
withNewtonsoft.Json
An example for ASP.NET Core application using System.Text.Json
:
var webHost = new HostBuilder()
.ConfigureServices(collection =>
{
collection.AddMvc()
.AddJsonOptions(options => options.JsonSerializerOptions
.Converters
.Add(new ValueObjectJsonConverterFactory()));
})
An example for minimal apis:
var builder = WebApplication.CreateBuilder();
builder.Services
.ConfigureHttpJsonOptions(options => options.SerializerOptions
.Converters
.Add(new ValueObjectJsonConverterFactory()));
The code for Newtonsoft.Json
is almost identical:
var webHost = new HostBuilder()
.ConfigureServices(collection =>
{
collection.AddMvc()
.AddNewtonsoftJson(options => options.SerializerSettings
.Converters
.Add(new ValueObjectNewtonsoftJsonConverter()));
})
There are 2 options to make the value objects MessagePack serializable.
The easiest way is to make Thinktecture.Runtime.Extensions.MessagePack a dependency of the project(s) the value objects are in. The dependency doesn't have to be a direct one but can be transitive as well.
The Nuget package leads to generation of a MessagePack formatter and flags the value object with a MessagePackFormatterAttribute
. This way the value object can be serialized to and from MessagePack without extra code.
For simple value objects only.
If making previously mentioned Nuget package a dependency of project(s) with value objects is not possible or desirable, then the other option is to register the MessagePack formatter with MessagePack serializer options. By using the ValueObjectMessageFormatterResolver
directly, the Nuget package can be installed in any project where the MessagePack options are configured.
An example of a round-trip-serialization of the value object ProductName
:
// Use "ValueObjectMessageFormatterResolver.Instance"
var resolver = CompositeResolver.Create(ValueObjectMessageFormatterResolver.Instance, StandardResolver.Instance);
var options = MessagePackSerializerOptions.Standard.WithResolver(resolver);
ProductName chocolate = ProductName.Create("Chocolate");
// Serialize to MessagePack
var bytes = MessagePackSerializer.Serialize(chocolate, options, CancellationToken.None);
// Deserialize from MessagePack
var deserializedChocolate = MessagePackSerializer.Deserialize<ProductName>(bytes, options, CancellationToken.None);
Having JSON convertible value objects is just half of the equation. If a value of a simple value object is received as a query parameter, then there is no JSON conversion in play but ASP.NET Core Model Binding. Besides model binding, i.e., conversion from query string to a value object, there is model validation as well.
ASP.NET Core Model Binding is for simple value objects only and for complex objects with
ValueObjectFactoryAttribute<string>
(see section Convert from/to any specific type). A complex value object has more than 1 property/field, so, deserialization (withoutValueObjectFactoryAttribute<string>
) from astring
to 2+ members is a case for JSON (de)serialization.
The parameter binding of Minimal Apis in .NET 7 is still quite primitive in comparison to the model binding of MVC controllers. To make a type bindable it has to implement either TryParse
or BindAsync
. A simple Value Object implements TryParse
(interface IParsable<T>
) by default, so it can be used with Minimal Apis without any changes.
At the moment, the parameter binding (with TryParse
and BindAsync
) doesn't allow to pass custom validation errors to be returned to the client. The only information we can pass is an indication whether the parameter could be bound or not.
ASP.NET MVC gives us more control during model binding. For example, if we expect from client a ProductName
and receive the value A
, which is rejected by the validation, then the ASP.NET Core ModelState
will be invalid. In this case we can reject (or let ApiControllerAttribute reject) the request.
By rejecting the request, the client gets the status code BadRequest (400)
and the error:
{
"productName": [
"Product name cannot be 1 character long."
]
}
To help out the Model Binding we have to register the ValueObjectModelBinderProvider
with ASP.NET Core. By using the custom model binder, the Nuget package can be installed in any project where ASP.NET Core is configured.
Place the "ValueObjectModelBinderProvider" before default providers, so they don't try to bind value objects.
var webHost = new HostBuilder()
.ConfigureServices(collection =>
{
collection.AddMvc(options => options.ModelBinderProviders
.Insert(0, new ValueObjectModelBinderProvider()));
})
Starting with Entity Framework Core 2.1 we've got the feature Value Conversion. By providing a value converter, EF Core can convert a simple value object (like ProductName
) to and from a primitive type (like string
) when persisting the data and when reading the value from database.
The registration of a value converter can be done manually by using one of the method overloads of HasConversion
in OnModelCreating
.
This approach is not recommended because "Create" can perform some heavy validations which will affect performance. In general, data loaded from the database doesn't have to validated because the database is the "source of truth". Use option 2 or 3 to register a value converter which (by default) uses the constructor directly.
// Entity
public class Product
{
// other properties...
public ProductName Name { get; private set; }
}
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Product>(builder =>
{
builder.Property(p => p.Name)
.HasConversion(name => (string)name,
s => ProductName.Create(s));
});
}
}
Entity Framework Core value conversion is for simple value objects only. Treating a complex value object as an owned entity or complex type is more suitable than pressing multiple members into 1 column.
// Entity
public class Product
{
// other properties...
public Boundary Boundary { get; private set; }
}
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Product>(builder =>
{
builder.ComplexProperty(p => p.Boundary,
boundaryBuilder =>
{
boundaryBuilder.Property(b => b.Lower).HasColumnName("Lower").HasPrecision(18, 2);
boundaryBuilder.Property(b => b.Upper).HasColumnName("Upper").HasPrecision(18, 2);
});
});
}
}
Alternatively, you can install the appropriate Nuget package for EF Core 5, EF Core 6, EF Core 7 or EF Core 8 and use the extension method AddValueObjectConverters
to register the value converters for you.
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddValueObjectConverters();
}
}
You can provide a delegate to adjust the configuration of Smart Enums and Value Objects.
modelBuilder.AddValueObjectConverters(
configureEnumsAndKeyedValueObjects: property =>
{
if (property.ClrType == typeof(ProductType))
property.SetMaxLength(20);
});
The other options is to use the extension method UseValueObjectValueConverter
for the DbContextOptionsBuilder
.
services
.AddDbContext<DemoDbContext>(builder => builder
.UseValueObjectValueConverter(validateOnWrite: true,
configureEnumsAndKeyedValueObjects: property =>
{
if (property.ClrType == typeof(ProductType))
property.SetMaxLength(20);
})
Logging can be activated in the csproj-file. Define the property ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath
pointing to an existing(!) folder (like C:\temp\
). You can provide a file name (like samples_logs.txt
) which is being used as a template for creation of a unique log file name like samples_logs_20230322_220653_19c0d6c18ec14512a1acf97621912abb.txt
.
Please note, that there will be more than 1 log file (per project) because IDEs (Rider/VS) usually create 1 Source Generator for constant running in the background, and 1 for each build/rebuild of a project. Unless,
ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique
is set tofalse
.
With ThinktectureRuntimeExtensions_SourceGenerator_LogLevel
you can specify one of the following log levels: Trace
, Debug
, Information
(DEFAULT), Warning
, Error
.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
...
<ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath>C:\temp\samples_logs.txt</ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath>
<ThinktectureRuntimeExtensions_SourceGenerator_LogLevel>information</ThinktectureRuntimeExtensions_SourceGenerator_LogLevel>
<ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique>false</ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique>
</PropertyGroup>
If the logger throws an exception, for example due to insufficient file system access permissions, then the logger will try to write the exception into a temp file. You can find the file ThinktectureRuntimeExtensionsSourceGenerator.log
in the temp folder of the user the IDE/CLI is running with.
I started to write down some examples I used in the past to show the developers the benefits of value objects and smart enums.
There are multiple ways to implement an end date with open-end. All of them have their pros and cons.
Here are the most popular approaches I encountered in the past:
-
Use nullable
DateOnly?
(orDateTime?
)-
PRO: Better semantics, i.e.
null
means there is no end date. The default value ofDateOnly?
isnull
as well, which results in expected behavior. -
CON: (LINQ) queries must check for both
null
and a concrete date, i.e.query.Where(i => i.MyEndDate is null || i.MyEndDate > now)
. Using such query with a database usually results in worse performance because||
/OR
prevents the database from using an appropriate index.
-
PRO: Better semantics, i.e.
-
Use
DateOnly.MaxValue
(orDateTime
)-
PRO: The condition in the (LINQ) query is straight-forward
query.Where(i => i.MyEndDate > now)
. If this query is executed on a database then the database is able to use an appropriate index which result in better performance. -
CON: Using a special value like
DateOnly.MaxValue
to represent an open-ended date results in worse semantics. -
CON: The main culprit is the keyword
default
or the default value of aDateOnly
(orDateTime
), which isDateOnly.MinValue
. If the property/field/variable is not assigned explicitly and staysDateOnly.MinValue
, then this most likely will lead to an undesired behavior. In this situation I would like to have an open-ended end date instead of the date0001-01-01
, which is an invalid end date in the most use cases.
-
PRO: The condition in the (LINQ) query is straight-forward
The desired solution must:
- not require
OR
in queries to improve performance - have a default value which represents open-ended end date
An always-valid value object EndDate
which is a readonly struct
.
[ValueObject<DateOnly>(SkipKeyMember = true, // We implement the key member "Date" ourselves
KeyMemberName = "Date", // Source Generator needs to know the name we've chosen
DefaultInstancePropertyName = "Infinite", // "EndDate.Infinite" represent an open-ended end date
EqualityComparisonOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads)] // for comparison with DateOnly without implicit cast
public readonly partial struct EndDate
{
private readonly DateOnly? _date;
// can be public as well
private DateOnly Date
{
get => _date ?? DateOnly.MaxValue;
init => _date = value;
}
// Further validation
// static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref DateOnly date)
// {
// validationError = date.Year switch
// {
// < 2000 => new ValidationError("The end date lies too far in the past."),
// >= 2050 => new ValidationError("The end date lies too far in the future."),
// _ => validationError
// };
// }
}
Basic usage (see also ValueObjectDemos.cs) is virtually the same as with DateOnly
or DateTime
.
// Create an EndDate
DateOnly today = DateOnly.FromDateTime(DateTime.Now);
EndDate endDate = (EndDate)today;
EndDate endDate = EndDate.Create(today); // alternative
// Compare the dates
var isTrue = EndDate.Infinite > endDate;
// Default value is equal to infinite date and equal to "DateOnly.MaxValue"
var defaultEndDate = default(EndDate);
var infiniteEndDate = EndDate.Infinite;
isTrue = infiniteEndDate == defaultEndDate;
// Get the actual date if needed
DateOnly dateOfDefaultDate = defaultEndDate;
DateOnly dateOfInfiniteDate = infiniteEndDate;
isTrue = dateOfDefaultDate == dateOfInfiniteDate;
// Compare DateOnly with EndDate
isTrue = EndDate.Infinite == dateOfDefaultDate
Use EndDate
with Entity Framework Core (see also Product.cs, EF-Demos and Support for Entity Framework Core)
Please note that
DateOnly
is not supported in EF Core 7 but will be in EF Core 8. I use the library ErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly in my EF Core 7 demos.
// Entity
public class Product
{
...
public EndDate EndDate { get; set; }
}
// query
var today = (EndDate)DateOnly.FromDateTime(DateTime.Today);
var products = await ctx.Products
.Where(p => p.EndDate >= today)
.ToListAsync();
Use EndDate
with ASP.NET Core controllers (see also DemoController.cs) and minimal api (see also minimal api demo).
Read the section "Support for Minimal Api Parameter Binding and ASP.NET Core Model Binding" to get more information.
// Controller
[Route("api"), ApiController]
public class DemoController : Controller
{
[HttpGet("enddate/{endDate}")]
public IActionResult RoundTripGet(EndDate endDate)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
return Json(endDate);
}
[HttpPost("enddate")]
public IActionResult RoundTripPost([FromBody] EndDate endDate)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
return Json(endDate);
}
}
// Minimal api
var app = builder.Build();
var routeGroup = app.MapGroup("/api");
routeGroup.MapGet("enddate/{date}", (EndDate date) => date);
routeGroup.MapPost("enddate", ([FromBody] EndDate date) => date);
The response is the same in both cases.
GET api/enddate/2023-04-05
and
POST api/enddate
with body "2023-04-05"
returns "2023-04-05"
Value objects are excellent for checking some kind of invariants. In one of my use cases I had to perform a calculation of moderate complexity and the result and all partial results must always be positive.
We could use a plain decimal
and check the (partial) result after every(!) arithmetic operation, but it requires more code and is difficult to read and to maintain. Instead, we switched from decimal to a readonly struct Amount
which checks the invariant automatically.
[ValueObject<decimal>(DefaultInstancePropertyName = "Zero", // renames Amount.Empty to Amount.Zero
ComparisonOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads, // for comparison of amount with a decimal without implicit conversion: amount > 42m
AdditionOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads, // for arithmetic operations of amount with a decimal without implicit conversion: amount + 42m
SubtractionOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads,
MultiplyOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads,
DivisionOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads)]
public readonly partial struct Amount
{
static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref decimal value)
{
if (value < 0)
validationError = new ValidationError("Amount must be positive.");
}
}
The usage is the same as with a plain decimal
.
// get an instance of amount with Create/TryCreate/Validate or an explicit cast
var amount = Amount.Create(1m);
var otherAmount = (Amount)2m;
var zero = Amount.Zero;
// equality comparisons
amount == zero; // false
amount > otherAmount; // false
amount > 42m; // false
amount.CompareTo(otherAmount); // -1
// arithmetic operations
amount + otherAmount; // 3
amount + 42m // 43