C# 9 Records as DDD Value Objects

Today, we’ll talk about the new C# 9 feature, Records, and whether or not they can be used as DDD value objects.

Download this article as a PDF

1. C# 9 Records

C# 9’s Records is a nice feature where the compiler automatically generates a whole bunch of useful boilerplate code for you. For example, for a record like this:

public record Address(string Street, string ZipCode);

The compiler generates the following:

  • A Street and ZipCode properties with init setters (meaning, they can only be set during the object instantiation) and a public constructor that accepts those fields:

public class Address
{
    public string Street { get; init; }
    public string ZipCode { get; init; }

    public Address(string street, string zipCode)
    {
        Street = street;
        ZipCode = zipCode;
    }
}
  • A ToString() method that prints the content of the record:

public override string ToString()
{
    // Prints "Address { Street = 1234 Main St, ZipCode = 20012 }"
}
  • Equality members that enable the comparison of Address instances by value, as opposed to by reference, which is the default for classes in C#:

var address1 = new Address("1234 Main St", "20012");
var address2 = new Address("1234 Main St", "20012");

bool b1 = address1.Equals(address2); // true
bool b2 = address1 == address2; // true
  • A Deconstruct() method that allows for object deconstruction:

var address = new Address("1234 Main St", "20012");
(string street, string zipCode) = address;

If you are familiar with F#, that’s exactly what the F# compiler does too for F# records.

2. C# Records as Value Objects

Value Object is a DDD concept that is immutable and doesn’t have its own identity. I wrote about it in-depth in this article. Value Objects are the backbone of any rich domain model.

Here’s how you could implement the same Address class as a value object:

public class Address : ValueObject
{
    public string Street { get; }
    public string ZipCode { get; }

    public Address(string street, string zipCode)
    {
        Street = street;
        ZipCode = zipCode;
    }

    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Street;
        yield return ZipCode;
    }
}

You can read more about the ValueObject base class in this article (its implementation is also available in the CSharpFunctionalExtensions library). This base class provides the same functionality as C# records: the equality members and the Deconstruct() method.

C# records are a good fit for DDD value objects — they provide the same semantics and do all the hard lifting behind the scenes. So, which approach is better? Having the ValueObject base class or using C# records?

Let’s compare them using several attributes.

3. IComparable interface

Unfortunately, C# records don’t implement the IComparable interface, which means that the following code doesn’t work:

var address1 = new Address("1234 Main St", "20012");
var address2 = new Address("1235 Main St", "20012");
Address[] addresses = new[] { address1, address2 }.OrderBy(x => x).ToArray();

If you try to run it, .NET will throw an exception:

Failed to compare two elements in the array. At least one object must implement IComparable.

This is unfortunate because such comparison is quite useful at times. This isn’t a big deal, though. I too added the support for this interface in the CSharpFunctionalExtensions library only recently.

4. Encapsulation

Encapsulation is an important part of any domain class, value objects included. Encapsulation stands for protection of application invariants: you shouldn’t be able to instantiate a value object in an invalid state.

In practice it means that public constructors are out of question when it comes to the use of C# records as value objects. You need to introduce static factory methods, like so:

public record Address
{
    public string Street { get; }
    public string ZipCode { get; }

    // C-tor is private
    private Address(string street, string zipCode)
    {
        Street = street;
        ZipCode = zipCode;
    }

    public static Result<Address> Create(string street, string zipCode)
    {
        // Check street and zipCode validity

        return Result.Success(new Address(street, zipCode));
    }
}

Notice that the constructor is private. You must use the static Create method to instantiate an Address. This method returns a success only if all validations pass.

With such implementation, the most important advantage of C# records, their conciseness, vanishes. You can’t use one-liner records to define value objects.

Note that, in terms of encapsulation, C# records are much better than structs (.NET value types) because you can’t hide the parameterless constructor in a struct.

Download this article as a PDF

5. Precise control over equality checks

The version with the ValueObject base class has the additional GetEqualityComponents method:

public class Address : ValueObject
{
    public string Street { get; }
    public string ZipCode { get; }

    private Address(string street, string zipCode)
    {
        Street = street;
        ZipCode = zipCode;
    }

    public static Result<Address> Create(string street, string zipCode)
    {
        // Check street and zipCode validity

        return Result.Success(new Address(street, zipCode));
    }

    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Street;
        yield return ZipCode;
    }
}

This might look unnecessary at first. After all, why not just take all the components into account when comparing two value objects?

In fact, this is a very useful (and necessary) addition. With it, you can have precise control over equality checks.

5.1. Exclusion of equality components

Sometimes, you might want to exclude a property from the comparison:

public sealed class Error : ValueObject
{
    public string Code { get; }
    public string Message { get; }

    internal Error(string code, string message)
    {
        Code = code;
        Message = message;
    }

    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Code;
    }
}

Here, the Message property is just a comment that helps better understand the error. It shouldn’t be considered when comparing two errors. (Check out this article to learn more about the Error class.) With C# records, the only way to exclude this property is to override all the equality members in the class.

5.2. Comparison precision

Sometimes, it’s also important to control the precision of the comparison. For example, let’s say that we have a Weight value object (I’m omitting the Create factory method for brevity):

public record Weight(double Value);

And let’s also say that you want any fractions that are smaller than 0.01 pounds to be disregarded when comparing two weight objects. There’s no way to do that with C# records:

var weight1 = new Weight(1);
var weight2 = new Weight(1.001);

bool result = weight1 == weight2; // false

With GetEqualityComponents(), on the other hand, you can manually set up the comparison precision:

public class Weight : ValueObject
{
    public double Value { get; }

    public Weight(double value)
    {
        Value = value;
    }

    protected override IEnumerable<object> GetEqualityComponents()
    {
        // Rounding excludes fractions from the comparison
        yield return Math.Round(Value, 2);
    }
}

5.3. Collection comparison

Finally, C# records' equality doesn’t work when one of the record’s members doesn’t follow the comparison-by-value semantics.

For example, let’s say we have this value object:

public record Address(string Street, string ZipCode, string[] Comments);

The following code doesn’t work, even though the two instances are exactly the same:

var address1 = new Address("1234 Main St", "20012", new [] { "Comment1", "Comment2" });
var address2 = new Address("1234 Main St", "20012", new [] { "Comment1", "Comment2" });

bool result = address1 == address2; // false

That’s because arrays (and other collections) are compared by reference in .NET: the two string arrays are considered different even though their content is the same.

GetEqualityComponents() allows you to mitigate this issue and compare value objects using the content of their collections:

public class Address : ValueObject
{
    public string Street { get; }
    public string ZipCode { get; }
    public string[] Comments { get; }

    public Address(string street, string zipCode, string[] comments)
    {
        Street = street;
        ZipCode = zipCode;
        Comments = comments;
    }

    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Street;
        yield return ZipCode;

        // Representing each item in the collection
        // as a separate component
        foreach (string comment in Comments)
        {
            yield return comment;
        }
    }
}

Of course, in the vast majority of cases, you don’t need such precise control over equality checks. But sometimes you do. It’s important to have this control as an option, without resorting to overriding all of the record’s equality members.

6. The presence of the base class

There’s also something to be said about the presence of the ValueObject base class and lack thereof.

Look at the following two class definitions:

public record Address
{
    public string Street { get; }
    public string ZipCode { get; }
}

// vs.

public class Address : ValueObject
{
    public string Street { get; }
    public string ZipCode { get; }
}

The first one is shorter but I would argue that the second option is still better because it’s more explicit — it unambiguously shows that Address is part of the domain model.

This is similar to inheriting your domain entities from Entity and domain events — from IDomainEvent. The ValueObject and Entity base classes not only provide utility in the form of useful methods, but also serve as markers to indicate the role of the inheriting class.

Without them, you would have to rely on namespaces (which is fine) or the addition of weasel words (which is not). An example of such weasel words is renaming Address into AddressValueObject.

7. The problem with with

What about the new with feature? This feature works similarly to Fluent Interface in that it allows for creation of new objects based on old ones:

Address address1 = new Address("1234 Main St", "20012");
Address address2 = address1 with { Street = "1234 Second St" };
bool result = address1.ZipCode == address2.ZipCode; // true

This feature is neat, but it doesn’t work in scenarios where encapsulation is a requirement. In such scenarios, you need a setter, which in turn allows for bypassing of the value object’s invariants.

For example, let’s say that we have the following CustomerStatus value object:

public record CustomerStatus
{
    public bool IsAdvanced { get; init; }
    public DateTime? ExpirationDate { get; init; }

    public CustomerStatus(bool isAdvanced, DateTime? expirationDate)
    {
        if (isAdvanced && expirationDate.HasValue == false)
            throw new Exception("Advanced status must have an expiration date");
        if (isAdvanced == false && expirationDate.HasValue)
            throw new Exception("Regular status must have no expiration date");

        IsAdvanced = isAdvanced;
        ExpirationDate = expirationDate;
    }
}

Notice that there’s an invariant in here: the advanced status must have an expiration date, while the regular status must not have one. The class verifies this invariant in the constructor and throws an exception if it’s violated. This way, the class guarantees that its instances will never have an invalid state, which is the essence of encapsulation.

Notice also that the properties have the init setters, which are prerequisites for the with feature.

Now look at this code:

CustomerStatus status = new CustomerStatus(false, null);
CustomerStatus status2 = status with { IsAdvanced = true };
// status2: IsAdvanced = true, ExpirationDate = null

We’ve just bypassed the invariant check and created an invalid CustomerStatus instance. This code is equivalent to the following:

var status = new CustomerStatus(false, null)
{
    IsAdvanced = true
};

This is silly of course (we are setting IsAdvanced twice) but valid nonetheless.

The with feature, just like fluent interfaces, are good for building test data, queries, and so on. They aren’t good for domain classes, which usually require encapsulation.

8. Comparison results

C# records have only one advantage over the approach with the ValueObject base class: you don’t need to implement the GetEqualityComponents method. This is also a disadvantage: the lack of this method prevents you from tuning the equality comparison behavior, which might be important in some cases.

Overall, the approach with the ValueObject base class is superior to C# records. This base class makes your value objects slightly more verbose, but provides more functionality and flexibility.

9. Use cases for C# records

So what are the use cases for C# records then? There are quite a few, even though DDD value objects isn’t one of them.

For one, I think records will replace the Fluent Interface pattern in C#. The Test Data Builder pattern is a great example here. Instead of writing your own boilerplate code, you can now use the new with feature and save yourself tons of time and effort.

Second, C# records (and init setters in particular) are good for DTOs (read about the difference between Value Objects and DTOs in this article).

You may also need interim data classes while loading data to or retrieving it from the database or while doing some preprocessing. This is similar to the above DTOs, but instead of serving as data contracts between your application and external systems, these data classes act as DTOs between different layers of your own system. C# records are great for that too.

Finally, not all applications require a rich, fully encapsulated domain model. In most simpler cases that don’t need much of encapsulation, C# records would do just fine.

Download this article as a PDF

Subscribe


I don't post everything on my blog. Don't miss smaller tips and updates. Sign up to my mailing list below.

Comments


comments powered by Disqus