Today I’d like to talk about Value Objects vs Primitives and when you’d want to use a Value Object over a Primitive. The longer you apply DDD to your software the better you become at spotting when to use one over the other. So far my personal experience has proven that many times it’s better to use a Value Object even when at a glance a Primitive seems like it gets the job done. Though at the end of the day it really depends on your business requirements, your domain and your product(s).
If you haven’t already go back and read Value Objects: The Basics.
Let’s review a simple requirement like storing a bool
flag. Even a requirement as simple as this can trick you into thinking you don’t need a Value Object when in fact you do. Let me explain a common real world scenario I have on my mind in more detail.
Say you have a business requirement to track email marketing opt-in for users. Creating a bool
property on the User
entity will no doubt be enough to satisfy these requirements. Going without a Value Object and instead opting for a public setter is the best route and offers utmost model simplicity, as seen below.
namespace Company.Domain.User;
public class User : IAggregateRoot
{
public Guid Id { get; private set; }
public UserLegalName Name { get; private set; }
public bool EmailMarketingOptIn { get; set; }
public User(UserLegalName name)
{
Id = Guid.NewGuid();
Name = name;
}
}
A few notes about the above implementation. It’s completely preferential whether to use a public setter on bool EmailMarketingOptIn
or a private setter with a public method that does more, like raise a Domain Event. Whether to use one over the other is going to boil down to your specific business requirements and your teams preferences.
Let’s amend the above requirements and see how it impacts our decision to use Value Objects vs Primitives change. The new business requirement is to track a user’s email marketing opt-in along with the date the user opted in/out.
Using Primitive Properties to Capture Opt-in Flag and Timestamp (Using Public Setters)
This implementation will always invalidate the model because a lack of consistency guard rails. In other words the model below allow setting the opt-in status without setting the timestamp. The model above causes our domain entity to be nothing more than a glorious DTO using public get-set in our domain model which also has a side effect of anemic domain model.
namespace Company.Domain.User;
public class User : IAggregateRoot
{
public Guid Id { get; private set; }
public UserLegalName Name { get; private set; }
public bool EmailMarketingOptIn { get; set; }
public DateTime EmailMarketingOptInTimestamp { get; set; }
public User(UserLegalName name)
{
Id = Guid.NewGuid();
Name = name;
}
}
Additionally if we need to emit a domain event when changing marketing preferences we’d run into inconveniences. We’d need to raise the event in only the EmailMarketingOptIn
setter which feels wrong because it allows the timestamp to change without raising the event. Or raise it in both setters for EmailMarketingOptIn
and EmailMarketingOptInTimestamp
, but this would result in duplicate events for the same action.
We would interact with the model like so.
namespace Company.Api.Controllers;
public class UserController : BaseController
{
private IUserRepository _userRepository;
public UserController(IUserRepository userRepository)
{
_userRepository = userRepository;
}
[HttpPatch]
public void ChangeMarketingPreference(Guid userId, bool optIn)
{
var user = _userRepository.UserOfId(userId);
user.EmailMarketingOptIn = optIn;
// It's entirely probable the timestamp assignment would be forgotten about
user.EmailMarketingOptInTimestamp = DateTime.UtcNow;
_userRepository.Save();
}
}
Most developers tend to use the above approach for similar business requirements. Unfortunately this implementation is like a row boat with lots of holes.
To review here are the pros and cons for this implementation.
Pros
- Simplicity, no additional classes and no additional methods within
User
. - Fewer validation code to write to force adherence to business requirements.
- Business requirements are satisfied. I mean, we ARE tracking the opt-in and its timestamp at the end of the day…
Cons
- Business requirements are not fully protected.
- No guarantee
EmailMarketingOptIn
will be updated whenEmailMarketingOptInTimestamp
is updated. - No guarantee
EmailMarketingOptInTimestamp
will be updated whenEmailMarketingOptIn
is updated. - The consumer of
User
must be aware thatEmailMarketingOptInTimestamp
has to be updated when changingEmailMarketingOptIn
. - There’s no clear cut spot for raising a domain event within the
User
entity when the email marketing preferences change.
Using Primitive Properties to Capture Opt-in Flag and Timestamp (Using Private Setters)
This implementation is slightly better than the previous because the public method names capture the ubiquitous language a little better and alleviates the consumer of the User
entity from the opt-in timestamp requirement.
namespace Company.Domain.User;
public class User : IAggregateRoot
{
public Guid Id { get; private set; }
public UserLegalName Name { get; private set; }
public bool EmailMarketingOptIn { get; private set; }
public DateTime EmailMarketingOptInTimestamp { get; private set; }
public User(UserLegalName name)
{
Id = Guid.NewGuid();
Name = name;
}
public void ChangeEmailMarketingPreference(bool optIn)
{
EmailMarketingOptIn = optIn;
EmailMarketingOptInTimestamp = DateTime.UtcNow;
DomainEvents.Raise(new EmailMarketingPreferencesChangedEvent(Id));
}
}
namespace Company.Api.Controllers;
public class UserController : BaseController
{
private IUserRepository _userRepository;
public UserController(IUserRepository userRepository)
{
_userRepository = userRepository;
}
[HttpPatch]
public void ChangeEmailMarketingPreference(Guid userId, bool optIn)
{
var user = _userRepository.UserOfId(userId);
user.ChangeEmailMarketingPreference(optIn);
_userRepository.Save();
}
}
I totally accept this last implementation as a reasonable approach because it protects our business requirements. What I like about this method over the last is it doesn’t rely on the User
entity consumer to know it needs to keep the timestamp up to date when changing email marketing preferences. In fact there’s nothing wrong or better with this approach compared to the next solution. One consideration to keep in mind. Even with this approach you still rely on the internals of the User entity to keep the opt-in timestamp current. This issue is solved with Value Objects because changing one opt-in property require both to be specified.
This leads us into the next solution, using a Value Object.
To review here are the pros and cons for this implementation.
Pros
- Technically simpler than the Value Object approach. No additional classes to manage and map at the repository layer.
- Changing the bool opt-in preference automatically updates the timestamp behind the scenes.
- The opt-in timestamp requirement is opaque to the
User
consumer. - Business requirements are satisfied.
Cons
- We still rely on developers maintaining the internals of
User
to remember to keep the opt-in timestamp consistent with the opt-inbool
changes.
Using a Value Object to Capture Opt-in Flag and Timestamp
namespace Company.Domain.User;
public class EmailMarketingPreference : ValueObjectBase<EmailMarketingPreference>
{
public bool OptIn { get; private set; }
public DateTime Timestamp { get; private set; }
public EmailMarketingPreference(bool optIn, DateTime timestamp)
{
OptIn = optIn;
Timestamp = timestamp;
}
protected override IEnumerable<object> GetAtomicValues()
{
yield return OptIn;
yield return Timestamp;
}
}
namespace Company.Domain.User;
public class User : IAggregateRoot
{
public Guid Id { get; private set; }
public UserLegalName Name { get; private set; }
public EmailMarketingPreference EmailMarketingOptIn { get; private set; }
public User(UserLegalName name)
{
Id = Guid.NewGuid();
Name = name;
}
public void ChangeEmailMarketingPreference(bool optIn)
{
EmailMarketingOptIn = new EmailMarketingPreference(optIn, DateTime.UtcNow);
DomainEvents.Raise(new EmailMarketingPreferencesChangedEvent(Id));
}
}
namespace Company.Api.Controllers;
public class UserController : BaseController
{
private IUserRepository _userRepository;
public UserController(IUserRepository userRepository)
{
_userRepository = userRepository;
}
[HttpPatch]
public void ChangeEmailMarketingPreference(Guid userId, bool optIn)
{
var user = _userRepository.UserOfId(userId);
user.ChangeEmailMarketingPreference(optIn);
_userRepository.Save();
}
}
Because the new requirements state the user’s email marketing opt-in must always be accompanied by a timestamp it should never be acceptable to allow changing the opt-in status without specifying a timestamp at the same time. Hence bringing in a Value Object to enforce this hardens the domain by protecting the business requirements.
To review here are the pros and cons for this implementation.
Pros
- Changing the bool opt-in preference automatically updates the timestamp behind the scenes.
- The opt-in timestamp requirement is opaque to the
User
consumer. - We no longer rely on developers remembering to remember to keep the opt-in timestamp consistent with the opt-in
bool
changes. - Business requirements are satisfied.
Cons
- Additional repository mapping needed to map the Value Object to the persistence layer.
- An additional class needs to be created/managed. Though arguably this class may not change very often.
Conclusion
So in conclusion there’s no correct answer in the Value Objects vs Primitives debate. It boils down to your business requirements that drive your product and the underlying domain model that makes the product work. Continually applying DDD in your ever day life will definitely make you better at identifying when to use a Value Object over a primitive and it helps to know your market so you can stay one step ahead of your business stake holders.
For example using the example in this article. You can predict the probability of a requirement change to track an opt-in timestamp if you’re in a market that emphasizes end user privacy and transparency.