Value Objects in .NET: DDD Fundamentals Made Simple
Learn value objects in .NET with simple examples. Understand equality, immutability, records vs base class, and EF Core mapping in domain-driven design.
Imagine you give your friend a 10-rupee note. Later your friend gives you back a different 10-rupee note. Did you lose anything? No. A 10-rupee note is a 10-rupee note. You do not care which exact note you hold. You only care that it is worth 10 rupees.
Now think about your school ID card. If a friend hands you back a different ID card with someone else's photo, that is a big problem. An ID card is special. It points to one exact person. You care which card it is, not just what is printed on it.
This small difference is the heart of today's topic. In software we call the first kind a value object and the second kind an entity. Once you feel this difference, a lot of clean code starts to make sense.
What is a value object?
A value object is a small object that is described only by its values. It has no separate identity. If two value objects hold the same values, we treat them as the same thing.
Some everyday examples:
- A money amount, like "100 INR".
- A date range, like "1 June to 10 June".
- An address, like "12 MG Road, Pune, 411001".
- A colour, like "red" or
#FF0000.
None of these need an ID. You would never say "money number 7" or "the colour with id 42". You just say "100 rupees" or "red". The values are the thing.
Entity vs value object
The opposite of a value object is an entity. An entity has its own identity, usually an ID. Even if two entities have the same data, they are still different things because their IDs differ.
Think of two students both named "Ravi Kumar", both in class 7, both 12 years old. They are still two different people. Each has a unique roll number. That roll number is the identity. So a Student is an entity.
Here is a table to make the difference clear.
| Question | Entity | Value object |
|---|---|---|
| Has an ID? | Yes | No |
| How do we compare two? | By identity (ID) | By all values |
| Can its data change over time? | Yes, usually | No, it stays the same |
| Example | Student, Order, Bank Account | Money, Address, Date Range |
| Do we track its history? | Often yes | No |
A simple test you can use: ask yourself, "If I change one value, is it still the same thing?"
- For a bank account, if the balance changes, it is still your account. So a bank account is an entity.
- For money, if you change 100 INR to 200 INR, it is a different amount. So money is a value object.
Deciding entity or value object
Steps
Need an ID?
If yes, lean entity
Track changes?
If yes, lean entity
Decide
Else, value object
The two golden rules
Value objects follow two simple rules. Almost everything else flows from these.
- Equality by value. Two value objects are equal when all their values match.
- Immutability. Once created, a value object never changes. To get a different value, you make a new object.
Let us look at each.
Rule 1: Equality by value
In normal C# classes, two objects are equal only if they are the same object in memory. That is reference equality. For value objects we do not want that. We want structural equality: equal if the values match.
Here is the problem with a plain class.
public class Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
}
var a = new Money(100, "INR");
var b = new Money(100, "INR");
// This prints False, even though the values are the same!
Console.WriteLine(a == b);That False is surprising and wrong for our domain. Two 100-rupee amounts should be equal. We need to fix equality.
Rule 2: Immutability
Immutability means "cannot change after it is made". Look again at the Money class above. The properties have get but no set. So once you build a Money, nobody can quietly change its amount later. This keeps the object safe and predictable.
If a value never changes, you can pass it around freely without fear. Nobody far away in the code can edit it behind your back. This is why immutable value objects are so reliable.
Three ways to build a value object
There are three common ways to build value objects in modern .NET. We will look at all three so you can pick the right one.
| Approach | Best when | Effort |
|---|---|---|
record type | You want value equality fast | Very low |
ValueObject base class | You need to control which fields count | Medium |
readonly struct | Tiny values, you care about performance | Medium |
Way 1: The C# record (the easy way)
Since C# 9, records give you value equality for free. A record compares by its values automatically and is easy to make immutable. This is the most common choice today.
public record Money(decimal Amount, string Currency);
var a = new Money(100, "INR");
var b = new Money(100, "INR");
// This prints True. Records compare by value out of the box.
Console.WriteLine(a == b);That is it. No extra equality code. The record keyword writes the Equals, GetHashCode, and == for you. The positional parameters (Amount, Currency) become init-only properties, so the object is immutable too.
You can also add behaviour and validation. A good value object protects itself from bad data.
public record Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0)
throw new ArgumentException("Amount cannot be negative.");
if (string.IsNullOrWhiteSpace(currency))
throw new ArgumentException("Currency is required.");
Amount = amount;
Currency = currency;
}
// Behaviour lives with the data. Returns a NEW Money.
public Money Add(Money other)
{
if (other.Currency != Currency)
throw new InvalidOperationException("Currencies must match.");
return new Money(Amount + other.Amount, Currency);
}
}Notice that Add does not change the current object. It returns a brand-new Money. That keeps our immutability rule safe.
Way 2: The ValueObject base class (the flexible way)
Sometimes you need more control. Maybe two addresses should be equal even if the city name has different capital letters. Or maybe one field should not count toward equality. A record always uses all its values, so it cannot do this kind of tuning.
For these cases, a small ValueObject base class is popular. This style was made famous by Jimmy Bogard and Vladimir Khorikov. You list the exact values that should count toward equality.
public abstract class ValueObject
{
// Each child lists the values that define equality.
protected abstract IEnumerable<object> GetEqualityComponents();
public override bool Equals(object? obj)
{
if (obj is null || obj.GetType() != GetType())
return false;
var other = (ValueObject)obj;
return GetEqualityComponents()
.SequenceEqual(other.GetEqualityComponents());
}
public override int GetHashCode()
{
return GetEqualityComponents()
.Aggregate(0, (hash, value) =>
HashCode.Combine(hash, value?.GetHashCode() ?? 0));
}
}Now a child class decides what counts. Here the address ignores letter case for the city.
public class Address : ValueObject
{
public string Street { get; }
public string City { get; }
public string PinCode { get; }
public Address(string street, string city, string pinCode)
{
Street = street;
City = city;
PinCode = pinCode;
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Street;
yield return City.ToLowerInvariant(); // case-insensitive city
yield return PinCode;
}
}So "Pune" and "pune" now count as the same city. A record could not do this without extra work.
Way 3: The readonly struct (the lightweight way)
For very small values you want many copies of, a readonly struct can be a good fit. Structs live on the stack and avoid some memory work. The readonly keyword keeps it immutable.
public readonly struct Temperature
{
public double Celsius { get; }
public Temperature(double celsius) => Celsius = celsius;
public double ToFahrenheit() => (Celsius * 9 / 5) + 32;
}Use this only when you truly need the performance. For most value objects, a record is simpler and clearer.
When should you reach for a value object?
A common beginner mistake is to use plain primitives like string and decimal everywhere. This is sometimes called "primitive obsession". Look at this method signature.
// What does this even mean? Easy to mix up the arguments.
public void Transfer(decimal amount, string from, string to, string currency);It is too easy to pass the wrong value in the wrong spot. The compiler will not warn you. Now compare with value objects.
public void Transfer(Money amount, AccountId from, AccountId to);This reads like a sentence. You cannot swap from and to by accident if they are typed. The value objects carry meaning and validation. This is the real payoff of the pattern.
From primitives to value objects
Steps
Raw values
string, decimal
Wrap
Money, Email
Validate
reject bad data
Use safely
clear methods
Here is an Email value object that refuses to exist if the text is not a valid email. Once you hold an Email, you know it is valid. No need to check again later.
public record Email
{
public string Value { get; }
public Email(string value)
{
if (string.IsNullOrWhiteSpace(value) || !value.Contains('@'))
throw new ArgumentException("Invalid email address.");
Value = value.Trim().ToLowerInvariant();
}
public override string ToString() => Value;
}This idea is powerful. The type itself becomes a guarantee. If a method takes an Email, bad emails can never reach it.
Saving value objects with EF Core
A common question: how do you store value objects in a database? Most value objects do not get their own table. They live inside the entity that owns them. Entity Framework Core supports this with owned types.
For example, a Customer entity owns an Address. The address columns are stored right inside the customer table.
public class Customer
{
public int Id { get; private set; } // identity = entity
public string Name { get; private set; } = "";
public Address Address { get; private set; } = null!; // value object
}
// In your DbContext configuration:
modelBuilder.Entity<Customer>().OwnsOne(c => c.Address);With OwnsOne, EF Core adds columns like Address_Street, Address_City, and Address_PinCode to the Customers table. The address has no ID of its own, which matches our rule perfectly.
A few good habits
As you start using value objects, keep these in mind.
- Validate in the constructor. A value object should never exist in a broken state. If the data is bad, throw early.
- Add behaviour, not just data. Methods like
Money.AddorDateRange.Overlapsbelong on the value object. Keep logic close to the data it uses. - Return new objects, never edit. Any "change" should produce a fresh value object.
- Keep them small. A value object should describe one clear idea.
- Do not give them an ID. The moment you add an
Id, you probably have an entity instead.
One small note on equality and case rules: if your domain says "MG Road" and "mg road" are the same address, decide that once, inside the value object. Then the whole app behaves the same way. That is the beauty of putting the rule in one place.
A note on the .NET landscape
Value objects are a core part of domain-driven design and work the same way across recent .NET versions. With .NET 10 (LTS) and C# 14, records remain the simplest path, and EF Core still maps them as owned types. C# 15, which brings union types, is in .NET 11 preview and may make some value-object style modelling even nicer in the future, but you do not need it today. Plain records cover almost every case.
If you read older DDD tutorials, you may see libraries used for messaging like MediatR or MassTransit. Note that both are now commercially licensed. They are not needed for value objects at all, so you can ignore them for this topic.
Quick recap
- A value object is described only by its values and has no identity, like money or an address.
- An entity has an identity (an ID), like a student or a bank account.
- Value objects follow two rules: equality by value and immutability.
- The easiest way to build one in .NET is a
record, which gives value equality for free. - Use a
ValueObjectbase class when you need to control exactly which fields count toward equality. - A
readonly structsuits tiny values where performance matters. - Value objects fight primitive obsession by wrapping raw values with meaning and validation.
- In EF Core, store value objects as owned types inside the owning entity's table.
References and further reading
- Implementing value objects - Microsoft Learn
- Value Objects in .NET (DDD Fundamentals) - Milan Jovanovic
- C# 9 Records as DDD Value Objects - Enterprise Craftsmanship
- Entity vs Value Object: the ultimate list of differences - Enterprise Craftsmanship
- Value Object - DevIQ
Related Posts
What Invariants Are and Why the Domain Model Enforces Them Best
Learn what invariants are in DDD with simple examples, and why your .NET domain model and aggregate roots are the safest place to keep business rules true.
Refactoring From an Anemic Domain Model to a Rich Domain Model in .NET
A friendly, step-by-step guide to turning a data-only anemic domain model into a rich domain model in C# .NET, with rules living inside your objects.
CQRS Pattern in .NET: The Way It Should Have Been From the Start
A friendly, hands-on guide to the CQRS pattern in .NET 10. Learn commands, queries, handlers, diagrams, and when to actually use it.
From Anemic Models to Behavior-Driven Models: A Practical DDD Refactor in C#
Learn to refactor anemic C# classes into rich, behavior-driven domain models using DDD. A simple, step-by-step guide with diagrams and real code.
The Interview Question That Changed How I Think About System Design
One simple interview question taught me that good system design is not about fancy tools. It is about honest trade-offs, in plain .NET.
Refactoring a Modular Monolith Without MediatR in .NET
Learn to remove MediatR from a .NET modular monolith using plain handlers and a tiny dispatcher, with CQRS, pipeline behaviors, and clear module boundaries.