Skip to content

Version 5.x.x Immutable value objects

Pawel Gerr edited this page Mar 4, 2023 · 1 revision

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.

Requirements

  • TargetFramework: .NET Standard 2.1 or higher (like .NET Core 3.1, .NET 5)
  • Language version: C# 9.0

Getting started

Required Nuget package: Thinktecture.Runtime.Extensions

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.

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 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.

Complex value objects

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.

Customizing

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.

Validation of the factory method arguments

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}'");
   }
}

Validation of the constructor arguments

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 but ValidateFactoryArguments 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
   }
}

Custom equality comparer

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; }
}

Custom comparer

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> and IComparer<T>. Don't confuse the IComparer<T> with IEqualityComparer<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; }
}

Skip factory methods generation

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; }
}

Null in factory methods yields null

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; }
}

Empty-String in factory methods yields null

(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; }
}

Skip generation of CompareTo

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; }
}

Changing the name of static property Empty

For structs only.

[ValueObject(DefaultInstancePropertyName= "None")]
public readonly partial struct ProductNameStruct
{
   public string Value { get; }
}

// Usage
var none = ProductNameStruct.None; // instead of ProductNameStruct.Empty

JSON serialization

Depending on the concrete JSON library you use, you need a different Nuget package:

  • For System.Text.Json: Thinktecture.Runtime.Extensions.Json
  • For Newtonsoft.Json: Thinktecture.Runtime.Extensions.Newtonsoft.Json

There are 2 options to make the value objects JSON convertible.

Option 1: Make value objects depend on Nuget package

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.

Option 2: Register JSON converter with JSON serializer settings

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 with System.Text.Json
  • Use ValueObjectNewtonsoftJsonConverter with Newtonsoft.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()));
               })

Support for ASP.NET Core Model Binding

Required nuget package: 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. 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()));
              })

MessagePack serialization

  • Required nuget package: Thinktecture.Runtime.Extensions.MessagePack

There are 2 options to make the value objects MessagePack serializable.

Option 1: Make value objects depend on Nuget package

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.

Option 2: Register MessagePack FormatterResolver with MessagePack serializer options

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);

Support for Entity Framework Core

Optional nuget package: Thinktecture.Runtime.Extensions.EntityFrameworkCore

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.

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.

// 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");
                          });
      });
   }
}

Option 2: Registration of the ValueConverter via extension method for ModelBuilder

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();
   }
}

Option 3: Registration of the ValueConverter via extension method for DbContextOptionsBuilder

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()

Limitations

Source Generation: Aliases are not supported

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
{
}
Clone this wiki locally