Updated on

A value object is a type we identify by its values, not by an ID. Two Money values of 100 USD are the same money, the same way two 5s are the same number.

In C#, a record gets us most of the way there. It compares by value out of the box, and its positional properties can’t change after creation. Refusing a negative amount is still up to us.

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

We will take a look at many aspects of value objects, what makes them useful, and discuss situations where they may not be appropriate. We’ll also store one with EF Core.

What Is a Value Object in C#?

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. Money and an email address are typical examples.

The main difference from entities is the fact that they do not possess a unique identifier. Rather, they are defined only by their values. So 100 USD equals any other 100 USD, and for a different amount, we create a new Money.

A Payment, on the other hand, is an entity. It has an ID, so two payments of 100 USD are still two different payments. Entities usually hold value objects as properties.

Domain-driven design is a software development approach that emphasizes the correct modeling of the domain at hand with its rules, and value objects are one of its building blocks.

In C#, records compare by value and keep their positional properties fixed after creation. The rules that make a value valid, such as “no negative money”, we still write ourselves.

What Are the Benefits of Value Objects?

There are numerous reasons to invest time to introduce value objects into a project. Let’s look at five of them, and each section below shows one in code.

They keep values that belong together in one type, so an amount never travels without its currency.

They validate their input once, when we create them. After that, the rest of our code can trust every instance it receives.

They’re immutable. Code that shares an instance can’t change it behind another object’s back.

They make method signatures safer. A parameter of type Station won’t accept a Country, while a string parameter accepts any text at all, including the wrong one.

And they compare by value, which records give us without any extra code.

The price is more types to write, plus some work wherever a value object crosses into JSON or a database. The drawbacks section covers that side, so you can weigh the cost for your own domain.

Encapsulation

Encapsulation 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 the way it often looks at first. Here’s that version of Entities/Payment.cs, and it isn’t one to copy:

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. 100 USD would turn into 100 EUR, and nothing would stop it.

On a side note, this entity has another flaw 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.

Now let’s fix this entity by introducing the Money value object in ValueObjects/Money.cs:

public record Money(decimal Amount, string Currency);

Once we declare our Money value object, we can refactor Entities/Payment.cs 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 has been created, by using private setters.

Money itself still accepts anything we hand it, though, so let’s extend this example a bit more in the next section.

Validation of Value Objects

Validation is an important 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.

Money needs real rules. A price can’t be negative, and let’s assume that our system only accepts payments in USD or EUR. These domain rules must be enforced by the Money value object, so let’s replace ValueObjects/Money.cs:

public sealed record Money
{
    private static readonly string[] SupportedCurrencies = ["USD", "EUR"];

    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(amount);
        ArgumentException.ThrowIfNullOrWhiteSpace(currency);

        var code = currency.ToUpperInvariant();

        if (!SupportedCurrencies.Contains(code))
            throw new ArgumentException($"Currency {code} is not supported.", nameof(currency));

        Amount = amount;
        Currency = code;
    }
}

Notice how many rules we enforce. First, ThrowIfNegative() rejects a negative amount. Then, ThrowIfNullOrWhiteSpace() makes sure that the currency is not null, an empty string, or whitespace only.

Next, by calling the ToUpperInvariant() method, we ensure that the currency code is uppercase. Finally, we make sure that the currency is supported, by checking it in the SupportedCurrencies array, and we store that upper-case version.

So what happens when we ask for minus five dollars? new Money(-5m, "USD") throws an ArgumentOutOfRangeException, and no negative Money ever reaches the rest of our code. The exception message starts with amount ('-5') must be a non-negative value., then names the parameter and the value it got.

Is a negative amount always wrong? Not in every domain. If you model a bank account, its balance can drop below zero. For the price of a ticket, though, it’s always a bug, and that’s the rule our Money enforces.

Throwing fits when a bad value means a bug somewhere upstream. By the time a request reaches the domain, the API has usually validated it, so a negative price at that point is a mistake in our code.

But what if Money is the first code that sees the raw input, like a currency code someone typed into a form? Then wrong values are part of everyday use, because people mistype.

In that case, or if we have more complex validation logic, we could introduce a factory method and a Result object to handle the validations better. Let’s add a small result type in ValueObjects/Result.cs:

public enum ErrorType
{
    NotFound,
    Conflict,
    Validation
}

public sealed record Error(string Code, string Description, ErrorType Type);

public class Result
{
    protected Result(Error? error) => Error = error;

    public Error? Error { get; }
    public bool IsSuccess => Error is null;

    public static Result Success() => new(null);

    public static implicit operator Result(Error error) => new(error);
}

public sealed class Result<TValue> : Result
{
    private readonly TValue? _value;

    private Result(TValue? value, Error? error)
        : base(error) => _value = value;

    public TValue Value => IsSuccess
        ? _value!
        : throw new InvalidOperationException("A failed result has no value.");

    public static implicit operator Result<TValue>(TValue value) =>
        new(value, null);

    public static implicit operator Result<TValue>(Error error) =>
        new(default, error);
}

Each failure is an Error record in three parts: a code that callers can match on, text meant for people, and an ErrorType. A Result<Money> carries the new Money on success and the Error on failure. Reading Value on a failure is a bug, so it throws.

ErrorType also lists NotFound and Conflict, the failures a use case returns. We add Validation, because rejecting input is the one failure a value object’s factory produces.

If you want the reasoning behind this design, check out our Result pattern article.

We keep the errors of Money in one static class beside it, in ValueObjects/MoneyErrors.cs:

public static class MoneyErrors
{
    public static Error NegativeAmount(decimal amount) =>
        new("Money.NegativeAmount", $"Amount {amount} is negative.", ErrorType.Validation);

    public static Error UnsupportedCurrency(string currency) =>
        new("Money.UnsupportedCurrency", $"Currency '{currency}' is not supported.", ErrorType.Validation);
}

Now let’s see how to create the factory method for the Money value object. Money gets a private constructor and a Create() factory, so let’s replace ValueObjects/Money.cs one more time:

public sealed record Money
{
    private static readonly string[] SupportedCurrencies = ["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 (amount < 0)
            return MoneyErrors.NegativeAmount(amount);

        var code = currency.ToUpperInvariant();

        if (!SupportedCurrencies.Contains(code))
            return MoneyErrors.UnsupportedCurrency(currency);

        return new Money(amount, code);
    }
}

The constructor is private, so Create() is the only way in. It returns an Error for a negative amount or an unknown currency, and a Money otherwise.

The implicit conversions on Result<TValue> wrap both, so the method never has to spell out Result<Money> when it returns.

Let’s ask for minus five dollars again, in Program.cs:

var refused = Money.Create(-5m, "USD");

Console.WriteLine(refused.IsSuccess);
Console.WriteLine(refused.Error);

This time, instead of throwing an exception, we get a failure result:

False
Error { Code = Money.NegativeAmount, Description = Amount -5 is negative., Type = Validation }

That allows the caller to handle this in a cleaner fashion. It checks IsSuccess and can pass the error on, for example into an API response.

So far, we have used records to define our value objects. Now let’s look at 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.

Why does a value object need that? Because other objects share it. If one payment could change a Money in place, we’d also change every other payment that holds the same instance.

Records, which arrived in C# 9, are the natural fit. They aren’t immutable by design, though, and neither are structs.

The C# language reference on Microsoft Learn says it plainly: “While records can be mutable, they’re primarily intended for supporting immutable data models.”

A positional record does give us init-only properties, which nothing can change after creation. Our Money goes a step further: its properties have no setter at all.

Why not a readonly record struct, then? It’s immutable, and it compares by value too.

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: default creates any struct without calling a constructor. Here’s a quick experiment outside our project, with a version of Money we won’t keep:

public readonly record struct Money(decimal Amount, string Currency);

Let’s print its default value:

Console.WriteLine(default(Money));

We get money with no currency at all:

Money { Amount = 0, Currency =  }

This can lead to the violation of the encapsulation and validation properties, since anyone can instantiate an empty struct. Whatever rules we put in a struct’s constructor, default skips them, so for a value object that must never be invalid, we stay with a record class.

Can a with Expression Skip Validation?

Records support nondestructive mutation. A with expression copies an instance and changes the properties we name, and it never calls our constructor.

Instead, it clones the record and then sets each named property through its init accessor.

That breaks a common way of adding validation to a positional record. Here’s the pattern as a separate experiment, outside our project. It has a hole, so don’t copy it:

public record Money(decimal Amount, string Currency)
{
    public decimal Amount { get; init; } = Amount >= 0
        ? Amount
        : throw new ArgumentOutOfRangeException(nameof(Amount), "Money can't be negative.");
}

The check runs when we call the constructor, so new Money(-5m, "USD") throws. Now let’s go around it:

var price = new Money(10m, "USD");
var negative = price with { Amount = -5m };

Console.WriteLine(negative);

There’s no exception this time:

Money { Currency = USD, Amount = -5 }

That’s a negative Money, and the rule never ran.

Can we move the check into the init accessor instead? With the field keyword from C# 14, that’s one expression, and it opens the opposite hole.

The with expression now runs the check, while the primary constructor writes the backing field directly and skips it. In our run, new Money(-5m, "USD") came out as Money { Currency = USD, Amount = -5 }.

A positional record has two ways in, and a check on either one leaves the other open. The Money we built in the validation section doesn’t have this problem.

Its properties are get-only, so you can’t set them with a with expression at all. Let’s try the same trick on it, in Program.cs:

var price = Money.Create(10m, "USD").Value;
var negative = price with { Amount = -5m };

This one doesn’t even compile:

error CS0200: Property or indexer 'Money.Amount' cannot be assigned to -- it is read only

Delete those two lines again before moving on. We lose with for Money, and we won’t miss it. To get a different amount, we call Create() again, and every rule runs again.

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

Type Safety

When looking at methods, we sometimes see a signature containing multiple parameters of the same type. Here’s one in TypeSafety/ITicketPriceProvider.cs, and it invites mistakes:

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. Nothing warns us when we do. 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 in ValueObjects/Station.cs and ValueObjects/Country.cs:

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, since the compiler will provide feedback indicating any argument of the wrong type. Let’s group the origin details at the beginning of the parameter list in TypeSafety/ITicketPriceProvider.cs:

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

Now the origin details sit at the beginning of the parameter list and the destination details at the end. Let’s instantiate some value objects in Program.cs:

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 in TypeSafety/TicketPriceProvider.cs, to be able to call the method:

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

Let’s try to call the method with the wrong order of parameters:

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

The build fails with two compiler errors, one for each swapped argument:

error CS1503: Argument 2: cannot convert from 'Country' to 'Station'
error CS1503: Argument 3: cannot convert from 'Station' to 'Country'

With four strings, the same call would compile and price the wrong trip. Swap the arguments back, and the build passes again.

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

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.

Both classes and records are reference types, so a variable of either kind holds a reference. For a class, == also compares references, so we get true only when both sides are the same object.

However, behind the scenes, C# generates the == and != operators, the Equals() method and the GetHashCode() method in the case of records, and they compare every field. So records become the perfect candidate for being value objects.

Let’s write three unit tests in Tests/ValueEqualityUnitTest.cs, inside a ValueEqualityUnitTest class of an xUnit test project that sits beside our app and references it, to check the equality of records and classes:

[Fact]
public void GivenTwoRecords_WhenTheyHaveTheSameData_ThenTheyShouldBeEqual()
{
    var hundredUsd = Money.Create(100, "USD");
    var another100Usd = Money.Create(100, "USD");

    Assert.True(hundredUsd.IsSuccess);
    Assert.True(another100Usd.IsSuccess);
    Assert.Equal(hundredUsd.Value, another100Usd.Value);
}

[Fact]
public void GivenTwoRecords_WhenTheyHaveDifferentData_ThenTheyShouldNotBeEqual()
{
    var hundredUsd = Money.Create(100, "USD");
    var hundredEur = Money.Create(100, "EUR");

    Assert.True(hundredUsd.IsSuccess);
    Assert.True(hundredEur.IsSuccess);
    Assert.NotEqual(hundredUsd.Value, hundredEur.Value);
}

[Fact]
public void GivenTwoClasses_WhenTheyHaveTheSameData_ThenTheyShouldNotBeEqual()
{
    var hundredUsd = Money.Create(100, "USD").Value;
    var payment1 = new Payment(hundredUsd);
    var payment2 = new Payment(hundredUsd);

    Assert.NotEqual(payment1, payment2);
}

All three tests pass, indicating that value objects truly compare by value, while entities, like our two Payment objects, do not.

We can watch the same rules at work in Program.cs:

var hundredUsd = Money.Create(100m, "USD").Value;
var typedInLowerCase = Money.Create(100m, "usd").Value;
var hundredEur = Money.Create(100m, "EUR").Value;

Console.WriteLine(hundredUsd == typedInLowerCase);
Console.WriteLine(hundredUsd == hundredEur);
Console.WriteLine(new Payment(hundredUsd) == new Payment(hundredUsd));

Here’s what it prints:

True
False
False

The first line depends on one detail in Create(): it stores the upper-case code, not the currency we passed in.

Record equality compares strings exactly. If we store usd the way the user typed it, it never equals USD.

Our Money is ready, so let’s give it a place in the database.

How Do We Store Value Objects With EF Core?

EF Core stores a value object as a complex type. We call ComplexProperty() on the property that holds it, and EF Core saves the value object’s properties as extra columns in the owner’s table.

A Money on a Payment becomes two columns, Quoted_Amount and Quoted_Currency.

A complex type has no key and no identity of its own, which is what a value object is.

EF Core compares complex types by value as well, so a LINQ query can filter on a whole Money at once.

Complex types arrived in EF Core 8. EF Core 10 filled in the rest: optional value objects like Money?, a single JSON column instead of several, and whole lists of value objects stored as JSON.

The older route is an owned entity type, configured with OwnsOne(). Owned types carry a hidden key, so EF Core treats them as entities.

Microsoft’s EF Core documentation now recommends complex types for value objects instead.

EF Core and its SQLite provider come in one package:

dotnet package add Microsoft.EntityFrameworkCore.Sqlite --version 10.0.12

Let’s map our Payment in Persistence/PaymentsDbContext.cs:

using Microsoft.EntityFrameworkCore;

public sealed class PaymentsDbContext(DbContextOptions<PaymentsDbContext> options)
    : DbContext(options)
{
    public DbSet<Payment> Payments => Set<Payment>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Payment>().ComplexProperty(p => p.Quoted, money =>
        {
            money.Property(m => m.Amount);
            money.Property(m => m.Currency).HasMaxLength(3);
        });
    }
}

Why the two Property() calls? Money‘s properties have no setters, and EF Core doesn’t map a property without a setter by convention.

If you leave the calls out, EF Core finds nothing to pass to Money‘s constructor and rejects your model:

No suitable constructor was found for the type 'Payment.Quoted#Money (Money)'. The following constructors had parameters that could not be bound to properties of the type:
    Cannot bind 'amount', 'currency' in 'Payment.Quoted#Money (Money)(decimal amount, string currency)'
    Cannot bind 'original' in 'Payment.Quoted#Money (Money)(Money original)'

The original line is the copy constructor that the compiler writes for every record, and EF Core can’t bind that one either.

With both properties named, EF Core maps them and builds each Money through its private constructor.

Payment needs a change too. EF Core can’t pass a complex property into a constructor, so with only Payment(Money quoted) available, it stops with Cannot bind 'quoted' in 'Payment(Money quoted)'.

Let’s give EF Core a private constructor of its own in Entities/Payment.cs:

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;
    }

    private Payment()
    {
        Quoted = null!;
    }
}

When you load a payment, EF Core calls the private constructor and then fills Quoted from the two columns. The null! only keeps the compiler quiet until it does.

Your own code still has one way to create a payment, and it needs a real Money.

We keep the DbContext in the same project so the sample stays small. In a layered app, the mapping belongs in the infrastructure project.

Money stays in the domain project, with no reference to EF Core at all.

Let’s save a payment and find it again by its price. An in-memory SQLite database is enough, so you don’t have to install anything. Let’s add this to Program.cs, with using Microsoft.Data.Sqlite; and using Microsoft.EntityFrameworkCore; at the top of the file:

await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();

var options = new DbContextOptionsBuilder<PaymentsDbContext>()
    .UseSqlite(connection)
    .Options;

await using (var context = new PaymentsDbContext(options))
{
    await context.Database.EnsureCreatedAsync();
    Console.WriteLine(context.Database.GenerateCreateScript().TrimEnd());

    context.Payments.Add(new Payment(typedInLowerCase));
    await context.SaveChangesAsync();
}

await using (var context = new PaymentsDbContext(options))
{
    var price = Money.Create(100m, "USD").Value;
    var query = context.Payments.Where(p => p.Quoted == price);

    Console.WriteLine(query.ToQueryString());

    var payment = await query.SingleAsync();

    Console.WriteLine(payment.Quoted);
    Console.WriteLine(payment.Quoted == hundredUsd);
}

Here, we create the table and print its script, trimmed of trailing blank lines, then save a payment of 100 USD that was typed in lower case.

With a fresh context, we query for the payment whose Quoted equals price, print the SQL, and read the payment back. Let’s run it:

CREATE TABLE "Payments" (
    "Id" TEXT NOT NULL CONSTRAINT "PK_Payments" PRIMARY KEY,
    "CreatedAt" TEXT NOT NULL,
    "Quoted_Amount" TEXT NOT NULL,
    "Quoted_Currency" TEXT NOT NULL
);
.param set @entity_equality_price_Amount '100.0'
.param set @entity_equality_price_Currency 'USD'

SELECT "p"."Id", "p"."CreatedAt", "p"."Quoted_Amount", "p"."Quoted_Currency"
FROM "Payments" AS "p"
WHERE "p"."Quoted_Amount" = @entity_equality_price_Amount AND "p"."Quoted_Currency" = @entity_equality_price_Currency
Money { Amount = 100.0, Currency = USD }
True

Our Money became two columns, and the query compares both of them.

Look at the amount you get back, though: 100.0, not 100. SQLite stores decimals as text, and EF Core reads that text back with its decimal place.

It still equals hundredUsd, because decimal equality ignores trailing zeros, which is what you want from money.

Why not owned types, which you’ll find in older tutorials? We ran the same model with OwnsOne() in place of ComplexProperty().

The query by value threw an InvalidOperationException, because EF Core compares owned types by their hidden key, and our price doesn’t have one.

Owned types failed a second test too. Two new payments that shared one Money instance failed to save, with NOT NULL constraint failed: Payments.Quoted_Amount.

An owned instance can belong to only one owner. With ComplexProperty(), both operations work.

Owned types still have a job when the object needs a table of its own or a navigation to another entity, and complex types can do neither.

Microsoft’s complex types documentation reaches the same verdict: “complex types are generally the better choice for modeling value objects”.

Complex types also cover more shapes of value object than our Money. Here’s how each shape mapped in EF Core 10, in our runs against SQLite:

Value objectMapping in OnModelCreating()What EF Core 10 created on SQLite
Money, always presentComplexProperty(p => p.Quoted)Quoted_Amount and Quoted_Currency, both NOT NULL
Money?, sometimes missingComplexProperty(o => o.Refund)Refund_Amount and Refund_Currency, both NULL (EF Core 10 and later)
A struct, readonly record struct Kilograms(decimal Value)ComplexProperty(o => o.Weight)Weight_Value (struct complex types: EF Core 10 and later)
Money kept as one documentComplexProperty(o => o.Snapshot, b => b.ToJson())one column, Snapshot, holding JSON as text (EF Core 10 and later)
List<Money>ComplexCollection(o => o.Installments, b => b.ToJson())one JSON column, Installments (EF Core 10 and later; collections must use JSON)
EmailAddress, a single propertyProperty(c => c.Email).HasConversion(e => e.Address, a => new EmailAddress(a))one column, Email (a complex type gives Email_Address)

The EmailAddress row works differently from the rest. For a value object with a single property, like EmailAddress, a value converter keeps the column name you’d expect: Email rather than Email_Address.

The Money rows also need the two Property() calls from our PaymentsDbContext, since Money has no setters.

Our article on complex types in EF Core goes deeper into the mapping and how it affects query performance.

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, and value objects are no different. They cost us more code, some work at the JSON boundary, and a gap in record equality for collections.

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, once we extended our Money value object with validation and a Result, we had three files where one type used to be.

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 them into DTOs (data transfer objects).

To learn how DTOs compare to Plain Old C# Objects (POCOs), check out our article on 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 a User entity in Entities/User.cs, an EmailAddress value object in ValueObjects/EmailAddress.cs, and an EmailAddressConverter class in Serialization/EmailAddressConverter.cs, a file that starts with using System.Text.Json;:

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 two tests in a SerializationUnitTest class in Tests/SerializationUnitTest.cs, a file that starts with using System.Text.Json;, so we can acknowledge that the solution works:

[Fact]
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.Equal(expectedJson, json);
}

[Fact]
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.NotNull(user);
    Assert.NotNull(user.EmailAddress);
    Assert.Equal("[email protected]", user.EmailAddress.Address);
}

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 JsonSerializer and deserialize the JSON. After that, we check the resulting User and compare the EmailAddress.Address property to our expected email.

EF Core faces the same choice for single-value objects, which is why the value converter row sits in the mapping table above.

Record Equality Stops at Collections

Record equality has a limit you’ll run into with value objects sooner or later. It compares each field with that field’s own Equals(), and for a list or an array, Equals() compares references.

Here’s a Route value object in ValueObjects/Route.cs that looks right and isn’t, so don’t copy it:

public record Route(IReadOnlyList<Station> Stops);

Let’s compare two routes with the same stops, in Program.cs:

Station[] stops = [new("JFK", "John F. Kennedy International Airport"), new("YVR", "Vancouver International Airport")];
Station[] sameStops = [new("JFK", "John F. Kennedy International Airport"), new("YVR", "Vancouver International Airport")];

Console.WriteLine(new Route(stops) == new Route(sameStops));

It prints:

False

The stations and their order match, but the two arrays are different objects, so you get False.

The fix is to write the equality ourselves. Let’s replace ValueObjects/Route.cs:

public sealed record Route(IReadOnlyList<Station> Stops)
{
    public bool Equals(Route? other) =>
        other is not null && Stops.SequenceEqual(other.Stops);

    public override int GetHashCode()
    {
        var hash = new HashCode();
        foreach (var stop in Stops)
            hash.Add(stop);

        return hash.ToHashCode();
    }
}

The same comparison prints True this time. We override GetHashCode() too, so two equal routes also end up as one entry in a HashSet<Route> or as one key in a dictionary.

Conclusion

In this article, we have explored the concept of value objects: their definition, benefits, drawbacks, and appropriate usage scenarios.

We’ve built a Money value object that refuses negative amounts and unknown currencies, first by throwing and then by returning a Result.

Records gave us value equality, but not guaranteed immutability, and a with expression can walk right past a check that sits in the wrong place.

EF Core 10 stores the whole thing as a complex type, in two columns, and compares it by value in queries.

As a closing thought, we should always consider whether, in a specific situation, the advantages of implementing any architectural pattern outweigh its drawbacks.

Value objects rarely live alone. They sit inside entities, and entities in turn group into aggregates, which our article on aggregate design builds in .NET.

Value objects get a lesson of their own in the Clean Architecture in .NET course, inside a complete event ticketing app that maps them with EF Core 10.

Tested with the .NET 10 SDK 10.0.302 (runtime 10.0.10), C# 14, EF Core 10.0.12 and xUnit v3 4.0.1.