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.
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:
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:
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:
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:
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:
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:
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:
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:
- Complex Types in EF Core 10 - optional complex types, struct support and breaking changes
- JSON Type Support in EF Core 10 - storing complex types as JSON with full LINQ support
- Value Conversions in EF Core - when you need custom converters instead of complex types
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 CodeI hope you enjoyed it, subscribe and get a notification when a new blog is up!



