Skip to content

Version 6.x.x Value Objects

Pawel Gerr edited this page Nov 28, 2023 · 2 revisions

This library provides some interfaces, classes, Roslyn Source Generators, Roslyn Analyzers and Roslyn CodeFixes for implementation of 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

  • C# 11 (or higher) for generated code
  • SDK 7.0.102 (or higher) for building projects

Getting started

Required Nuget package: Thinktecture.Runtime.Extensions

The value objects described here are divided in 2 categories. Each category is for a specific use case and has different features:

  • Simple or keyed value objects are types with 1 field/property, which share a lot of features with a Smart Enum)
  • Complex value objects are types with 2 or more fields/properties

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 some business rules.

A value object can be either a class or a readonly struct flagged with ValueObjectAttribute. Let's take a look at a value object without validation first.

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 sealed 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 from now on.

// Factory method for creation of new instances.
// Throws ValidationException if the validation fails
ProductName bread = ProductName.Create("Bread");

// Alternatively, using an explicit cast (behaves the same as with Create)
ProductName bread = (ProductName)"Bread"; // is the same as calling "ProductName.Create"

-----------

// the same as above but returns a bool instead of throwing an exception (dictionary-style)
bool created = ProductName.TryCreate("Milk", out ProductName milk);

-----------

// similar to TryCreate but returns a ValidationResult instead of a boolean.
ValidationResult? validationResult = ProductName.Validate("Milk", out var milk);

if (validationResult == ValidationResult.Success)
{
    logger.Information("Product name {Name} created", milk);
}
else
{
    logger.Warning("Failed to create product name. Validation result: {ValidationResult}", validationResult.ErrorMessage);
}

-----------

// implicit conversion to the type of the key member
string valueOfTheProductName = bread; // "Bread"

-----------

// Equality comparison with 'Equals'
// which compares the key members using default or custom 'IEqualityComparer<T>'.
// Strings are compared with 'StringComparer.OrdinalIgnoreCase' by default.
bool equal = bread.Equals(bread);

-----------

// Equality comparison with '==' and '!='
bool equal = bread == bread;
bool notEqual = bread != bread;

-----------

// Hash code
int hashCode = bread.GetHashCode();

-----------

// 'ToString' implementation
string value = bread.ToString(); // "Bread"

------------

// Implements IParsable<T> which is especially helpful with minimal web apis.
// This feature can be disabled if it doesn't make sense (see ValueObjectAttribute).
bool success = ProductName.TryParse("New product name", null, out var productName);

------------

// Implements "IFormattable" if the key member is an "IFormattable".
// This feature can be disabled if it doesn't make sense (see ValueObjectAttribute).
[ValueObject]
public sealed partial class Amount
{
   private readonly int _value;
}

var amount = Amount.Create(42);
string formattedValue = amount.ToString("000", CultureInfo.InvariantCulture); // "042"

------------

// Implements "IComparable<ProductName>" if the key member is an "IComparable"
// This feature can be disabled if it doesn't make sense (see ValueObjectAttribute).
var amount = Amount.Create(1);
var otherAmount = Amount.Create(2);

var comparison = amount.CompareTo(otherAmount); // -1

// Implements comparison operators (<,<=,>,>=) if the key member has comparison operators itself.
// This feature can be disabled if it doesn't make sense (see ValueObjectAttribute).
var isBigger = amount > otherAmount;  

------------

// Implements addition / subtraction / multiplication / division if the key member supports operators
// This feature can be disabled if it doesn't make sense (see ValueObjectAttribute).
var sum = amount + otherAmount;

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 "assignable" properties/fields. The main use case is to manage multiple values as a whole.

A simple example would be a Boundary with 2 properties, one is the lower boundary and the other is the upper boundary. Yet again, we skip the validation at the moment.

[ValueObject]
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
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 ValidationResult instead of a boolean.
ValidationResult? validationResult = Boundary.Validate(lower: 1, upper: 2, out Boundary boundary);

if (validationResult == ValidationResult.Success)
{
    logger.Information("Boundary {Boundary} created", boundary);
}
else
{
    logger.Warning("Failed to create boundary. Validation result: {ValidationResult}", validationResult.ErrorMessage);
}

-----------

// Equality comparison with 'Equals'
// which 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 }"

Validation

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 sealed 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.",
                                                 new[] { nameof(Value) });
         return;
      }

      if (value.Length == 1)
      {
         validationResult = new ValidationResult("Product name cannot be 1 character long.",
                                                 new[] { nameof(Value) });
         return;
      }

      value = value.Trim();
   }
}

The implementation of ValidateFactoryArguments of a complex value object looks very similar.

[ValueObject]
public sealed 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}'",
                                              new[] { nameof(Lower), nameof(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 sealed 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 sealed partial class Boundary
{
   public decimal Lower { get; }
   public decimal Upper { get; }

   static partial void ValidateConstructorArguments(ref decimal lower, ref decimal upper)
   {
      // do something
   }
}

Customizing

Custom equality comparer

By default, the source generator is using the default implementation of Equals and GetHashCode, except strings, for all assignable properties and fields for equality comparison and for the hash code. If the property or field is a string, then StringComparer.OrdinalIgnoreCase is being used for comparisons.

The reason strings are not using EqualityComparer.Default is because I encountered very few use cases where the comparison must be performed case-sensitive. Case-sensitive string comparisons, I encountered in the past, were almost all bugs because the developer have forgotten to pass appropriate comparer.

With ValueObjectMemberEqualityComparerAttribute<TComparer, TMember> it is possible to change both, the equality comparer and the members being used for comparison and computation of the hash code.

Simple value objects have just 1 member, we can put the equality comparer on.

[ValueObject]
public sealed partial class ProductName
{
   [ValueObjectMemberEqualityComparer<ComparerAccessors.Default<decimal>, decimal>]
   public string Value { get; }
}

First generic parameter of ValueObjectMemberEqualityComparerAttribute is an implementation of IEqualityComparerAccessor<T> which provides the equality comparer to use.

public interface IEqualityComparerAccessor<in T>
{
   static abstract IEqualityComparer<T> EqualityComparer { get; }
}

You can implement your own IEqualityComparerAccessor or use one of the predefined accessors on static class ComparerAccessors:

    // Example of a custom implementation
   public class StringOrdinal : IEqualityComparerAccessor<string>
   {
      public static IEqualityComparer<string> EqualityComparer => StringComparer.Ordinal;
   }
   
   // 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>

With complex types it is getting more complex...

By putting the ValueObjectMemberEqualityComparerAttribute on 1 member only means that other members don't take part in the equality comparison!

[ValueObject]
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.

[ValueObject]
public sealed partial class Boundary
{
   [ValueObjectMemberEquality<ComparerAccessors.Default<decimal>, decimal>]
   public decimal Lower { get; }

   [ValueObjectMemberEquality<ComparerAccessors.Default<decimal>, decimal>]
   public decimal Upper { get; }
}

Custom comparer

A custom implementation of IComparer<T> can be specified on key members only, i.e. having a simple value object. A complex value object 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.

Use ValueObjectMemberComparerAttribute to specify a comparer.

[ValueObject]
public sealed partial class ProductName
{
   [ValueObjectMemberComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>]
   public string Value { get; }
}

First generic parameter of the ValueObjectMemberComparerAttribute is an implementation of IComparerAccessor<T> which provides the comparer to use. You can implement your own IComparerAccessor or use one of the predefined accessors on static class ComparerAccessors

    // Example of a custom implementation
   public class StringOrdinal : IComparerAccessor<string>
   {
        public static IComparer<string> Comparer => StringComparer.OrdinalIgnoreCase;
   }
   
   // 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>

Skip factory methods generation

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(SkipFactoryMethods = true)]
public sealed 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 sealed partial class ProductName
{
   public string Value { get; }
}

Empty-String in factory methods yields null

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(EmptyStringInFactoryMethodsYieldsNull= true)]
public sealed partial class ProductName
{
   public string Value { get; }
}

Skip implementation of IComparable/IComparable<T>

Use ValueObjectAttribute to set SkipIComparable to true to disable the implementation of IComparable and IComparable<T>.

[ValueObject(SkipIComparable = true)]
public sealed partial class ProductGroup
{

Implementation of addition operators

Use ValueObjectAttribute 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 underlying type.

[ValueObject(AdditionOperators = OperatorsGeneration.None)]
public sealed partial class ProductGroup
{

Implementation of subtraction operators

Use ValueObjectAttribute 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 underlying type.

[ValueObject(SubtractionOperators = OperatorsGeneration.None)]
public sealed partial class ProductGroup
{

Implementation of multiply operators

Use ValueObjectAttribute 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 underlying type.

[ValueObject(MultiplyOperators = OperatorsGeneration.None)]
public sealed partial class ProductGroup
{

Implementation of division operators

Use ValueObjectAttribute 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 underlying type.

[ValueObject(DivisionOperators = OperatorsGeneration.None)]
public sealed partial class ProductGroup
{

Implementation of comparison operators

Use ValueObjectAttribute 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 underlying type.

[ValueObject(ComparisonOperators = OperatorsGeneration.None)]
public sealed partial class ProductGroup
{

Skip implementation of IParsable<T>

Use ValueObjectAttribute to set SkipIParsable to true to disable the implementation of IParsable<T>.

[ValueObject(SkipIParsable = true)]
public sealed partial class ProductGroup
{

Skip implementation of IFormattable

Use ValueObjectAttribute to set SkipIFormattable to true to disable the implementation of IFormattable.

[ValueObject(SkipIFormattable = true)]
public sealed partial class ProductGroup
{

Skip implementation of ToString

Use ValueObjectAttribute to set SkipToString to true to disable the implementation of the method ToString().

[ValueObject(SkipToString = true)]
public sealed partial class ProductGroup
{

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 project with Value Objects depend on corresponding 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()));
               })

An example for minimal web 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()));
               })

MessagePack serialization

  • Required nuget package: 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 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 Minimal Web Api Parameter Binding and 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.

Minimal Web Api

The parameter binding of Minimal Web 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 Web Apis without any changes.

At the moment, all means (i.e. 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 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()));
              })

Support for Entity Framework Core

Optional nuget packages:
Thinktecture.Runtime.Extensions.EntityFrameworkCore5
Thinktecture.Runtime.Extensions.EntityFrameworkCore6
Thinktecture.Runtime.Extensions.EntityFrameworkCore7

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, you can install the appropriate Nuget package for EF Core 5, EF Core 6 or EF Core 7 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();
   }
}

You can provide a delegate to adjust the configuration of Smart Enums and Value Objects.

modelBuilder.AddEnumAndValueObjectConverters(
               configureEnumsAndKeyedValueObjects: property =>
                                                   {
                                                      if (property.ClrType == typeof(ProductType))
                                                         property.SetMaxLength(20);
                                                   });

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

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 (v6.1.0 or higher)

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.

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

Real-world use cases and ideas

I started to write down some examples I used in the past to show the developers the benefits of value objects and smart enums.
More examples will come very soon!

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 EndDate which is a readonly struct.

[ValueObject(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
{
   // Source Generator should work with the property "Date" only and ignore this backing field
   [ValueObjectMemberIgnore]
   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 ValidationResult? validationResult, ref DateOnly date)
   // {
   //    validationResult = date.Year switch
   //    {
   //       < 2000 => new ValidationResult("The end date lies too far in the past."),
   //       >= 2050 => new ValidationResult("The end date lies too far in the future."),
   //       _ => validationResult
   //    };
   // }
}

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 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 web api (see also minimal web api demo).

Read the section "Support for Minimal Web 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 web 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"

(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 of the 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 an to maintain. Instead, we switched from decimal to a readonly struct Amount which checks the invariant automatically.

[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
{
   private readonly decimal _value;

   static partial void ValidateFactoryArguments(ref ValidationResult? validationResult, ref decimal value)
   {
      if (value < 0)
         validationResult = new ValidationResult("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(1);
var otherAmount = (Amount)2;
var zero = Amount.Zero;

// equality comparisons
amount == zero; // false
amount > otherAmount; // false
amount > 42; // false
amount.CompareTo(otherAmount); // -1

// arithmetic operations
amount + otherAmount; // 3
amount + 42 // 43
Clone this wiki locally