Value Objects: Implementing Domain-Driven Design by Example
Exploring the Role of Value Objects in Building Secure And Meaningful Domain Models
Today, I’m going to jump right into a real-life example of what I just noticed while modeling the implementation of the core domain GeofenceDetection of my soon-to-be-released Real-Time Location Insights API.
To give a short overview: GeofenceDetection is the core domain of my product because it contains the core logic with the highest business value. I have written several articles about it. If you are new to this topic, I recommend reading this article:
What Means Domain in the Context of Domain-Driven Design?
I have identified BusinessLocation as the root entity aka aggregate root. Aggregates are at the heart of your domain model. They encapsulate entities and value objects into a single entity with consistency and transactional integrity. An aggregate root, like BusinessLocation, acts as a central poinThe good thing is that it is easy to apply by following Domain-Driven Design principles. Value Objects give us the power to implement code that is secure by design. Let me explain this by entering my journey.
When I started implementing my the BusinessLocation aggregate root I did the following.t in a domain, grouping together different elements (entities and value objects) to manage them as one unit, ensuring they stay consistent and correct through all operations.
Entities have a unique identity so that they can be distinguished from one another, whereas value objects have no identity and are defined only by their attributes.
In this article, my aim is to highlight the importance and effectiveness of value objects within Domain-Driven Design (DDD). These elements are fundamental to ensuring domain consistency and integrity. Furthermore, value objects serve as a central tool for constructing domain models that inherently embody secure+ity by design. I believe that security should be built in as a natural part of developing. I see security as a concern, not a feature.
public class BusinessLocation : IAggregateRoot
{
private IList<Customer> _customers;
private BusinessLocation(Guid id, Guid tenantId, double longitude, double latitude, double radius, IList<Customer>? customers)
{
Id = id;
TenantId = tenantId;
Longitude = longitude;
Latitude = latitude;
Radius = radius;
_customers = customers ?? new List<Customer>();
}
public Guid Id { get; init; }
public Guid TenantId { get; init; }
public double Longitude { get; init; }
public double Latitude { get; init; }
public double Radius { get; init; }
// Factory method
public static BusinessLocation CreateNew(Guid id, Guid tenantId, double longitude, double latitude, double radius, IList<Customer> customers = null)
{
// Validate parameters
return new BusinessLocation(id, tenantId, longitude, latitude, radius, customers);
}
// Other methods...
}
While implementing a factory method to ensure consistent initialization of instances, I found myself pausing to carefully evaluate the validation of incoming parameters.In DDD, factories play a fundamental role in encapsulating the logic for creating complex objects and ensuring that they meet the strict requirements. This approach simplifies object creation and also strengthens the integrity and consistency of the domain model.
Upon reflection, I recognized that embedding validation logic directly into the factory method could compromise security and lead to unnecessarily bloated code that ultimately obscures its purpose. Furthermore, from a domain perspective, longitude and latitude are fundamentally different and should not be treated as identical types. This distinction underscores the importance of designing our domain models with precision, ensuring that each element accurately reflects its real-world counterpart, and adhering to the principles of clarity and safety in DDD. By implementing separate value objects for Latitude and Longitude, specific validations and behaviors can be encapsulated, strengthening the model’s integrity and expressiveness. For general understanding, latitude and longitude are specific coordinate types that describe a location on the earth’s surface using the geographic coordinate system.
Let’s define, from a domain point of view, what are the characteristics of these two types.
- Latitude: A value object specifically for latitude, ensuring values are within the range of -90 to 90 degrees.
- Longitude: A value object for longitude, ensuring values are within the range of -180 to 180 degrees.
The implementations will look like the following:
public class Latitude : ValueObject
{
public double Value { get; init; }
public Latitude(double value)
{
if (value is < -90 or > 90)
throw new ArgumentOutOfRangeException(nameof(value), "Latitude must be between -90 and 90 degrees.");
Value = value;
}
protected override IEnumerable<IComparable> GetEqualityComponents()
{
yield return Value;
}
}
public class Longitude : ValueObject
{
public double Value { get; init; }
public Longitude(double value)
{
if (value is < -180 or > 180)
throw new ArgumentOutOfRangeException(nameof(value), "Longitude must be between -180 and 180 degrees.");
Value = value;
}
protected override IEnumerable<IComparable> GetEqualityComponents()
{
yield return Value;
}
}
As you can see in the code provided above, I used a ValueObject base class because it facilitates the correct implementation of value objects within a domain model. Value objects are critical for capturing and encapsulating values along with their associated behavior without the need for a unique identity. This class ensures that value objects are compared based on their content and properties, not their memory references, in accordance with the DDD principle that value objects should be equal if all their attributes are equal.
Value objects must be immutable; once created, their state cannot change. They do not have their own lifecycle. This immutability is critical because it ensures that the object’s hash code remains consistent, an essential aspect for collections. In addition, immutability makes the system more predictable and easier to reason about, since value objects can be safely shared between different parts of the application without the risk of unintended changes.
public abstract class ValueObject
{
public override bool Equals(object obj)
{
if (obj is not ValueObject other)
{
return false;
}
return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
}
public static bool operator ==(ValueObject a, ValueObject b)
{
if (ReferenceEquals(a, null) && ReferenceEquals(b, null))
return true;
if (ReferenceEquals(a, null) || ReferenceEquals(b, null))
return false;
return a.Equals(b);
}
public static bool operator !=(ValueObject a, ValueObject b)
{
return !(a == b);
}
public override int GetHashCode()
{
return GetEqualityComponents()
.Select(x => x?.GetHashCode() ?? 0)
.Aggregate((x, y) => x * 23 + y);
}
protected abstract IEnumerable<object> GetEqualityComponents();
}
In this revised version, as shown below, BusinessLocation uses the specific value objects for Longitude and Latitude (and also for Radius), enhancing the expressiveness and integrity of the domain model. This change ensures that any instance of BusinessLocation will always have valid coordinates, using the encapsulated validation logic within the specific value objects.
public class BusinessLocation : IAggregateRoot
{
private IList<Customer> _customers;
private BusinessLocation(Guid id, Guid tenantId, Longitude longitude, Latitude latitude, Radius radius, IList<Customer>? customers)
{
Id = id;
TenantId = tenantId;
Longitude = longitude;
Latitude = latitude;
Radius = radius;
_customers = customers ?? new List<Customer>();
}
// Public properties
public Guid Id { get; }
public Guid TenantId { get; }
public Longitude Longitude { get; }
public Latitude Latitude { get; }
public Radius Radius { get; private set; }
// Factory method
public static BusinessLocation CreateNew(Guid id, Guid tenantId, double longitude, double latitude, double radius, IList<Customer> customers = null)
{
return new BusinessLocation(id,
tenantId,
new Longitude(longitude),
new Latitude(latitude),
new Radius(radius),
customers);
}
// Other Methods...
}
In refactoring the BusinessLocation aggregate root to use value objects instead of simple types, we took several key steps to improve the domain model in accordance with DDD principles, focusing in particular on encapsulation, immutability, and robustness of the domain model. Here’s a summary of the key actions and what they’re for:
- Introduced value objects for coordinates: I have replaced simple double types for longitude and latitude with dedicated value objects. This change ensures that the coordinates are not just raw numbers, but have domain-specific meaning and behavior, including validation logic that ensures the coordinates are within valid ranges.
- Encapsulating validation logic: By moving the validation logic into the value objects (latitude and longitude coordinates), we ensure that each instance of these objects is valid according to the domain rules. This approach prevents invalid data from being created or manipulated within our domain model, thereby enforcing business invariants.
- Immutability: The value objects are designed to be immutable, meaning that once an instance is created, its state cannot be changed. This property is critical to maintaining consistency and predictability within the domain model, as it prevents side effects from unintended changes.
- Improved domain model expressiveness: Using value objects instead of primitives makes the domain model more expressive. It becomes clearer to developers and domain experts alike what each part of the model represents and how it behaves. For example, a coordinate value object immediately conveys its purpose and constraints, unlike a simple double.
- Improved code structure and maintenance: Refactoring results in a cleaner, more maintainable code base. Value objects can encapsulate complex behaviors and validations, reducing duplication and separating concerns within the domain model. This modular approach simplifies future changes and extensions to the domain logic.
- Ensure domain consistency: By using value objects, we ensure that all instances of BusinessLocation and its components consistently adhere to domain rules and constraints throughout the application. This consistency is critical to maintaining the integrity of the domain model and the correctness of its operations.
In summary, the refactoring process has significantly improved the design and implementation of the BusinessLocation aggregate root by introducing value objects. This enhancement not only strengthens the expressiveness and integrity of the domain model, but also aligns with DDD best practices, making the model more robust, maintainable, and aligned with the business domain.
Cheers!