HomeNikola Knezevic

In this article

Banner

Value Objects in .NET

03 Sept 2026
7 min

Special Thanks to Our Sponsors:

Sponsor Logo

EF Core is too slow? Discover how you can easily insert 14x faster (reducing saving time by 94%).

Boost your performance with our method within EF Core: Bulk Insert, update, delete and merge.

Thousands of satisfied customers have trusted our library since 2014.

👉 Learn more

Sponsor Newsletter

In many applications we represent domain concepts with primitives like decimal, string or Guid.

That works for simple CRUD, but as the domain grows the model becomes harder to reason about. A price is not just a number and an address is not just four strings spread across an entity.

Without a clear boundary, business rules end up scattered across services and bugs slip in when the same concept is modeled differently in different places.

One of the simplest ways to make the domain more expressive is by introducing value objects.

Value Objects

Value objects are a core DDD concept. They represent a single idea in your domain, such as Money or Address.

Unlike entities, value objects do not have identity. Two instances with the same values are considered equal.

In practice, value objects usually follow these rules:

  • No identity - equality is based on values, not an Id
  • Immutable - you replace the whole object instead of mutating it
  • Self-contained - they can enforce domain rules through behavior
  • Side-effect free - operations return new instances instead of changing state

Compare that to an entity like Order. Two orders with the same total and address are still different orders because each one has its own Id.

Enums like OrderStatus are useful, but they are not value objects. A value object is a type with structure and rules, not just a named constant.

Let's start with the classic class-based approach and use Money as our example throughout.

Class-Based Value Objects

In C#, reference types do not compare by value by default. If you create two Money instances with the same amount and currency, == returns false unless you override equality yourself.

That's why many DDD codebases introduce a small base abstraction. It centralizes equality logic so every value object behaves the same way.

csharp
public abstract class ValueObject : IEquatable<ValueObject>
{
    public static bool operator ==(ValueObject first, ValueObject second)
    {
        if (first is null && second is null)
        {
            return true;
        }

        if (first is null || second is null)
        {
            return false;
        }

        return first.Equals(second);
    }

    public static bool operator !=(ValueObject first, ValueObject second) => !(first == second);

    public bool Equals(ValueObject other) => other is not null && ValuesEqual(other);

    public override bool Equals(object obj)
    {
        if (obj is null)
        {
            return false;
        }

        if (GetType() != obj.GetType())
        {
            return false;
        }

        if (obj is not ValueObject otherValueObject)
        {
            return false;
        }

        return ValuesEqual(otherValueObject);
    }

    public override int GetHashCode() =>
        GetAtomicValues()
            .Aggregate(default(int), (hashcode, value) =>
                HashCode.Combine(hashcode, value.GetHashCode()));

    protected abstract IEnumerable<object> GetAtomicValues();

    private bool ValuesEqual(ValueObject other) => GetAtomicValues().SequenceEqual(other.GetAtomicValues());
}

Each concrete value object tells the base class which properties participate in equality through GetAtomicValues.

Here's how Money looks as a class-based value object:

csharp
public sealed class Money : ValueObject
{
    public decimal Amount { get; }
    public Currency Currency { get; }

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

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
        {
            throw new InvalidOperationException("Cannot add money with different currencies.");
        }

        return new Money(Amount + other.Amount, Currency);
    }

    protected override IEnumerable<object> GetAtomicValues()
    {
        yield return Amount;
        yield return Currency;
    }
}

Notice that Add does not modify the current instance. It validates the rule and returns a new Money. That keeps the object immutable and makes behavior easy to reason about.

This approach is explicit and works everywhere, including older codebases. The trade-off is boilerplate around equality.

Records as Value Objects

Modern C# gives us a simpler option. Records already provide value-based equality, immutability and concise syntax out of the box.

For many value objects you no longer need a base class at all.

Here's the same Money concept as a record struct:

csharp
public readonly record struct MoneyRecord(decimal Amount, Currency Currency)
{
    public MoneyRecord Add(MoneyRecord other)
    {
        if (Currency != other.Currency)
        {
            throw new InvalidOperationException("Cannot add money with different currencies.");
        }

        return new MoneyRecord(Amount + other.Amount, Currency);
    }
}

Same domain rule, far less ceremony. Equality works the way you'd expect:

csharp
var first = new MoneyRecord(100, Currency.Eur);
var second = new MoneyRecord(100, Currency.Eur);

first == second; // true

For new projects I usually reach for records first. The class-based approach still makes sense when you need inheritance, tighter control over equality or you're working in a codebase that already standardizes on a ValueObject base.

For persistence we'll stick with the class-based Money on our Order entity, but the domain modeling ideas are the same either way.

Mapping Value Objects

Modeling with value objects is only half the story. Sooner or later you need to persist them.

Instead of flattening Amount and Currency directly on Order, we keep TotalAmount as a Money value object:

csharp
public class Order
{
    public Guid Id { get; set; }
    public Money TotalAmount { get; set; }
    public Address ShippingAddress { get; set; }

    public static Order Create(Address shippingAddress, Money totalAmount) =>
        new(Guid.NewGuid(), shippingAddress, totalAmount);

    public void AddToTotal(Money amount) =>
        TotalAmount = TotalAmount.Add(amount);
}

For a long time, the way to map this in EF Core was through owned types using OwnsOne:

csharp
builder.OwnsOne(order => order.TotalAmount, moneyBuilder =>
{
    moneyBuilder.Property(money => money.Amount)
        .HasPrecision(18, 2)
        .HasColumnName("TotalAmount")
        .IsRequired();

    moneyBuilder.Property(money => money.Currency)
        .HasConversion<string>()
        .HasMaxLength(3)
        .HasColumnName("TotalAmountCurrency")
        .IsRequired();
});

builder.Navigation(order => order.TotalAmount).IsRequired();

It works and the data ends up in the same table, but owned types never felt quite right for value objects.

Under the hood they were still treated as entity types. You often ended up fighting column naming, navigation configuration and query behavior that didn't match how you modeled the domain.

Basically owned types solved mapping, but not modeling. In our sample, TotalAmount is still mapped this way.

Complex Types

EF Core 8 introduced complex types as first-class support for value objects. They are not entities, they don't get their own table and they don't have identity.

Mapping them is much simpler through ComplexProperty. A composite value object like Address is a good place to use it. Multiple related fields, one domain concept, mapped as a single unit:

csharp
public record Address(
    string Street,
    string City,
    string Country,
    string ZipCode);

public class Order
{
    public Guid Id { get; set; }
    public Money TotalAmount { get; set; }
    public Address ShippingAddress { get; set; }
}

No separate navigation setup, no awkward owned entity semantics. Just a value object mapped the way you'd expect, with full control over column specifications:

csharp
builder.ComplexProperty(order => order.ShippingAddress, address =>
{
    address.Property(a => a.Street).HasMaxLength(200).IsRequired();
    address.Property(a => a.City).HasMaxLength(100).IsRequired();
    address.Property(a => a.Country).HasMaxLength(100).IsRequired();
    address.Property(a => a.ZipCode).HasMaxLength(20).IsRequired();
});

EF Core 10 pushed this further with optional complex types, struct support, JSON mapping and queryable JSON columns. Those are mapping concerns rather than value object modeling, so I've covered them in separate posts:

If you're building a richer domain model, value objects give you the modeling side. Complex types give you the persistence side. Together they make the whole thing practical.

Conclusion

Value objects help you model domain concepts with clarity. They keep related data together, enforce rules close to the concept and make equality behave the way the business expects.

Start with records when you can. Fall back to a class with a ValueObject base when you need the classic DDD approach or you're extending an existing codebase that already uses it.

When it's time to persist them, use ComplexProperty for new value objects and migrate older OwnsOne mappings when you can. For everything beyond the basics, check out the dedicated EF Core posts linked above.

Either way, the goal is the same. Primitives carry data, value objects carry meaning.

If you want to check out examples I created, you can find the source code here:

Source Code

I hope you enjoyed it, subscribe and get a notification when a new blog is up!

Related posts
Complex Types in EF Core 10
Complex Types in EF Core 10

Complex types in EF Core 10 are no longer a niche feature. They are now safer, more expressive and finally compatible with proper value object modeling.

JSON Type Support in EF Core 10
JSON Type Support in EF Core 10

EF Core 10 makes working with JSON in SQL Server straightforward and efficient.

Value Conversions in EF Core
Value Conversions in EF Core

Value Converters in EF Core give you the flexibility to bridge the gap between your domain model and the database schema without compromising either.

Subscribe

Stay tuned for valuable insights every Thursday morning.