Save your reputation, build better Value Objects in .NET
![Concrete walls casting shadows.](/content/images/size/w2000/2024/07/atelier-david-telerman-iwan-baan-mcneal-020.jpg)
Yes, I'm serious. Well written Value Objects are one of the most valuable skills you can learn as a software developer. There's a reason why Primitive Obsession is so popular.
Value Objects is one of the most powerful concepts that should be at the core of every developer's skill set. Unfortunately, most don't understand just how important they really are.
Most developers are flooded with short-form content on LinkedIn, Instagram, Twitter, and YouTube that try to click-bait attention for popularity points. Very few content creators take the time and effort to walk other developers through the deep dive needed to really learn and understand these concepts.
So take the time with me to explore why Value Objects are so important, how to build better Value Objects in .NET, and why this will make you a better software developer that will earn you clout and respect.
But first, some history!
Value Objects and the shadow they live under
Although it may have been popularized in 1999 by Martin Fowler with the publishing of his book Refactoring: Improving the Design of Existing Code, the concept of Value Objects is far from a new one.
As most things in history, the world forgets, someone brings something old out of the shadow, and the original authors are forgotten about until the pattern repeats itself again.
Value Objects are one of those things.
You see, there's been a resurgence of popularity around Value Objects lately. There are plenty of YouTube videos, wiki articles, blog posts, and talks. Even Entity Framework Core recently improved support for Value Objects with the release of Complex Types.
Throughout that content, sometimes Martin Fowler's 2002 book Patterns of Enterprise Application Architecture will be referenced, but most often, Value Objects tend to live in the shadow of the very well known book Domain-Driven Design written in 2003 by Eric Evans.
There's a reason why I say in the shadow of DDD.
The concept of Value Objects is much older than the early 2000s. We're just at the peak of another cycle of history.
The core concept and origins of Value Objects
So you've heard of SOLID right? Remember the L? Liskov's substitution principle?
The Liskov is for Barbara Liskov. She wrote a paper at MIT in 1974 that was titled Programming with Abstract Data Types in which she laided out the foundational concepts that would eventually evolve into the modern Value Object.
What makes Value Objects so important
Everyone who's written any decent amount of software knows that a lot of what is software development revolves around data. Inputing data, outputing data, manipulating data, calculating data, aggregating data, comparing data, reporting data, and the list goes on.
Most of our time as developers is spent around data in our systems.
- Validating input before it enters our system.
- Organizing data into objects to work with it logically.
- Storing and indexing it efficiently in databases.
- Running routines on that data to generate reports.
We also spend a considerable amount of effort to ensure the integrity of that data. If users can't trust the data then they won't trust the system, and if you're a developer on that system, that means that its users won't trust you either.
Value Objects are so crucial to software because they're built around trust.
Most .NET developers won't use float
because they don't trust it. They'd rather use decimal
because of the inherit nature of real numbers that causes floating point errors.
At it's core, a Value Object is "a small simple object, like money or a date range, whose equality isn't based on identity". But as Martin Fowler points out when using Money as an example:
A large proportion of the computers in this world manipulate money, so it's always puzzled me that money isn't actually a first class data type in any mainstream programming language. The lack of a type causes problems, the most obvious surrounding currencies. If all your calculations are done in a single currency, this isn't a huge problem, but once you involve multiple currencies you want to avoid adding your dollars to your yen without taking the currency differences into account. The more subtle problem is with rounding. Monetary calculations are often rounded to the smallest currency unit. When you do this it's easy to lose pennies (or your local equivalent) because of rounding errors.
Which again comes back to trust.
Value Objects serve as a mechanism to build trust in the data of your system.
How to create Value Objects that build trust
There are two rules I use when I design Value Objects to ensure that trust is inherently built into them.
Never allow a Value Object to exist with an invalid value
Knowing that a Value Object cannot exist if its value is invalid allows you to implicitly trust its usage everywhere else in your code base. It doesn't matter if the value comes from a user's input or from the database, its very nature means you can rely on the fact that any instance of the Value Object you encounter will always contain valid data for its type.
Never allow a Value Object to be mutated
Not allowing a Value Object to mutate also means that you can be confident that it's value did not change when passed around to other methods. No matter where the value originated, any particular instance of a Value Object will always contain the same valid value throughout its lifetime.
Building the Value Object
And keeping it efficient at the same time
I won't go into the the details of what the heap and stack are, but you should familiarize yourself with the difference between value types and reference types. Most built-in types are value types with the exception of string
and object
. We'll be using C# 12.0 for all of the code example, so if you're wondering where the braces went, that's why.
How to build an Email Address Value Object
I'm going to use an email address as an example of how to build a solid Value Object that builds the trust we are looking for in its value, while also being efficient and performant.
To struct or not to struct
The very first choice we need to make is whether to build a struct
or a class
based Value Object. What should influence your decision primarily is the underlying type of the value that Value Object will contain. If the underlying value of your Value Object is a value type, then use a struct
. If it's a reference type, use a class
. This will allow you to use it in the same way with a similar performance envelope as the underlying type would offer.
An email address is a string
, which is a reference type.
public class EmailAddress;
Seal it!
A Value Object should never inherit from another Value Object. Value Objects, like the values they contain, should only ever exist as a unit. This will also ensure that any behavior that is later associated with that Value Object is not influenced by any base or derived types.
public sealed class EmailAddress;
Don't let anyone look under the hood
We need to store the underlying value inside the Value Object, but you should never expose it directly. Doing so would allow other developers to simply bypass all of the protections built into it by accessing the value directly.
public sealed class EmailAddress
{
private readonly string _emailAddress;
}
Creating new instances the right way
Refering back to the first rule "Never allow a Value Object to exist with an invalid value" means we need to validate any strings used to create new instances of the email address Value Object. Don't validate in the constructor, constructors aren't designed for this. As stated in the C# documentation, constructors are for setting default values and limit instantiation. Instead, use a Factory method.
public sealed class EmailAddress
{
private static Regex _emailValidation = new(@".+\@.+\..+");
private readonly string _emailAddress;
private EmailAddress(string emailAddress)
{
_emailAddress = emailAddress;
}
public static EmailAddress Create(string value)
{
if (_emailValidation.IsMatch(value))
throw new ArgumentException("Value is not a valid email address.");
return new EmailAddress(value);
}
}
struct
for EmailAddress would haved allowed validation to be bypassed very easily. Since structs are value types, any default
instance of EmailAddress would cause the underlying string to be initialized to null
.Adding better validation
What is a valid email address is a highly subjective and relative topic. A lot of which depends on your use case. If your system is meant to act as an SMTP server, then you'll want to accept what is valid according to the RFC including odd-ball addresses like postmaster@[123.123.123.123]
. Instead, what you'll most likely want to accept is what's mostly regarded as a standard email address with a domain and tld such as very.common@example.com
.
A simple Regex such as the one above of .+@.+..+
isn't really going to cut it. There are some monstreous Regex examples out there such as this RFC 5322 compliant one. But a much better approach is to use a variety of methods to fail fast whenever the value is invalid.
- The simplest and fastest indication that an email address is invalid is the absense of the @ symbol.
- An empty string or a string that exceeds 320 characters is also an invalid email address. Specifically, RFC 2822 mentions that the local part of an email address must not exceed 64 characters and the domain part must not exceed 255 characters. So adding 64 to 255, plus 1 to account for the @ symbol, is how we arrive at the total of 320.
- We can also validate the local part and the domain part individually.
- An empty string or a string that exceeds 64 characters for the local part would make it invalid.
- We can then validate the local part with some basic Regex to match our expected patterns.
- An empty string or a string that exceeds 320 characters for the domain part would make it invalid as well.
- The domain can then be validated in two steps. First using Regex to determine that it's a fully qualified domain name (FQDN), then using the official IANA list of all valid top level domains (TLDs).
public sealed partial class EmailAddress
{
private static readonly FrozenSet<string> IanaTlds = GetIanaTlds();
private readonly string _emailAddress;
private EmailAddress(string emailAddress)
{
_emailAddress = emailAddress;
}
public static EmailAddress Create(string value)
{
if (value.Length is 0 or > 320)
throw new ArgumentException("Value is not a valid email address.");
var span = value.AsSpan();
var indexOf = value.IndexOf('@');
if (indexOf == -1)
throw new ArgumentException("Value is not a valid email address.");
var localPart = span[..indexOf];
if (localPart.Length is 0 or > 64)
throw new ArgumentException("Value is not a valid email address.");
if (!LocalPartRegex().IsMatch(localPart))
throw new ArgumentException("Value is not a valid email address.");
var domainPart = span[(indexOf + 1)..];
if (domainPart.Length is 0 or > 255)
throw new ArgumentException("Value is not a valid email address.");
if (!DomainFqdnRegex().IsMatch(domainPart))
throw new ArgumentException("Value is not a valid email address.");
var tld = DomainTldRegex().Match(value).Groups[1].Value;
if (!IanaTlds.Contains(tld))
throw new ArgumentException("Value is not a valid email address.");
return new EmailAddress(value);
}
[GeneratedRegex(@"^[a-z0-9]+([._+-][a-z0-9]+)*$")]
private static partial Regex LocalPartRegex();
[GeneratedRegex(@"^(?!-)(?:[a-z0-9-]{1,63}|xn--[a-z0-9]{1,59})(?<!-)(?:\.(?!-)(?:[a-z0-9-]{1,63}|xn--[a-z0-9]{1,59})(?<!-))*\.[a-z]{2,}$")]
private static partial Regex DomainFqdnRegex();
[GeneratedRegex(@"\.((?:xn--)?[a-z]{2,})$")]
private static partial Regex DomainTldRegex();
private static FrozenSet<string> GetIanaTlds()
{
var hashSet = new HashSet<string>
{
"com",
"net",
"org",
};
return hashSet.ToFrozenSet();
}
}
Span
, GeneratedRegex
, and FrozenSet
as ways to increase the performance and efficiency of the validation.Eliminating Exceptions
I absolutely hate Exceptions. I've written about it in the past. They're messy, they're slow, they're difficult to debug, and offer very little contextual information that can easily be communicated to other parts of the system. Using LightResults, we can replace all of the ArgumentException
s with a failed Result<T>
that adds more context to the error and return it through a TryCreate
method.
public static Result<EmailAddress> TryCreate(string value)
{
switch (value.Length)
{
case 0:
return Result.Fail<EmailAddress>("Value must not be empty.");
case > 320:
return Result.Fail<EmailAddress>("Value must not exceed 320 characters.");
}
var span = value.AsSpan();
var indexOf = value.IndexOf('@');
if (indexOf == -1)
return Result.Fail<EmailAddress>("Value is not a valid email address.");
var localPart = span[..indexOf];
switch (localPart.Length)
{
case 0:
return Result.Fail<EmailAddress>("The local part of the email address must not be empty.");
case > 64:
return Result.Fail<EmailAddress>("The local part of the email address must not exceed 64 characters.");
}
if (!LocalPartRegex().IsMatch(localPart))
return Result.Fail<EmailAddress>("Value is not a valid email address.");
var domainPart = span[(indexOf + 1)..];
switch (domainPart.Length)
{
case 0:
return Result.Fail<EmailAddress>("The domain part of the email address must not be empty.");
case > 255:
return Result.Fail<EmailAddress>("The domain part of the email address must not exceed 255 characters.");
}
if (!DomainFqdnRegex().IsMatch(domainPart))
return Result.Fail<EmailAddress>("The domain part of the email address is not a valid fully qualified domain name.");
var tld = DomainTldRegex().Match(value).Groups[1].Value;
if (!IanaTlds.Contains(tld))
return Result.Fail<EmailAddress>("The domain part of the email address does not end with a valid top level domain.");
return new EmailAddress(value);
}
Don't make people hate you
Strict validation is both important and necessary to ensure the integrity of data and is the whole premise behind Value Objects. Nonetheless, if you do not provide a frictionless way of creating a Value Object that's flexible to input variations, both your users and your fellow developers will hate you. Don't be lazy, add a Parse method that does everything it reasonably can to try to understand the input and return a valid Value Object.
You'll notice that the TryCreate
method is completely inflexible. It doesn't accept mixed case or upper case email addresses and it won't even accept whitespace before or after. This is intentional. Because creation is supposed to be a very high frequency operation, it has to be as efficient as possible. We do not want to skip validation because we want to protect ourselves from bad values no matter where they come from, even if it's from the database. Nothing prevents an administrator from manually updating a value in the database. Never assume that the data used to create a Value Object can be trusted. Parse methods are where flexibility is meant to be introduced and can afford to be less efficient.
private static readonly ParserOptions ParserOptions = new() { AllowAddressesWithoutDomain = false };
public static Result<EmailAddress> TryParse(string str)
{
var value = str.Trim().ToLowerInvariant();
if (!MailboxAddress.TryParse(ParserOptions, value, out var mailboxAddress))
{
return Result.Fail<EmailAddress>("Value is not a valid email address.");
}
return TryCreate(mailboxAddress.Address);
}
Accessing the value without exposing it
If the Value Object contains only a single underlying value, then it should never be directly accessed nor exposed as this will lead other developers to use the underlying value directly. Instead, expose methods that will allow the use of the underlying value in ways that are appropriate for it such a ToType
method.
In the case of our email address Value Object, that means a ToString
method.
public override string ToString()
{
return _emailAddress;
}
Comparing references types is not comparing its values
Be careful with reference types, when two instances are compared, their references are compared, not their values. Which means if you create two Value Objects that are class
es and you check that they are the same using ==
, even if they have the same underlying values, the comparison will come back as false.
To solve this issue, add the IEquatable<T>
interface and its related operators to your Value Object.
public sealed partial class EmailAddress : IEquatable<EmailAddress>
{
public bool Equals(EmailAddress? other)
{
if (ReferenceEquals(null, other))
return false;
if (ReferenceEquals(this, other))
return true;
return _emailAddress == other._emailAddress;
}
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is EmailAddress other && Equals(other);
}
public override int GetHashCode()
{
return _emailAddress.GetHashCode();
}
public static bool operator ==(EmailAddress? left, EmailAddress? right)
{
return Equals(left, right);
}
public static bool operator !=(EmailAddress? left, EmailAddress? right)
{
return !Equals(left, right);
}
}
Order matters
Don't be lazy. If your underlying value should be sortable, add the IComparable<T>
and IComparable
interfaces along with its related operators.
public sealed partial class EmailAddress : IComparable<EmailAddress>, IComparable
{
public int CompareTo(EmailAddress? other)
{
if (ReferenceEquals(this, other))
return 0;
if (ReferenceEquals(null, other))
return 1;
return string.Compare(_emailAddress, other._emailAddress, StringComparison.Ordinal);
}
public int CompareTo(object? obj)
{
if (ReferenceEquals(null, obj))
return 1;
if (ReferenceEquals(this, obj))
return 0;
return obj is EmailAddress other ? CompareTo(other) : throw new ArgumentException($"Object must be of type {nameof(EmailAddress)}");
}
public static bool operator <(EmailAddress? left, EmailAddress? right)
{
return Comparer<EmailAddress>.Default.Compare(left, right) < 0;
}
public static bool operator >(EmailAddress? left, EmailAddress? right)
{
return Comparer<EmailAddress>.Default.Compare(left, right) > 0;
}
public static bool operator <=(EmailAddress? left, EmailAddress? right)
{
return Comparer<EmailAddress>.Default.Compare(left, right) <= 0;
}
public static bool operator >=(EmailAddress? left, EmailAddress? right)
{
return Comparer<EmailAddress>.Default.Compare(left, right) >= 0;
}
}
A well rounded implementation
That's it! Now we have a complete implementation of an Email Address Value Object that ensures it can never exist without a value email address, it does so in an efficient way, allows for flexibility in input, and mimics the behavior you'd expect from its underlying type.
public sealed partial class EmailAddress : IEquatable<EmailAddress>, IComparable<EmailAddress>, IComparable
{
private static readonly FrozenSet<string> IanaTlds = GetIanaTlds();
private static readonly ParserOptions ParserOptions = new() { AllowAddressesWithoutDomain = false };
private readonly string _emailAddress;
private EmailAddress(string emailAddress)
{
_emailAddress = emailAddress;
}
public static EmailAddress Create(string value)
{
if (TryCreate(value).IsFailed(out var error, out var emailAddress))
throw new ArgumentException(error.Message, nameof(value));
return emailAddress;
}
public static Result<EmailAddress> TryCreate(string value)
{
switch (value.Length)
{
case 0:
return Result.Fail<EmailAddress>("Value must not be empty.");
case > 320:
return Result.Fail<EmailAddress>("Value must not exceed 320 characters.");
}
var span = value.AsSpan();
var indexOf = value.IndexOf('@');
if (indexOf == -1)
return Result.Fail<EmailAddress>("Value is not a valid email address.");
var localPart = span[..indexOf];
switch (localPart.Length)
{
case 0:
return Result.Fail<EmailAddress>("The local part of the email address must not be empty.");
case > 64:
return Result.Fail<EmailAddress>("The local part of the email address must not exceed 64 characters.");
}
if (!LocalPartRegex().IsMatch(localPart))
return Result.Fail<EmailAddress>("Value is not a valid email address.");
var domainPart = span[(indexOf + 1)..];
switch (domainPart.Length)
{
case 0:
return Result.Fail<EmailAddress>("The domain part of the email address must not be empty.");
case > 255:
return Result.Fail<EmailAddress>("The domain part of the email address must not exceed 255 characters.");
}
if (!DomainFqdnRegex().IsMatch(domainPart))
return Result.Fail<EmailAddress>("The domain part of the email address is not a valid fully qualified domain name.");
var tld = DomainTldRegex().Match(value).Groups[1].Value;
if (!IanaTlds.Contains(tld))
return Result.Fail<EmailAddress>("The domain part of the email address does not end with a valid top level domain.");
return new EmailAddress(value);
}
public static EmailAddress Parse(string str)
{
if (TryParse(str).IsFailed(out var error, out var emailAddress))
throw new ArgumentException(error.Message, nameof(str));
return emailAddress;
}
public static Result<EmailAddress> TryParse(string str)
{
var value = str.Trim().ToLowerInvariant();
if (!MailboxAddress.TryParse(ParserOptions, value, out var mailboxAddress))
{
return Result.Fail<EmailAddress>("Value is not a valid email address.");
}
return TryCreate(mailboxAddress.Address);
}
public override string ToString()
{
return _emailAddress;
}
public bool Equals(EmailAddress? other)
{
if (ReferenceEquals(null, other))
return false;
if (ReferenceEquals(this, other))
return true;
return _emailAddress == other._emailAddress;
}
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is EmailAddress other && Equals(other);
}
public override int GetHashCode()
{
return _emailAddress.GetHashCode();
}
public static bool operator ==(EmailAddress? left, EmailAddress? right)
{
return Equals(left, right);
}
public static bool operator !=(EmailAddress? left, EmailAddress? right)
{
return !Equals(left, right);
}
public int CompareTo(EmailAddress? other)
{
if (ReferenceEquals(this, other))
return 0;
if (ReferenceEquals(null, other))
return 1;
return string.Compare(_emailAddress, other._emailAddress, StringComparison.Ordinal);
}
public int CompareTo(object? obj)
{
if (ReferenceEquals(null, obj))
return 1;
if (ReferenceEquals(this, obj))
return 0;
return obj is EmailAddress other ? CompareTo(other) : throw new ArgumentException($"Object must be of type {nameof(EmailAddress)}");
}
public static bool operator <(EmailAddress? left, EmailAddress? right)
{
return Comparer<EmailAddress>.Default.Compare(left, right) < 0;
}
public static bool operator >(EmailAddress? left, EmailAddress? right)
{
return Comparer<EmailAddress>.Default.Compare(left, right) > 0;
}
public static bool operator <=(EmailAddress? left, EmailAddress? right)
{
return Comparer<EmailAddress>.Default.Compare(left, right) <= 0;
}
public static bool operator >=(EmailAddress? left, EmailAddress? right)
{
return Comparer<EmailAddress>.Default.Compare(left, right) >= 0;
}
[GeneratedRegex(@"^[a-z0-9]+([._+-][a-z0-9]+)*$")]
private static partial Regex LocalPartRegex();
[GeneratedRegex(@"^(?!-)(?:[a-z0-9-]{1,63}|xn--[a-z0-9]{1,59})(?<!-)(?:\.(?!-)(?:[a-z0-9-]{1,63}|xn--[a-z0-9]{1,59})(?<!-))*\.[a-z]{2,}$")]
private static partial Regex DomainFqdnRegex();
[GeneratedRegex(@"\.((?:xn--)?[a-z]{2,})$")]
private static partial Regex DomainTldRegex();
private static FrozenSet<string> GetIanaTlds()
{
var hashSet = new HashSet<string>
{
"com",
"net",
"org",
};
return hashSet.ToFrozenSet();
}
}
Using it with Entity Framework
We can easily integrate our email address Value Object with Entity Framework just as we would with any other primitive type.
All we need is a ValueConverter
that converts between our EmailAddress
and a string
.
public class EmailAddressConverter : ValueConverter<EmailAddress, string>
{
public EmailAddressConverter()
: base(emailAddress => emailAddress.ToString(), value => EmailAddress.Create(value))
{
}
}
Which we can then configure against all of our entities using a convention.
public class MyDbContext : DbContext
{
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Properties<EmailAddress>()
.HaveConversion<EmailAddressConverter>()
.HaveColumnType("varchar")
.HaveMaxLength(320);
}
}
Now that you have a better understanding on how to build better Value Objects, let me know what you think by finding me online or leaving me a comment below!