3 Reasons to Model Identity as a Value Object
On of the defining characteristics of an Entity is that it has identity. From the Blue Book:
“Some objects are not defined primarily by their attributes. They represent a thread of identity that runs through time and often across distinct representations… An object defined primarily by its identity is called an ENTITY” (Evans, 91)
There are different ways of representing identity. In our CRUD world, we might use a Guid
or an int
that’s stored as a primary key in a SQL table. In DDD implementations, you might see a different pattern - using Value Objects as identifiers for Entities. What’s the point in doing that?
It’s not a requirement, but there are a couple reasons for using Value Objects for identity that might benefit your model in the long run.
1: Allowing for future changes to the underlying identity values without “shotgun surgery”
When we’re relating Aggregate Roots with each other, which is always done through a “soft reference”, we only hold a reference to the identity of the related Aggregate Root. This is important, since an Aggregate Root represents a consistency boundary in your Domain Model, i.e. no two Aggregate Roots will be fully consistent with each other. This is one of the reasons why we use DDD, because retrieving and saving large object graphs is not scalable. If we hold a “hard reference” (a fully instantiated Aggregate Root) inside an Aggregate Root, we have indicated that there is strong consistency with the world outside of the Aggregate Root - and it means that we have made a mistake in modeling the domain.
When we want to create a soft reference from one Aggregate Root to another, we do it by referencing the other Aggregate Root’s identity. If this is modeled as a int
type (e.g. int BeanId
), what happens if we decide to change the type for Aggregate Root Bean
? Here’s a Batch
that represents a batch of green coffee beans that First Pop Coffee Co. is going to roast:
public class Batch : Entity
{
public BatchId BatchId { get; private set; }
public RoastDayId RoastDayId { get; set; }
public BeanId BeanId { get; private set; } // soft reference to Aggregate Root: Bean
public BatchQuantity Quantity { get; private set; }
...
Here’s the Bean
Aggregate Root:
public class Bean : AggregateRootEntity
{
public BeanId BeanId { get; private set; }
public Description Description { get; private set; }
public string UnitOfMeasure { get; private set; }
public decimal UnitPrice { get; private set; }
public OnHandQuantity OnHandQuantity { get; private set; }
...
And here’s BeanId
Value Object:
public class BeanId : ValueType<BeanId>
{
public readonly Guid Id;
public BeanId()
{
Id = Guid.NewGuid();
}
public BeanId(Guid id)
{
Id = id;
}
protected override IEnumerable<object> GetAllAttributesToBeUsedForEquality()
{
return new List<object>() { Id };
}
public override string ToString()
{
return Id.ToString();
}
}
If we modeled BeanId
as a Guid
(which happens to be how it’s stored currently inside of the BeanId
Value Object), like this:
public class Batch : Entity
{
public Guid BatchId { get; private set; }
...
And referenced it like this:
public class Bean : AggregateRootEntity
{
public Guid BeanId { get; private set; }
...
Some time in the future (if you anticipate it), changing this to an int
would require you to change both Entities. This might mean some unintended shotgun surgery across the clients of the Bean
Entity.
2: You can guard against invalid values for identity
We might have some restrictions against what a valid int
backing value is for a certain identity. We may need to add guards like:
public class BeanId : ValueType<BeanId>
{
public readonly int Id;
public BeanId(int id)
{
if (id < 0) throw new ArgumentException("id must be a positive integer");
Id = id;
}
...
3: The compiler can help you spot mistakes
It might help, if you have a team of developers working on the same model, to use the compiler to your advantage to prevent oversights that might make it past code reviews. For example, spot anything wrong with this right away?
public class Order {
public readonly int OrderId;
public readonly List<int> ProductIds;
...
public void AddProduct(int productId) {
ProductIds.Add(productId);
}
}
Order order = new Order();
addProduct(order.OrderId)
This will compile successfully, because order.OrderId
is an int
just like ProductId
would be, but we’re adding the wrong identifier to the ProductIds
collection.
Summary
We went over a few reasons to use a Value Object to model Entity identity. It’s not a requirement, but there are some advantages that might help you out in the long run.
As always, make sure you sign up for my weekly newsletter for much more exciting content related to .NET and software architecture by clicking the red button below: