In this article, we are going to dive into a very interesting concept in domain-driven design: value objects. We will take a look at many aspects of value objects, what makes them useful, and discuss situations where they may not be appropriate.

To download the source code for this article, you can visit our GitHub repository.

Let’s dive in!

What is Domain-Driven Design?

Let’s start with a brief introduction to domain-driven design.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

Domain-driven design is a software development approach, that emphasizes the correct modeling of the domain at hand with its rules. It promotes tight collaboration of domain experts and software developers to create the best possible software representation of real-world objects and concepts. It provides a set of tools, concepts, and best practices to help achieve this goal.

Value objects are one of these concepts, so let’s delve into what they are.

What Is a Value Object?

Value objects are immutable data structures in domain-driven design, used primarily to encapsulate primitives or other value objects that are logically bound to each other.

The main difference from entities is the fact that they do not possess a unique identifier. Rather they are defined only by their values. Additionally, there exists a hierarchical relationship between entities and value objects, where entities serve as parents of value objects.

Let’s take a look at the various properties of value objects more in-depth.

Why Should We Use Value Objects?

There are numerous reasons to invest time to introduce value objects into a project, let’s look at some.

Encapsulation

Encapsulation basically means to group logically bound properties together, hide and preserve the integrity of any internal state. Let’s look at this by declaring a Payment entity:

public class Payment
{
    public Guid Id { get; set; }
    public decimal Amount { get; set; }
    public string Currency { get; set; }
    public DateTime CreatedAt { get; set; }
}

We can see that Amount and Currency belong together, since for example changing the Currency without changing the Amount should not happen.

On a side note, this entity has two more flaws regarding encapsulation. All properties leak their internal state by having a public setter. They shouldn’t be changeable after we instantiate the entity. Maybe the Currency and Amount could be changeable, but it’s called conversion and it should not be implemented through public setters. We’ll cover this in more detail later on.

Now let’s fix this entity by introducing the Money value object:

public record Money(decimal Amount, string Currency);

Once we declare our Money value object we can refactor the Payment entity to use it:

public class Payment
{
    public Guid Id { get; private set; }
    public Money Quoted { get; private set; }
    public DateTime CreatedAt { get; private set; }

    public Payment(Money quoted)
    {
        Id = Guid.NewGuid();
        Quoted = quoted;
        CreatedAt = DateTime.UtcNow;
    }
}

Now we make it clear, that Amount and Currency belong together. Also, we fixed the Payment entity, so it won’t allow changes to its properties after it had been created by using private setters. Let’s extend this example a bit more in the next section. 

Validation of Value Objects

Validation is a key concept in domain-driven design. We must ensure that our domain model is in a valid state at all times. Thus every entity and value object is responsible for its own validity.

In simple cases, we can implement validation by employing encapsulation correctly. For example, we have fixed our Payment entity, now it doesn’t allow changes to its properties from outside, so it remains valid at all times.

Let’s assume that our system only accepts payments in USD or EUR. This domain rule must be enforced by the Money value object:

public record Money
{
    private static readonly IReadOnlyCollection<string> SupportedCurrencies = new[]{"USD", "EUR"};

    public decimal Amount { get; }
    public string Currency { get; }
    
    public Money(decimal amount, string currency)
    {
        if (string.IsNullOrWhiteSpace(currency))
            throw new ArgumentNullException(nameof(currency));
        
        if (!SupportedCurrencies.Contains(currency.ToUpperInvariant()))
            throw new ArgumentException($"Currency {currency.ToUpperInvariant()} is not supported.",
                nameof(currency));
        
        Amount = amount;
        Currency = currency.ToUpperInvariant();
    }
}

Notice how many rules we enforce. First, we declare, that currency cannot be null, an empty string, or whitespace only. Next, we make sure that currency is supported, by checking it in the SupportedCurrencies array.

Finally, by calling the ToUpperInvariant() method we ensure that Currency is uppercase.

If we have more complex validation logic, then we could introduce a factory method and a Result object to handle the validations better:

public record Result<T>
{
    public bool IsSuccess { get; private init; }
    public T? Value { get; private init; }
    public string? ErrorMessage { get; private init; }

    private Result(){}

    public static Result<T> Success(T value) => new() {IsSuccess = true, Value = value};
    public static Result<T> Failure(string errorMessage) => new()
    {
        IsSuccess = false, ErrorMessage = errorMessage
    };
}

Here we declare the generic Result record, so now let’s see how to create the factory method for the Money value object:

public record Money
{
    private static readonly IReadOnlyCollection<string> SupportedCurrencies = new[]{"USD", "EUR"};

    public decimal Amount { get; }
    public string Currency { get; }
    
    private Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }

    public static Result<Money> Create(decimal amount, string currency)
    {
        if(string.IsNullOrWhiteSpace(currency))
            return Result<Money>.Failure($"{nameof(currency)} cannot be null or whitespace.");

        if(!SupportedCurrencies.Contains(currency.ToUpperInvariant()))
            return Result<Money>.Failure($"'{currency}' is not supported.");
        
        return Result<Money>.Success(new(amount, currency));
    }
}

This time, instead of throwing exceptions, we return a Failure result, allowing the caller to handle this in a cleaner fashion.

So far, we have used records to define our value objects. Now let’s dive into the reasons behind this choice.

Value Object Immutability

Immutability means that once we instantiate an object, none of its properties can change. Effectively it means a read-only object. From C# 9 we have two built-in constructs that are immutable by design: struct and record.

From the domain-driven design point of view, the only difference that matters is that when using a struct we cannot hide the default parameterless constructor. This can lead to the violation of the encapsulation and validation properties since anyone can instantiate an empty struct.

That out of the way, let’s delve into another benefit that value objects provide.

Type Safety

When looking at methods we sometimes see a signature containing multiple parameters of the same type:

public interface ITicketPriceProvider
{
    Money GetTicketPrice(string originCountry,
        string destinationCountry,
        string originStation,
        string destinationStation);
}

Here, our method contains four very similar string parameters, and we can agree that it’s easy to pass the arguments in the wrong order. Fortunately, we can solve this by using value objects.

Let’s refactor this interface to accept value objects. First, let’s declare the new value objects:

public record Station(string StationCode, string StationName);
public record Country(string CountryCode, string CountryName);

Here, we introduce the Station and Country value objects. Now, let’s change the GetTicketPrice() method’s parameter types:

public interface ITicketPriceProvider
{
    Money GetTicketPrice(Country originCountry,
        Country destinationCountry,
        Station originStation,
        Station destinationStation);
}

This way we are able to rearrange the method parameters, for example, group the departure details at the beginning of the parameter list. Hence, the compiler will provide feedback indicating any incorrect usage of the method.

Let’s rearrange the parameters of the method:

public Money GetTicketPrice(Country originCountry, Station originStation, Country destinationCountry, Station destinationStation);

Here, we group the origin details at the beginning of the parameter list and the destination details at the end. Let’s instantiate some value objects:

var originCountry = new Country("US", "United States of America");
var originStation = new Station("JFK", "John F. Kennedy International Airport");
var destinationCountry = new Country("CA", "Canada");
var destinationStation = new Station("YVR", "Vancouver International Airport");

Now we can create a dummy implementation for our interface to be able and call the method:

public class TicketPriceProvider : ITicketPriceProvider
{
    public Money GetTicketPrice(Country originCountry,
        Station originStation,
        Country destinationCountry,
        Station destinationStation)
    {
        return Money.Create(100, "USD").Value!;
    }
}

If we try to call the method with the wrong order of parameters, we will get a compiler warning stating that Country is not assignable to the parameter type Station:

new TicketPriceProvider().GetTicketPrice(originCountry, destinationCountry, originStation, destinationStation);

We have arrived almost at the end of the properties of value objects, let’s discuss the only remaining property.

Value Object Equality

Value equality is the consequence of not having an id. We compare entities by id, but since value objects don’t have any, we are only able to compare them by value.

On the compiler level, both classes and records are reference types, so they are passed and compared by reference, not by value. However, behind the scenes, C# overrides the == and != operators, the Equals() method and the GetHashCode() method in the case of records. So they become the perfect candidate for being value objects.

Let’s write two unit tests to check the equality of two records and two classes:

[Test]
public void GivenTwoRecords_WhenTheyHaveTheSameData_ThenTheyShouldBeEqual()
{
    var hundredUsd = Money.Create(100, "USD");
    var another100Usd = Money.Create(100, "USD");
        
    Assert.That(hundredUsd.IsSuccess, Is.True);
    Assert.That(another100Usd.IsSuccess, Is.True);
    Assert.That(another100Usd.Value, Is.EqualTo(hundredUsd.Value));
}

[Test]
public void GivenTwoRecords_WhenTheyHaveTheDifferentData_ThenTheyShouldNotBeEqual()
{
    var hundredUsd = Money.Create(100, "USD");
    var payment1 = new Payment(hundredUsd.Value!);
    var payment2 = new Payment(hundredUsd.Value!);
        
    Assert.That(payment1, Is.Not.EqualTo(payment2));
}

Both tests pass, indicating that value objects truly compare by value, while entities do not.

Since we have finished discussing all the advantageous properties of value objects, let’s face the other side of the coin: where value objects are lacking.

Drawbacks of Value Objects

In software development, almost every principle has its downside. Value objects are no different. Let’s look at some disadvantages of using them.

Added Code Complexity

Let’s revisit our very first Payment entity example. There were no value objects, which made the entity itself quite simple. On the contrary, in the third example, where we extended our Money value object with validation it became complex. This complexity is the price we pay for safer code. Still, we should always examine each situation and implement our code accordingly.

For example is there a valid reason to include validations? Is someone able to create a domain object in an invalid state? Before the domain object creation, data passes through various other layers. If we decide that it is not a real danger that someone could create an invalid domain object, then we could skip validation, making the code simpler.

Speaking of other application layers, we should look at another issue that using value objects introduces.

Serialization Challenges With Value Objects

At some point, we always expose our domain objects to the outer world. The proper way to do this is to convert it into a DTO (data transfer object).

To learn more about DTOs and how they compare to Plain Old C# Objects (POCOs), check out our great article What is the Difference Between a DTO and a POCO?

First, we map every property from our domain object that is necessary to be revealed. Next, we serialize the DTO and send it through a transport layer.

This process becomes more complicated when it comes to value objects. For instance, if our value object consists solely of a primitive value, we should ensure that we appropriately map it to a primitive type during serialization. Let’s consider this with the EmailAddressConverter class:

public class User
{
    public EmailAddress EmailAddress { get; private set; }

    public User(EmailAddress emailAddress)
    {
        EmailAddress = emailAddress;
    }
}

public record EmailAddress(string Address); //validation omitted for brevity

public class EmailAddressConverter : System.Text.Json.Serialization.JsonConverter<EmailAddress>
{
    public override EmailAddress? Read(ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options)
    {
        var address = reader.GetString();
        if (string.IsNullOrEmpty(address))
            return null;
        return new(address);
    }

    public override void Write(Utf8JsonWriter writer,
        EmailAddress value,
        JsonSerializerOptions options)
    {
        writer.WriteStringValue(value.Address);
    }
}

First, we declare a User entity with an EmailAddress value object.

Next, we create a custom JsonConverter class that we add to the JsonSerializerOptions to make serialization work effectively and not create a child object just for one property of the value object.

We implement both the Read() and Write() methods of the JsonConverter class in a way to only write the value of Address into the serialized JSON and when reading, create the EmailAddress value object from it.

Let’s implement some tests so we can acknowledge that the solution works:

[Test]
public void GivenAUserWithEmailAddressObject_WhenUsingEmailAddressConverter_ThenCanSerializeItIntoString()
{
    var user = new User(new("[email protected]"));
    var settings = new JsonSerializerOptions {Converters = {new EmailAddressConverter()}};
    var json = JsonSerializer.Serialize(user, settings);
    
    const string expectedJson = "{\"EmailAddress\":\"[email protected]\"}";
    Assert.That(json, Is.EqualTo(expectedJson));
}

[Test]
public void GivenAUserWithEmailAddressJson_WhenUsingEmailAddressConverter_ThenCanDeserializeItIntoUser()
{
    const string json = "{\"EmailAddress\":\"[email protected]\"}";

    var settings = new JsonSerializerOptions {Converters = {new EmailAddressConverter()}};
    var user = JsonSerializer.Deserialize<User>(json, settings);
    
    Assert.That(user, Is.Not.Null);
    Assert.That(user!.EmailAddress, Is.Not.Null);
    Assert.That(user.EmailAddress.Address, Is.EqualTo("[email protected]"));
}

In the first test, we start by instantiating a User with an EmailAddress and configuring the JsonSerializer by creating an EmailAddressConverter and adding it to the JsonSerializerOptions class. Then we serialize the User and check whether the resulting JSON contains the email address as a string.

In the second test, we declare the same JSON as we expected to be the result in the first test. Then we create the same settings for the JsonSerlializer and deserialize the JSON. After that, we check the resulting User and compare the EmailAddress.Address property to our expected email.

Conclusion

In this article, we have explored the concept of value objects, understanding their definition, benefits, drawbacks, and appropriate usage scenarios. As a closing thought, it’s important to emphasize the importance of thoughtful decision-making, and how important it is to consider whether, in a specific situation, the advantages of implementing any architectural pattern outweigh their drawbacks.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!