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.
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
andZipCode
properties withinit
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.
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.
Subscribe
Comments
comments powered by Disqus