In my journey as a developer working with .NET and C#, I constantly encounter the use of primitive types like integers, strings, and booleans. These types are simple, and we often use them to represent concepts in our domain, such as a person’s name, email address, or age. However, this approach can introduce a subtle but persistent issue known as Primitive Obsession. This is when I use basic types to represent more complex domain concepts, and while it may seem efficient, it can lead to problems as my project grows in size and complexity.
In this post, I want to dive into how I can solve the issue of primitive obsession by introducing Value Objects, a concept from Domain-Driven Design (DDD). I'll explore the benefits they bring, demonstrate how to implement them using C# 12 and .NET 8, and explain how they improve code structure while enforcing constraints.
What is Primitive Obsession
Primitive obsession occurs when I use basic data types to represent domain concepts that are more intricate than a simple integer or string. Let me explain it with an example from my own experience.
Imagine I have a Member
entity with properties such as FirstName
, LastName
, and Email
. Initially, I might define these properties as strings:
public class Member
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public Member(string firstName, string lastName, string email)
{
FirstName = firstName;
LastName = lastName;
Email = email;
}
}
While this looks simple, it's easy to make mistakes when passing values to the constructor. For instance, I could accidentally switch the order of the parameters, and the code would still compile without errors, assigning incorrect values to the properties. I could even pass invalid data, like an improperly formatted email.
By using primitive types, I’m missing the opportunity to enforce business rules, such as restricting the maximum length of a name or ensuring an email is valid. This is where Value Objects come into play.
Introducing Value Objects
A Value Object represents a domain concept through its values rather than its identity. Unlike entities, which are defined by an ID, value objects are immutable and defined by the data they contain. If two value objects have the same data, they are considered equal.
I decided to use value objects to replace primitives in my Member
entity and encapsulate the domain rules for each concept. Here’s how I implemented a FirstName
value object:
Step 1 - Value Object Base Class
To ensure all value objects follow a consistent structure, I first created an abstract ValueObject
base class. This class ensures that equality is based on values, not reference identity.
public abstract class ValueObject
{
protected abstract IEnumerable<object> GetAtomicValues();
public override bool Equals(object? obj)
{
if (obj == null || obj.GetType() != GetType())
return false;
var valueObject = (ValueObject)obj;
return GetAtomicValues().SequenceEqual(valueObject.GetAtomicValues());
}
public override int GetHashCode()
{
return GetAtomicValues()
.Aggregate(0, (hashCode, value) =>
{
return HashCode.Combine(hashCode, value);
});
}
}
This abstract class ensures that equality comparisons and hash codes for all value objects are based on their internal values. The GetAtomicValues
method is implemented in each value object to return the values that define it.
Step 2 - FirstName Value Object
Now that I had the base class, I could implement FirstName
as a value object. In this object, I enforce domain rules like a maximum length of 50 characters and ensure the value is not null or empty.
public sealed class FirstName : ValueObject
{
private const int MaxLength = 50;
public string Value { get; }
private FirstName(string value)
{
Value = value;
}
public static Result<FirstName> Create(string value)
{
if (string.IsNullOrWhiteSpace(value))
return Result.Failure<FirstName>("First name cannot be empty.");
if (value.Length > MaxLength)
return Result.Failure<FirstName>($"First name cannot exceed {MaxLength} characters.");
return Result.Success(new FirstName(value));
}
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
}
The Create
method acts as a factory method that handles the validation. If the validation fails, I return an error wrapped in a Result<T>
object. This pattern allows me to encapsulate validation logic in the value object itself, rather than relying on external code to handle it.
Step 3 - Member Entity Value Object
With my value object ready, I replaced the FirstName
property in my Member
entity:
public class Member
{
public FirstName FirstName { get; }
public LastName LastName { get; }
public Email Email { get; }
public Member(FirstName firstName, LastName lastName, Email email)
{
FirstName = firstName;
LastName = lastName;
Email = email;
}
}
Now, when creating a new Member
, I must first ensure that the value objects are valid before assigning them to the entity:
var firstNameResult = FirstName.Create("John");
if (firstNameResult.IsFailure)
{
Console.WriteLine(firstNameResult.Error);
return;
}
var member = new Member(firstNameResult.Value, lastName, email);
Additional Benefits of Value Objects
By using value objects, I can now:
Encapsulate Validation - Instead of having validation scattered throughout the application, I centralize it within the value object.
Ensure Immutability - Value objects are immutable, meaning that once they are created, their state cannot change. This reduces potential bugs caused by accidental modifications.
Improve Type Safety - Using specific types for domain concepts like names and emails eliminates the risk of accidentally passing the wrong data to methods.
Structural Equality -
However, it's important to note that using value objects can increase complexity. In scenarios where the domain is simple, using primitives may still be appropriate. The decision to use value objects should be made based on the specific needs of the domain and the trade-off between type safety and complexity.