Implementing a Value Object

In the previous article, I introduced the concept of Value Objects as handy wrappers around primitive C# types. These objects ensure that they represent a valid value and enforce Customer invariants, which are the explicit and implicit rules that are always held. The Value Objects protect the invariants on the property level regardless of any container entity or another value object because they are designed to be reusable by Entities and other container Value Objects. They remove a good deal of uncertainty, carving a subset representing valid states out of the universe of possible values of their primitive types.

We can’t avoid primitive types, as they are the building blocks of our code, but we need to tame them to our needs by rigorously controlling them in Value Objects.

Let’s write code for the value objects from the previous article by starting with the simplest one

public readonly struct CustomerID
{
    public static CustomerID Empty() => new CustomerID(0);
    public static CustomerID NewCustomerID() => new CustomerID((uint)IdGenerator.GetNextId());

    //it can be Guid, string, or any other type chosen based
    //on performance and convenience requirements
    public readonly uint Value;

    private CustomerID(uint value)
    {
        Value = value;
    }

    public override bool Equals(object obj)
    {
        return obj is CustomerID iD && Equals(iD);
    }

    public bool Equals(CustomerID other)
    {
        return Value == other.Value;
    }

    public override int GetHashCode()
    {
        return Value.GetHashCode();
    }

    public static bool operator ==(CustomerID left, CustomerID right)
    {
        return left.Equals(right);
    }

    public static bool operator !=(CustomerID left, CustomerID right)
    {
        return !(left == right);
    }
}

//Guid has the most convenient built-in generator but it may not be performant enough
//on the storage side
internal static class IdGenerator
{
    private static long _currentId = 0;

    // called at application startup using some seeding strategy
    internal static void SetIdGenerator(long seed)
    {
        _currentId = seed;
    }

    internal static long GetNextId()
    {
        return Interlocked.Increment(ref _currentId);
    }
}

Value Objects are typically immutable as they are identified by the values they hold, rather than by some identity or reference in memory. So if you need it to mutate, you just create a new Value Object with changed values replacing the old one. Thus you simplify the code (see previous article for details) and avoid many other potential problems with Value Objects when sharing them or using them in multi-threaded applications. That is why the concept of equality is very important here as it defines how instances of these objects are compared and identified. Since they don’t have an identity, equality is the only way to tell whether two objects represent the same characteristic and that is often handy in all kinds of reconciliation and lookup in the application logic. That makes Value Objects a good choice for sorting, filtering, and finding. C# also encourages to implementation of GetHashCode whenever we implement equality and that makes them great for Dictionary keys or HashSets. Now CustomerId can be easily used for Customer lookup by its ID.

All that equality plumbing can be easily collapsed into a few lines of code with the newer C# syntax. We don’t add any additional invariants as the unit type already represents the intent to store the ID as a positive number.

    public record struct CustomerID(uint Value)
    {
        public static CustomerID Empty() => new CustomerID(0);
        public static CustomerID NewCustomerID() => new CustomerID((uint)IdGenerator.GetNextId());

        // we can still add more code to make specialized constructor private
    }

I digressed though and let’s move on to the Email where we have a bit more handmade constraints.

public record struct Email
{
    private const string EMAIL_PATTERN = @"^[^@\s]+@[^@\s]+\.[^@\s]+$";

    private readonly string _value;

    private Email(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            throw new ArgumentException("Email cannot be null or whitespace.", nameof(value));
        }

        if (!IsValidEmail(value))
        {
            throw new ArgumentException("Invalid email format.", nameof(value));
        }

        _value = value;
    }

    //prohibiting default constructor
    public Email():this(null) { }

    //that is the only entry point to create instance
    public static Email Create(string value)
    {
        return new Email(value);
    }

    public override string ToString()
    {
        return _value;
    }

    private static bool IsValidEmail(string email)
    {
        return Regex.IsMatch(email, EMAIL_PATTERN);
    }

    //adds more incapsulation rather than exposing Value property of string type
    public static implicit operator string(Email email)
    {
        return email._value;
    }

    public static explicit operator Email(string email)
    {
        return Create(email);
    }
}

You see how impossible it is to use a random string as an email now. When writing code for a core domain, design your objects in a way that is impossible to misuse by throwing blunt exceptions on unexpected input or creating always valid specialized types that control the correctness of your code at compilation time. Most of the relevant “clean code” principles are met automatically. We follow DRY (we don’t repeat email validation logic as we can use Email type in other Entities), SOLID (validation is put in one place, and one can inherit from Email to create more specialized versions of them such as a company or personal email, the email record can be used in sorting, filtering, lookups, etc.), KISS (it looks simple, clear, and readable), Fail Fast (it fails right at the boundary of instantiation right in the constructor), YAGNI (it is even lacking Value property as it is not defined in ubiquitous language, i.e. project requirements handed in by domain experts). Also, the object is immutable, which avoids sharing and handling issues in multi-threaded environments.

Here is “CustomerName” Value Object with even more constraints to its attributes

public record struct CustomerName
{
    private const int MAX_NAME_LENGTH = 100;
    private const string NamePattern = @"^[A-Z][a-zA-Z]*$";

    public string FirstName { get; }
    public string LastName { get; }

    private CustomerName(string firstName, string lastName)
    {
        if (string.IsNullOrWhiteSpace(firstName))
        {
            throw new ArgumentException("First name cannot be null or whitespace.", nameof(firstName));
        }

        if (string.IsNullOrWhiteSpace(lastName))
        {
            throw new ArgumentException("Last name cannot be null or whitespace.", nameof(lastName));
        }

        if (firstName.Length > MAX_NAME_LENGTH || lastName.Length > MAX_NAME_LENGTH)
        {
            throw new ArgumentException($"First and last names cannot exceed {MaxLength} characters.");
        }

        if (!IsValidName(firstName) || !IsValidName(lastName))
        {
            throw new ArgumentException("Names must contain only letters and start with a capital letter.");
        }

        FirstName = firstName;
        LastName = lastName;
    }

    //default 
    public CustomerName():this(null, null) { }

    public static CustomerName Create(string firstName, string lastName)
    {
        return new CustomerName(firstName, lastName);
    }

    public override string ToString()
    {
        return $"{FirstName} {LastName}";
    }

    private static bool IsValidName(string name)
    {
        return Regex.IsMatch(name, NamePattern);
    }

    public static implicit operator string(CustomerName customerName)
    {
        return customerName.ToString();
    }

    //adding a shortcut to create name from a single string
    public static explicit operator CustomerName(string fullName)
    {
        var names = fullName.Split(' ');
        if (names.Length != 2)
        {
            throw new ArgumentException("Full name must contain exactly two parts: first name and last name.");
        }
        return Create(names[0], names[1]);
    }
}

It incorporates quite a few restrictions that might not be obvious till you start thinking of all the possible ways the object can be abused by client code. Domain experts may not be that detailed to tell that they need a cap on string length or that the system should not allow special characters in the names but that is your job as a developer to think through all those patterns leading to the combinatorial explosion of invalid or abusive states. You can also check with your stakeholders about all those additional restrictions and limitations and they will tell with 99% certainty that it is a great idea. Never fear to overdo that. It is better to weaken your grip later and remove a couple of constraints (exception throws) than debugging through issues or dealing with malicious hacker attacks exploiting oversites in your model.

Let’s move on to the home address of a customer. We know that at least in the first versions of our system it will be a US address. Let’s code for that and put as many limitations as we could possibly think of.

public record struct USHomeAddress
{
    private const int MaxStreetLength = 100;
    private const int MaxCityLength = 50;
    private const int MaxStateLength = 2;
    private const int MaxZipCodeLength = 10;

    private const string StreetPattern = @"^[a-zA-Z0-9\s,'-]*$";
    private const string CityPattern = @"^[a-zA-Z\s,'-]*$";
    private const string StatePattern = @"^[A-Z]{2}$";
    private const string ZipCodePattern = @"^\d{5}(-\d{4})?$";

    public string Street { get; }
    public string City { get; }
    public string State { get; }
    public string ZipCode { get; }

    private USHomeAddress(string street, string city, string state, string zipCode)
    {
        if (string.IsNullOrWhiteSpace(street))
        {
            throw new ArgumentException("Street cannot be null or whitespace.", nameof(street));
        }

        if (string.IsNullOrWhiteSpace(city))
        {
            throw new ArgumentException("City cannot be null or whitespace.", nameof(city));
        }

        if (string.IsNullOrWhiteSpace(state))
        {
            throw new ArgumentException("State cannot be null or whitespace.", nameof(state));
        }

        if (string.IsNullOrWhiteSpace(zipCode))
        {
            throw new ArgumentException("ZipCode cannot be null or whitespace.", nameof(zipCode));
        }

        if (street.Length > MaxStreetLength || city.Length > MaxCityLength ||
            state.Length > MaxStateLength || zipCode.Length > MaxZipCodeLength)
        {
            throw new ArgumentException("Address components exceed maximum length.");
        }

        if (!IsValidComponent(street, StreetPattern) || !IsValidComponent(city, CityPattern) ||
            !IsValidComponent(state, StatePattern) || !IsValidComponent(zipCode, ZipCodePattern))
        {
            throw new ArgumentException("Invalid address format.");
        }

        Street = street;
        City = city;
        State = state;
        ZipCode = zipCode;
    }

    public static USHomeAddress Create(string street, string city, string state, string zipCode)
    {
        return new USHomeAddress(street, city, state, zipCode);
    }

    public override string ToString()
    {
        return $"{Street}, {City}, {State} {ZipCode}";
    }

    private static bool IsValidComponent(string component, string pattern)
    {
        return Regex.IsMatch(component, pattern);
    }

    public static implicit operator string(USHomeAddress homeAddress)
    {
        return homeAddress.ToString();
    }

    public static explicit operator HomeAddress(string address)
    {
        var parts = address.Split(',');
        if (parts.Length < 3)
        {
            throw new ArgumentException("Address must contain at least street, city, and state.");
        }

        var street = parts[0].Trim();
        var city = parts[1].Trim();
        var stateZip = parts[2].Trim().Split(' ');

        if (stateZip.Length != 2)
        {
            throw new ArgumentException("Address must contain a valid state and zip code.");
        }

        var state = stateZip[0].Trim();
        var zipCode = stateZip[1].Trim();

        return Create(street, city, state, zipCode);
    }
}

Do you see how bloated the Customer entity would be if we put all those validation rules there? The example is still contrived as there is always much more than you can regulate or add here to represent real-life use cases.

Next and the last one is DateOfBirth

public record struct DateOfBirth
{
    private const int MinAge = 1;
    private const int MaxAge = 150;

    public DateTime Value { get; }

    //default should throw exception
    public DateOfBirth():this(DateTime.MinValue, DateTime.MinValue) { }

    private DateOfBirth(DateTime value, DateTime currentDate)
    {
        if (value == DateTime.MinValue)
        {
            throw new ArgumentException("Date of birth cannot be the minimum date value.", nameof(value));
        }

        var age = CalculateAge(value, currentDate);
        if (age < MinAge || age > MaxAge)
        {
            throw new ArgumentException($"Age must be between {MinAge} and {MaxAge} years old.", nameof(value));
        }

        Value = value;
    }

    public static DateOfBirth Create(DateTime value, DateTime currentDate)
    {
        return new DateOfBirth(value, currentDate.Date);
    }

    public override string ToString()
    {
        return Value.ToString("yyyy-MM-dd");
    }

    public uint GetAge(DateTime currentDate) => CalculateAge(Value, currentDate);

    private static int CalculateAge(DateTime dateOfBirth, DateTime currentDate)
    {
        var age = currentDate.Year - dateOfBirth.Year;
        if (dateOfBirth.Date > currentDate.AddYears(-age)) age--;
        return age;
    }
}

This code ensures not only the correctness of the date but also that it belongs to a person/customer who is allowed to interact with the system. Please make a note that it does not call DateTime.Today or DateTime.Now that would make the implementation “impure” in a functional sense since it would have implicit “external“ dependency on the system clock and that is something to avoid. Not only does it make the unit test harder but also leads to unpredictable output as it changes over time and I don’t even mention all those complications with time zones.

That completes the list of Value Objects and I will get back to the Customer Entity in the next article.

0
Subscribe to my newsletter

Read articles from Dmitry Dezuk (Dezhurnyuk) directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Dmitry Dezuk (Dezhurnyuk)
Dmitry Dezuk (Dezhurnyuk)

Senior Software Developer with over 10 years of performance-focused .NET development experience. I assist developers in solving architectural challenges and simplifying complex software projects. Writing as Dmitry Dezuk to share everyday productivity tips for developing faster and more reliable software.