-
Notifications
You must be signed in to change notification settings - Fork 1
Version 5.x.x Immutable value objects
- Getting started
-
Customizing
- Validation of the factory method arguments
- Validation of the constructor arguments
- Custom equality comparer
- Custom comparer (simple value objects only)
- Skip factory methods generation
- Null in factory methods yields null
- Empty-String in factory methods yields null
- Skip generation of
CompareTo
- Changing the name of static property
Empty
- JSON serialization
- MessagePack serialization
- Support for ASP.NET Core Model Binding
- Support for Entity Framework Core
- Limitations
This library provides some objects and Roslyn Source Generators for implementation of immutable value objects. The library comes with some Roslyn Analyzers and Code Fixes to guide the software developer 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
.
- TargetFramework: .NET Standard 2.1 or higher (like .NET Core 3.1, .NET 5)
- Language version: C# 9.0
The immutable value objects described here are devided in 2 categories. Each category is for a specific use case and has different features:
- Simple value objects are types with 1 field/property, which share a lot of features with an enum-like class (a.k.a smart enum)
- Complex value objects are types with 2 or more fields/properties
Please note, that the constructors, implemented by the Roslyn source generators, are private
.
When it comes to the number of members then the "assignable" members are taken into consideration only. Read-only properties like "int Value => 42" are ignored.
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 your business rules.
The most basic example is a class
or a readonly struct
having the ValueObjectAttribute
and 1 field/property. This example probably is not of much use because it is lacking any validation, but it is a good starting point.
The only property or field of a simple value object (like
Value
in example below) will be called the key member from now on.
[ValueObject]
public partial class ProductName
{
public string Value { get; }
// The member can be a private readoly field as well
//private readonly string _value;
}
After the implementation of the ProductName
, a Roslyn source generator kicks in and implements the rest. Following API is available.
// Factory method for creation of new instances
ProductName bread = ProductName.Create("Bread");
// Factory method returning a validation result which provides an error message,
// if the provided value is not a valid product name.
// The "System.ComponentModel.DataAnnotations.ValidationResult" is being used
// by some other libraries/frameworks like ASP.NET Core Model Validation.
ValidationResult? validationResult = ProductName.TryCreate("Milk", out var milk);
bool success = validationResult == ValidationResult.Success;
// implicit conversion to the type of the key member
string valueOfTheProductName = bread; // "Bread"
// explicit conversion from the type of the key member to value object
ProductName bread = (ProductName)"Bread"; // is the same as calling "ProductName.Create"
// Equality comparison with 'Equals'
// which compares the key members using default or custom 'IEqualityComparer<T>'
bool equal = bread.Equals(bread);
// Equality comparison with '==' and '!='
bool equal = bread == bread;
bool notEqual = bread != bread;
// Hash code of the key member according 'IEqualityComparer<T>'
int hashCode = bread.GetHashCode();
// 'ToString' implementation
string value = bread.ToString(); // "Bread"
// ProductName implements "IComparable<ProductName>" because key member (string) is "comparable"
var productNames = new List<ProductName> { milk, bread };
productNames.Sort(); // ["Bread", "Milk"]
// The value object implements "IFormattable" if the key member is an "IFormattable".
// "string" does not implement "IFormattable" but "DateTime" does.
[ValueObject]
public partial class OtherValueObject
{
private readonly DateTime _date;
}
OtherValueObject otherValueObject = OtherValueObject.Create(DateTime.Now);
string formattedValue = $"Formatted value: {otherValueObject: yyyy-MM-dd}"; // "2021-02-15"
Additionally, the source generator implements a TypeConverter
which are used by some libraries/frameworks like JSON serialization or ASP.NET Core Model Binding.
var typeConverter = TypeDescriptor.GetConverter(typeof(ProductName));
string value = (string)typeConverter.ConvertTo(bread, typeof(string)); // "Bread"
ProductName productName = (ProductName)typeConverter.ConvertFrom("Bread");
Let's take a look at the complex value objects before we get to more realistic use cases.
A complex value object is considered a class
or a readonly struct
with a ValueObjectAttribute
and with more than 1 properties/fields. The main use case is to manage multiple values as a whole, i.e., one value is kind of meaningless without the other.
A simple example would be a Boundary
with 2 properties, one is the lower boundary and the other is the upper boundary. Yet again, this class is not very useful without validation.
[ValueObject]
public 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
Boundary boundary = Boundary.Create(lower: 1, upper: 2);
// Factory method returning a validation result which provides an error message,
// if the provided value is not a valid product name.
// The "System.ComponentModel.DataAnnotations.ValidationResult" is being used
// by some other libraries/frameworks like ASP.NET Core Model Validation.
ValidationResult? validationResult = Boundary.TryCreate(lower: 1, upper: 2, out Boundary boundary);
bool success = validationResult == ValidationResult.Success;
// Equality comparison with 'Equals'
// which compares the members using default or custom comparers
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 }"
In the next section we will look at the customization of the value objects, so they become more useful.
Until now, the value objects were more or less simple classes without added value. Let's add the most important feature of a value object, the 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 ValidationResult
.
[ValueObject]
public partial class ProductName
{
public string Value { get; }
static partial void ValidateFactoryArguments(ref ValidationResult? validationResult, ref string value)
{
if (String.IsNullOrWhiteSpace(value))
{
validationResult = new ValidationResult("Product name cannot be empty.");
return;
}
if (value.Length == 1)
{
validationResult = new ValidationResult("Product name cannot be 1 character long.");
return;
}
value = value.Trim();
}
}
The implementation of ValidateFactoryArguments
of a complex value object looks very similar.
[ValueObject]
public partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
static partial void ValidateFactoryArguments(ref ValidationResult? validationResult, ref decimal lower, ref decimal upper)
{
if (lower <= upper)
return;
validationResult = new ValidationResult($"Lower boundary '{lower}' must be less than upper boundary '{upper}'");
}
}
Additionally 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 NOT to use
ValidateConstructorArguments
butValidateFactoryArguments
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]
public partial class ProductName
{
public string Value { get; }
static partial void ValidateConstructorArguments(ref string value)
{
// do something
}
}
And the ValidateConstructorArguments
of a complex value object Boundary
.
[ValueObject]
public partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
static partial void ValidateConstructorArguments(ref decimal lower, ref decimal upper)
{
// do something
}
}
By default, the source generator is using the default implementation of Equals
and GetHashCode
for all assignable properties and fields for equality comparison and for the hash code. With ValueObjectEqualityMemberAttribute
it is possible to change both, the equality comparer and the members being used for comparison and computation of the hash code.
The simple value objects have just 1 member, we can put the equality comparer on.
[ValueObject]
public partial class ProductName
{
private static readonly IEqualityComparer<string> _equalityComparer = StringComparer.OrdinalIgnoreCase;
[ValueObjectEqualityMember(EqualityComparer = nameof(_equalityComparer))]
public string Value { get; }
}
With complex types it is getting more complex...
By putting the ValueObjectEqualityMemberAttribute
on 1 member only means that other members don't take part in the equality comparison.
[ValueObject]
public partial class Boundary
{
// the equality comparison uses `Lower` only
[ValueObjectEqualityMember]
public decimal Lower { get; }
public decimal Upper { get; }
}
The same as above but the comparison of Lower
is being made by a custom comparer.
[ValueObject]
public partial class Boundary
{
private static readonly IEqualityComparer<decimal> _comparer = EqualityComparer<decimal>.Default;
[ValueObjectEqualityMember(EqualityComparer = nameof(_comparer))]
public decimal Lower { get; }
public decimal Upper { get; }
}
To use all assignable properties in comparison, either don't use ValueObjectEqualityMemberAttribute
at all or put it on all members.
[ValueObject]
public partial class Boundary
{
private static readonly IEqualityComparer<decimal> _comparer = EqualityComparer<decimal>.Default;
[ValueObjectEqualityMember(EqualityComparer = nameof(_comparer))]
public decimal Lower { get; }
[ValueObjectEqualityMember]
public decimal Upper { get; }
}
A custom implementation of IComparer<T>
can be specified on key members, i.e. in simple value objects, only. A complex value object doesn't need a comparer because it doesn't implement the method CompareTo
.
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.
[ValueObject]
public partial class ProductName
{
private static readonly IComparer<string> _comparer = Comparer<string>.Default;
[ValueObjectEqualityMember(Comparer = nameof(_comparer))]
public string Value { get; }
}
It is possible to skip the generation of the factory methods 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 no JSON converter and ASP.NET Core model binder are going to be implemented.
[ValueObject(SkipFactoryMethods = true)]
public partial class ProductName
{
public string Value { get; }
}
By default, providing null
to methods Create
and TryCreate
of a keyed value object is not allowed. If property NullInFactoryMethodsYieldsNull
is set to true
, then providing a null
will return null
.
[ValueObject(NullInFactoryMethodsYieldsNull = true)]
public partial class ProductName
{
public string Value { get; }
}
(requires version 5.1.0 or higher)
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 a null
, an empty string or a string containting white spaces only.
[ValueObject(EmptyStringInFactoryMethodsYieldsNull= true)]
public partial class ProductName
{
public string Value { get; }
}
It is possible to skip the implementation of IComparable
, if the comparison makes no sense in your use case.
[ValueObject(SkipCompareTo = true)]
public partial class ProductName
{
public string Value { get; }
}
For structs
only.
[ValueObject(DefaultInstancePropertyName= "None")]
public readonly partial struct ProductNameStruct
{
public string Value { get; }
}
// Usage
var none = ProductNameStruct.None; // instead of ProductNameStruct.Empty
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 transitive as well.
Both Nuget packages come with another Roslyn source generator, which implements a JSON converter and flags the value object with a JsonConverterAttribute
. This way the value object can be converted to and from JSON without extra code.
For simple value objects only. At the moment, there is no generic JSON converter for complex value objects, so Option 1 is the only option for now.
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()));
})
The code for Newtonsoft.Json
is almost identical:
var webHost = new HostBuilder()
.ConfigureServices(collection =>
{
collection.AddMvc()
.AddNewtonsoftJson(options => options.SerializerSettings
.Converters
.Add(new ValueObjectNewtonsoftJsonConverter()));
})
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. A complex value object has more than 1 property/field, so, deserialization from a
string
to 2+ members is a case for JSON (de)serialization.
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
must 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.
var webHost = new HostBuilder()
.ConfigureServices(collection =>
{
collection.AddMvc(options => options.ModelBinderProviders
.Insert(0, new ValueObjectModelBinderProvider()));
})
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 transitive as well.
The Nuget package comes with another Roslyn source generator, which implements 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. At the moment, there is no generic MessagePack formatter for complex value objects, so Option 1 is the only option for now.
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 productName = ProductName.Create("Milk");
// Serialize to MessagePack
var bytes = MessagePackSerializer.Serialize(productName, options, CancellationToken.None);
// Deserialize from MessagePack
var deserializedProductName = MessagePackSerializer.Deserialize<ProductName>(bytes, options, CancellationToken.None);
Starting with Entity Framework Core 2.1 we've got the feature Value Conversion. By providing a value converter, the EF Core can convert a simple value object (like ProductName
) to a primitive type (like string
) before persisting the value and back to value object 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
.
// 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 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.OwnsOne(p => p.Boundary,
boundaryBuilder =>
{
boundaryBuilder.Property(b => b.Lower).HasColumnName("Lower");
boundaryBuilder.Property(b => b.Upper).HasColumnName("Upper");
});
});
}
}
Alternatively, if you are using Entity Framework Core v5, you can install the Nuget package Thinktecture.Runtime.Extensions.EntityFrameworkCore and use the extension method AddEnumAndValueObjectConverters
to register the value converters for you.
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddEnumAndValueObjectConverters();
}
}
Requires version 4.1.0 or later
The other options is to use the extension method UseValueObjectValueConverter
for the DbContextOptionsBuilder
.
services
.AddDbContext<DemoDbContext>(builder => builder
.UseValueObjectValueConverter()
Aliases, like ValObj
in the example below, are not supported due to performance reasons during source code generation.
using ValObj = Thinktecture.ValueObjectAttribute;
namespace Thinktecture.ValueObjects;
[ValObj]
public partial class ProductName
{
}