The Value Of Value Objects
For years I’ve struggled with how to handle very simple validation scenarios.
Most systems have identifiers with basic constraints — fixed length, allowed characters, or formatting rules. The problem usually isn’t writing the validation itself, but deciding where that validation should live and ensuring it’s applied consistently.
Consider a simple example: suppose our application models an Order, and the system requires that an order ID must be exactly 10 characters long.
For a long time I would have implemented it like this:
public class Order
{
public string Id { get; set; } = string.Empty;
}
Nothing unusual there. Also, until fairly recently I would typically enforce validation using a separate validator:
public class OrderValidator
{
public void Validate(Order order)
{
ArgumentNullException.ThrowIfNull(order);
ArgumentException.ThrowIfNullOrWhiteSpace(order.Id);
if (order.Id.Trim().Length != 10)
{
throw new ArgumentException(
"Id must be 10 characters long",
nameof(order.Id));
}
}
}
Now imagine a service responsible for creating orders:
public class OrderService
{
private readonly OrderValidator _validator;
public OrderService(OrderValidator validator)
{
_validator = validator;
}
public Order Create(string id)
{
var order = new Order
{
Id = id
};
_validator.Validate(order);
// persist order, publish events, etc.
return order;
}
}
Putting aside for the moment that using exceptions for control flow isn’t ideal, this approach works, but it comes with a few undesirable side effects:
- Any code responsible for creating or updating an Order needs to know that this validator exists.
- The rules for a valid order ID are disconnected from the value they describe.
- Invalid orders can exist until validation is explicitly invoked.
Also, what makes two orders equal in this case isn’t obvious. One possible fix is to implement equality directly on the entity:
public class Order : IEquatable<Order>
{
public string Id { get; set; } = string.Empty;
public bool Equals(Order? other)
{
if (ReferenceEquals(this, other)) return true;
if (other is null) return false;
return other.Id == Id;
}
public override bool Equals(object? obj)
=> obj is Order other && Equals(other);
public override int GetHashCode()
=> Id.GetHashCode();
}
Now we can compare orders using Equals(), but we still have to remember to do that instead of using ==.
Behold the Value Object
Although I haven’t read the entire book (yet), I’m a big fan of Domain-Driven Design. One of its core concepts is the Value Object. According to Eric Evans, a value object is:
“An object that represents a descriptive aspect of the domain with no conceptual identity.”
An easy way to think about value objects is that they behave like primitives such as string or int, but enforce business rules.
In our example, we could introduce an OrderId type:
public struct OrderId
{
public OrderId(string id)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id, nameof(id));
if (id.Trim().Length != 10)
{
throw new ArgumentException(
"Id must be 10 characters long",
nameof(id));
}
Value = id.Trim();
}
public string Value { get; }
}
Then the Order class becomes something like this:
public class Order : IEquatable<Order>
{
public OrderId Id { get; }
public Order(OrderId id)
{
Id = id;
}
public bool Equals(Order? other)
{
if (ReferenceEquals(this, other)) return true;
if (other is null) return false;
return other.Id.Equals(this.Id);
}
public override int GetHashCode()
{
return Id.GetHashCode();
}
public override bool Equals(object? obj)
{
return Equals(obj as Order);
}
}
Now there's no need for an OrderValidator, and an Order can't be instantiated with an invalid id. We can also add == and != operators so that orders compare naturally based on their Id.
Why This Works
By pushing validation into the type system, this approach yields a few practical benefits:
- No more duplicated validation rules
- Order identity is easy to find and understand because it's in one place
- The type system now clearly communicates intent: OrderId is no longer "just a string", it's a fundamental concept like a DateTime or a class.
As more value objects are added, the application starts to resemble a self-contained, well-defined framework of meaningful types rather than a collection of .NET primitives and utility methods.
As a corollary to #3 above, I'm a huge proponent of DDD's concept of "Ubiquitous Language". It forces everyone - project managers, subject matter experts, developers - to communicate in business-focused terms, rather than "tables" and "constraints" and "functions". There's many upsides to this approach, but the most consequential might be that it leads to fewer missed requirements.
Value objects are a small change in design, but they can have a huge impact. When rules are enforced by the type system, duplication disappears, invalid states become harder to create, and the application becomes easier to understand and navigate. Even simple value objects like OrderId can significantly improve the clarity and safety of a system.
I’ve only scratched the surface of value objects here. In future posts I plan to dig deeper into Domain-Driven Design and explore other practical ways they can simplify domain models and reduce accidental complexity. If this topic interests you, I highly recommend Implementing Domain-Driven Design by Vaughn Vernon, which explores these ideas in much greater depth.
Popular Products
-
Fake Pregnancy Test$61.56$30.78 -
Anti-Slip Safety Handle for Elderly S...$57.56$28.78 -
Toe Corrector Orthotics$41.56$20.78 -
Waterproof Trauma Medical First Aid Kit$169.56$84.78 -
Rescue Zip Stitch Kit$109.56$54.78