DEV Community

TillW
TillW

Posted on

DDD Value Objects as C# Records: The Missing Manual

C# Records are very handy if you need to implement Value Objects, because they offer support for value-equality out of the box. Positional Records and the with-operator ease the implementation and usage of immutable data-types. But beware, records are not a template for Value Objects but rather a toolbox. Therefore you should keep caution which features you use. Otherwhise, the compiler may create code that allows you to manipulate your 'immutable' or to create objects that have an invalid state.

As a brief recap, here are the most important properties of a Value Object in the context of Domain-Driven Design (DDD):

  • Value Objects contain business logic (e.g. restrictions/constraints on their properties)
  • Value Objects are immutable
  • Value Objects support value-equality (i.e. two instances are equal when all of their properties are equal)

Luckily, if we use Records we do not need to think much about equality. However, we do not get the immutability and enforcement of our restrictions from the framework for free. In this post, I'll show you some pitfalls and what you can do about them so that at the end of the day, you have a solid implementation of a Value object.

Validation of Constraints

To prevent an object from being created with an invalid state, the properties must be validated before an instance is created. The checks must therefore be implemented either in the initializer or in the constructor.

Lets assume we want to model the measured value of a thermometer. Obviously, a temperature cannot be below absolute zero. As this condition can be validated against the measured reading, the value can be validated within the initializer.

public record TemperatureCelsius
{
    private readonly Decimal _value;
    public Decimal Value
    {
        get => _value;
        init => _value = value < -273.15m ? throw new ArgumentOutOfRangeException() : value;
    }
}
Enter fullscreen mode Exit fullscreen mode

As an example of a dependency at validation-time, assume that the temperature unit is variable. Consequently, the correct minimum value must be taken into account when validating the temperature.

public record Temperature
{
    public Temperature(Decimal degrees, TemperatureUnit unit)
    {
        switch (unit)
        {
            case TemperatureUnit.Celsius when degrees < -273.15m: throw new ArgumentOutOfRangeException();
            case TemperatureUnit.Fahrenheit when degrees < -459.67m: throw new ArgumentOutOfRangeException();
            case TemperatureUnit.Kelvin when degrees < 0m: throw new ArgumentOutOfRangeException();
        }

        Degrees = degrees;
        Unit = unit;
    }

    public Decimal Degrees { get; private init; }
    public TemperatureUnit Unit { get; private init; }
}
Enter fullscreen mode Exit fullscreen mode

Could we have implemented the check in the initializers as well? Unfortunately no, because initializers can be called in any order. If the property Degrees is then initialized before Unit, we cannot perform a meaningful check for undercutting the zero point.

What does this mean for the implementation of a value-object?

  • If all properties can be validated in isolation, then you can implement the validation in initializers.
  • If there are dependencies at validation-time between properties, then you must do the validation in the constructor and properties must not have public initializers.
  • If you don't need to do any validation, then the value-object may have public initializers. In this case you should consider using Positional Records to keep the code short and readable.

(In my opinion, modeling a measurement value like in the above example ('Temperature') is suboptimal for a reallife application. If possible, only measurement unit should be used within a context. The conversion to other units then happens at the context boundaries.)

A little about the behavior of the with-operator

The with-operator can be used to create a new record with modified properties. The initializers are called for the properties that are set within the block and the values are copied for all other properties. This means that the validations we implemented in the initializer will be applied automatically. So we don't have to care about that use case during the implementation.

If a property does not have a public initializer, then the property cannot be changed when the with-operator is called.

// Using the type 'Temperature' from the example above.
var celsius = new Temperature(34m, TemperatureUnit.Celsius);
var kelvin = celsius with { Unit = TemperatureUnit.Kelvin }; // Error CS0272: Property has no accessible setter.
Enter fullscreen mode Exit fullscreen mode

Immutability

Creating an immutable record is not difficult, as long as your domain model is designed in such a way that a value-object contains only other Value Objects or primitive data types (int, string, ...). Then you just have to make sure that the properties and backing-fields are read-only. You achieve this by marking your fields with readonly and assure that properties have none or private setters.

However, if your model specifies that a Value object holds multiple objects in a single property, you must be careful. Arrays and Lists are mutable. If an instance of these types is declared read-only, that reference cannot be changed, but the contents of the object can. Therefore, you should make sure that the container does not allow manipulation of its contents.

private readonly int[] array;

public void Example()
{
    array[1] = 2; // Assignment is possible
    array = new int[3]; // Error CS0191: Cannot assign to read-only field
}
Enter fullscreen mode Exit fullscreen mode

In theory, you can use Immutable Collections provided by the framework. However, these have the disadvantage that they violate Liskov's substitution principle and that their equality is not determined based on their elements (see below).

What does this mean for the implementation of a value-object?

  • Properties must not have setters (but may have initializers).
  • Fields should be declared as readonly. This ensures that no method can change the state of the object.
  • Use immutable containers only.

Value Equality

The C# compiler automatically creates an Equals and GetHashCode method for a record that takes all properties of two instances into account. However, this only works if all properties also support value equality. This is the case for primitive data types and (correctly implemented) Value objects. As a container, we may only use data structures that support value equality.

Unfortunately, the Immutable Collections from the framework do not help us at this point, as the following example with the (vicariously selected) ImmutableArray class shows.

public record ColorGradient(System.Collections.Immutable.ImmutableArray<Color> Colors);
Enter fullscreen mode Exit fullscreen mode
[Fact]
public void Equality()
{
    var gradient1 = new ColorGradient(new[] { Color.Black, Color.Green, Color.White }.ToImmutableArray());
    var gradient2 = new ColorGradient(new[] { Color.Black, Color.Green, Color.White }.ToImmutableArray());

    Assert.NotEqual(gradient1, gradient2); // System.Collections.Immutable.ImmutableArray does not support value-equality.
}
Enter fullscreen mode Exit fullscreen mode

When using a container that satisfies value equality (e.g. ValueArrays) the type ColorGradient2 behaves as expected.

public record ColorGradient2(ValueArrays.ValueArray<Color> Colors);
Enter fullscreen mode Exit fullscreen mode
[Fact]
public void Equality()
{
    var gradient1 = new ColorGradient2(new ValueArrays.ValueArray<Color>(new[] { Color.Black, Color.Green, Color.White }));
    var gradient2 = new ColorGradient2(new ValueArrays.ValueArray<Color>(new[] { Color.Black, Color.Green, Color.White }));

    Assert.Equal(gradient1, gradient2);
}
Enter fullscreen mode Exit fullscreen mode

What does this mean for the implementation of a value-object?

  • To take advantage of the value equality of records, all properties must belong to one of the following categories:
    • Value Objects
    • Primitive data types
    • Container that support value equality (e.g. ValueArrays)

Conclusion

Regardless of whether you implement a value-object as a class or record, you must ensure that there can be no invalid or mutable objects. The necessary building blocks (initializer, constructor, 'no setters') were introduced in the sections "Validation of Constraints" and "Immutability". Why you should be careful when using containers has been explained in the sections "Immutability" and "Value Equality".

If you follow these tips, the built-in value equality of records will work. Also, you don't need to implement Equals or GetHashCode (and tests for them). Therefor you can fully focus on the implementation of your business logic...

Further Reading

Top comments (0)