Entities and Value Objects: Identity, Equality, and Immutable Models with C# Records
An entity is defined by an identity that lasts through time, a value object only by its value: two Money values are the same if amount and currency match, two Orders only if their Ids do. We write Money, Isbn and strongly typed ids with C# records and readonly record structs, map them with the complex types EF Core 10 recommends over owned types, and show the collection-in-record, struct default and with pitfalls with real output.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Bookstore's campaign team wants to define book bundles. One of the developers writes the bundle as a record, because value equality comes "for free":
public sealed record Bundle(string Name, List<BookId> Books);
Bundle a = new("Classics", [dune, emma]);
Bundle b = new("Classics", [dune, emma]);
Console.WriteLine(a == b);
Same name, same two books, same order. Do you see True or False? Second question: Money is a readonly record struct whose constructor validates the currency. After Money empty = default;, what is empty.Currency?
Entity: An Identity That Lasts Through Time
To put the definition from Eric Evans's DDD (Domain-Driven Design) Reference in my own words: some objects are defined not by their attributes but by an identity that continues through time. Ada changes her email address, moves house, changes her surname; she is still the same customer. An order goes from Placed to Shipped, one of its items is cancelled; it is still the same order. These objects are entities, and what tells them apart is an identity: CustomerId, OrderId.
You can see the distinction in .NET itself. System.Diagnostics.Process behaves like an entity: WorkingSet64 or Threads keep changing, but the process is the same process by its Id. DateOnly, TimeSpan, decimal or Guid, on the other hand, are values: there is no difference between new DateOnly(2026, 11, 8) and another new DateOnly(2026, 11, 8) created elsewhere, one replaces the other and neither changes; AddDays returns a new value.
In C# an entity is usually a class with an Id property. Most teams' first reflex here is to write an Entity<TId> base class and override Equals on the Id. Most of the time you don't need it: Entity Framework Core (EF Core) keeps a single instance per row within a DbContext (identity resolution), and domain code comparing two orders can write a.Id == b.Id. Surprises such as two new entities without assigned Ids being "equal" come from the base class's Equals.
Value Object: It Is What Its Value Is
A value object, in contrast, means something only through its value. The test in Martin Fowler's ValueObject article is simple: if you compare two objects by their contents rather than their identity, it is a value object. In Bookstore, money, addresses and ISBNs (International Standard Book Number) are like this. 12.50 EUR and another 12.50 EUR created somewhere else are the same thing. A value object has two more rules: it is immutable, and it protects its own rules. Bookstore's Money is the only type in the Shared Kernel between Ordering and Payments on the series' context map:
using System.Globalization;
namespace Bookstore.SharedKernel;
public readonly record struct Money
{
public Money(decimal amount, string currency)
{
ArgumentOutOfRangeException.ThrowIfNegative(amount);
if (currency is not ("EUR" or "TRY"))
throw new ArgumentException($"Unsupported currency '{currency}'.", nameof(currency));
Amount = decimal.Round(amount, 2, MidpointRounding.ToEven);
Currency = currency;
}
public decimal Amount { get; }
public string Currency { get; }
public static Money Zero(string currency) => new(0m, currency);
public static Money operator +(Money left, Money right)
{
EnsureSameCurrency(left, right);
return new Money(left.Amount + right.Amount, left.Currency);
}
public static Money operator *(Money price, int quantity) => new(price.Amount * quantity, price.Currency);
private static void EnsureSameCurrency(Money left, Money right)
{
if (left.Currency != right.Currency)
throw new InvalidOperationException($"Cannot combine {left.Currency} and {right.Currency}.");
}
public override string ToString() => $"{Amount.ToString("0.00", CultureInfo.InvariantCulture)} {Currency}";
}
The currency check is in the constructor, addition and multiplication are in the operators. No code that uses Money has to ask "are these two amounts in the same currency?"; the code that asks is compiled in exactly one place. Let's try it:
using Bookstore.Catalog;
using Bookstore.Ordering;
using Bookstore.SharedKernel;
Money price = new(12.50m, "EUR");
Money same = new(12.5m, "EUR");
Console.WriteLine($"{price == same} {price.Equals(same)} {price}");
Console.WriteLine(price * 3 + new Money(4.99m, "EUR"));
try { Console.WriteLine(price + new Money(450m, "TRY")); }
catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); }
Money empty = default;
Console.WriteLine($"[{empty.Amount}] [{empty.Currency ?? "null"}]");
Isbn dune = Isbn.Parse("978-0-441-17271-9");
Console.WriteLine($"{dune} {dune == Isbn.Parse("9780441172719")} {Isbn.TryParse("978-0-441-17271-8", out _)}");
OrderId orderId = OrderId.New();
Console.WriteLine(orderId);
True True 12.50 EUR
42.49 EUR
Cannot combine EUR and TRY.
[0] [null]
9780441172719 True False
OrderId { Value = 01a1b804-3171-7a3b-834f-207362fec040 }
12.50 and 12.5 are equal, because the Equals generated for the record struct compares each field with its own type's equality and decimal ignores scale. EUR and TRY (Turkish lira) don't add up. The default line answers the opening's second question: Currency is null. A struct's default value is created without running any constructor, zeroing every field, and the "must be EUR or TRY" rule is skipped entirely.
record, record struct or class?
C# gives you three tools for writing value objects. We covered the equality and with expression that records generate in the C# series post "C# record and record struct: Value Equality and the with Expression", and the copy semantics of structs in "struct vs class in C#: Copy Semantics and readonly struct". From a DDD point of view, the choice rests on these criteria:
| Option | Upside | Cost |
|---|---|---|
sealed record | Value equality, ToString and with out of the box; can be null but no default surprise | Every value is a heap allocation; with can skip validation in init properties |
readonly record struct | No allocation, can't be null, can be a complex type in EF Core 10 | default bypasses the constructor; not supported in EF Core complex collections |
class + IEquatable<T> | Full control of equality (e.g. a value object containing a collection) | Writing and testing Equals, GetHashCode and the operators by hand |
My rule of thumb: small, frequently created types that always have a value (Money, strongly typed ids) are readonly record struct; types with strings or more complex validation inside (Isbn, Address) are sealed record. Wherever you pick a struct, you take the default risk knowingly; we accept it for Money because it never arrives on its own across an API (Application Programming Interface) boundary, it always comes out of a factory or an operator.
Isbn: Getting Rid of Primitive Obsession
Carrying an ISBN as a string means every method has to ask "is this really an ISBN?" itself. We talked about primitive obsession in general, while separating DTOs (Data Transfer Object) from domain objects, in the Clean Code series post "Objects or Data Structures? Records, DTOs and Primitive Obsession"; on the DDD side it's called "making the concept explicit in the model". ISBN-13 has a checksum rule, and that rule should live in exactly one place:
using System.Diagnostics.CodeAnalysis;
namespace Bookstore.Catalog;
public sealed record Isbn
{
private Isbn(string value) => Value = value;
public string Value { get; }
public static Isbn Parse(string text) =>
TryParse(text, out Isbn? isbn) ? isbn : throw new FormatException($"'{text}' is not a valid ISBN-13.");
public static bool TryParse(string? text, [NotNullWhen(true)] out Isbn? isbn)
{
isbn = null;
string digits = new((text ?? "").Where(char.IsAsciiDigit).ToArray());
if (digits.Length != 13 || !(digits.StartsWith("978") || digits.StartsWith("979")))
return false;
int sum = 0;
for (int i = 0; i < 12; i++)
sum += (digits[i] - '0') * (i % 2 == 0 ? 1 : 3);
if ((10 - sum % 10) % 10 != digits[12] - '0')
return false;
isbn = new Isbn(digits);
return true;
}
public override string ToString() => Value;
}
The constructor is private; the only way to get an Isbn is through Parse or TryParse. That way the hyphenated "978-0-441-17271-9" and the plain "9780441172719" normalize to the same value and come out equal, as you saw in the output above, and an ISBN with a wrong last digit is rejected. The pattern itself is familiar from the BCL (Base Class Library): Guid.Parse/Guid.TryParse and IPAddress.Parse/IPAddress.TryParse offer the same pair.
Strongly Typed Ids
Entity identities are where primitive obsession shows up most. Guid orderId and Guid bookId are the same type to the compiler; passing the wrong Id to a method surfaces at runtime as "order not found". We write the glossary's ids as one-line record structs:
namespace Bookstore.Ordering;
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.CreateVersion7());
}
public readonly record struct BookId(Guid Value)
{
public static BookId New() => new(Guid.CreateVersion7());
}
using Bookstore.Ordering;
BookId bookId = BookId.New();
CancelOrder(bookId);
static void CancelOrder(OrderId id) => Console.WriteLine($"Cancelling {id}");
Program.cs(4,13): error CS1503: Argument 1: cannot convert from 'Bookstore.Ordering.BookId' to 'Bookstore.Ordering.OrderId'
The error now happens at compile time. Guid.CreateVersion7() has been available since .NET 9 and produces a time-ordered UUID (Universally Unique Identifier); in a database index it causes far fewer page splits than a random Guid. We covered the value converter mechanics for strongly typed ids on the EF Core side in the EF Core series post "Owned Types and Value Converters in EF Core: Addresses, Enums and Strongly Typed Ids"; here we just use them.
Value Objects in EF Core 10: Complex Types
When you write a value object to the database, EF Core gives you two options: owned entity types and complex types. We saw owned types in post 20 of the EF Core series, the first version of complex types in "What's New in EF Core 8: Complex Types, Primitive Collections and SqlQuery<T>", and the JSON (JavaScript Object Notation) and collection support in EF Core 10 in "What's New in EF Core 10: Named Query Filters, LeftJoin and Complex Types in JSON". What matters from a DDD point of view is this: an owned type is an entity with a hidden key behind the scenes, while a complex type is truly identity-less. Microsoft's EF Core 10 documentation now advises those using owned types for table splitting and JSON to switch to complex types. For the first time, the DDD definition of a value object and its EF counterpart line up one to one.
The small Order in post 07 focuses only on its value object properties; it grows into an aggregate in 09 and 10:
using Bookstore.SharedKernel;
namespace Bookstore.Ordering;
public readonly record struct CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.CreateVersion7());
}
public sealed record Address(string Street, string City, string PostalCode, string Country);
public sealed class Order
{
private Order() { }
public Order(CustomerId customerId, Address shippingAddress, Money total)
{
Id = OrderId.New();
CustomerId = customerId;
ShippingAddress = shippingAddress;
Total = total;
}
public OrderId Id { get; private set; }
public CustomerId CustomerId { get; private set; }
public Address ShippingAddress { get; private set; } = null!;
public Address? BillingAddress { get; private set; }
public Money Total { get; private set; }
public void ChangeShippingAddress(Address address) => ShippingAddress = address;
public void UseShippingAddressForBilling() => BillingAddress = ShippingAddress;
}
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Bookstore.Ordering.Infrastructure;
public sealed class OrderingDbContext(bool log = false) : DbContext
{
public DbSet<Order> Orders => Set<Order>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
options.UseSqlite("Data Source=orders07.db");
if (log) options.LogTo(Console.WriteLine, [DbLoggerCategory.Database.Command.Name], LogLevel.Information);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(order =>
{
order.HasKey(o => o.Id);
order.Property(o => o.Id).HasConversion(id => id.Value, value => new OrderId(value));
order.Property(o => o.CustomerId).HasConversion(id => id.Value, value => new CustomerId(value));
order.ComplexProperty(o => o.ShippingAddress);
order.ComplexProperty(o => o.BillingAddress);
order.ComplexProperty(o => o.Total, money =>
{
money.Property(m => m.Amount).HasColumnName("TotalAmount").HasPrecision(18, 2);
money.Property(m => m.Currency).HasColumnName("TotalCurrency").HasMaxLength(3);
});
});
}
}
Three things to notice. BillingAddress is nullable, and EF Core 10 maps it as an optional complex type; this works for complex types that have at least one required (non-nullable) property. Total is a struct and we name its columns ourselves. The ids go down to Guid through converters. When I ran the program, this is the table it created and the queries that followed:
using Bookstore.Ordering;
using Bookstore.Ordering.Infrastructure;
using Bookstore.SharedKernel;
using Microsoft.EntityFrameworkCore;
File.Delete("orders07.db");
await using (OrderingDbContext db = new())
{
await db.Database.EnsureCreatedAsync();
Console.WriteLine(db.Database.GenerateCreateScript());
}
OrderId id;
await using (OrderingDbContext db = new())
{
Address home = new("1 Baker St", "London", "NW1 6XE", "GB");
Order order = new(CustomerId.New(), home, new Money(42.49m, "EUR"));
order.UseShippingAddressForBilling();
db.Orders.Add(order);
await db.SaveChangesAsync();
id = order.Id;
}
await using (OrderingDbContext db = new(log: true))
{
Order order = await db.Orders.SingleAsync(o => o.Id == id);
Console.WriteLine($"{order.Total} | {order.ShippingAddress == order.BillingAddress}");
order.ChangeShippingAddress(order.ShippingAddress with { Street = "221B Baker St" });
await db.SaveChangesAsync();
}
await using (OrderingDbContext db = new())
{
int sameAddress = await db.Orders.CountAsync(o => o.ShippingAddress == o.BillingAddress);
Money total = new(42.49m, "EUR");
int byTotal = await db.Orders.CountAsync(o => o.Total == total);
Console.WriteLine($"{sameAddress} {byTotal}");
}
CREATE TABLE "Orders" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Orders" PRIMARY KEY,
"CustomerId" TEXT NOT NULL,
"BillingAddress_City" TEXT NULL,
"BillingAddress_Country" TEXT NULL,
"BillingAddress_PostalCode" TEXT NULL,
"BillingAddress_Street" TEXT NULL,
"ShippingAddress_City" TEXT NOT NULL,
"ShippingAddress_Country" TEXT NOT NULL,
"ShippingAddress_PostalCode" TEXT NOT NULL,
"ShippingAddress_Street" TEXT NOT NULL,
"TotalAmount" TEXT NOT NULL,
"TotalCurrency" TEXT NOT NULL
);
42.49 EUR | True
UPDATE "Orders" SET "ShippingAddress_Street" = @p0
WHERE "Id" = @p1
RETURNING 1;
0 1
Assigning the same Address instance to two properties just worked; with an owned type this scenario threw an exception. When the address changed we created a new value with with, and EF updated only the column that changed. In the last line two LINQ (Language Integrated Query) queries compared complex values as a whole: 0 because the addresses now differ, 1 for the order whose total is 42.49 EUR.
Common Pitfalls
A collection in a record. The answer to the opening question is False. The Equals a record generates compares List<BookId> by reference; two different lists with the same elements aren't equal. You have to write the equality yourself:
using Bookstore.Ordering;
BookId dune = BookId.New();
BookId emma = BookId.New();
Bundle a = new("Classics", [dune, emma]);
Bundle b = new("Classics", [dune, emma]);
Console.WriteLine(a == b);
SeriesBundle c = new("Classics", [dune, emma]);
SeriesBundle d = new("Classics", [dune, emma]);
Console.WriteLine(c == d);
public sealed record Bundle(string Name, List<BookId> Books);
public sealed record SeriesBundle(string Name, IReadOnlyList<BookId> Books)
{
public bool Equals(SeriesBundle? other) =>
other is not null && Name == other.Name && Books.SequenceEqual(other.Books);
public override int GetHashCode()
{
HashCode hash = new();
hash.Add(Name);
foreach (BookId id in Books) hash.Add(id);
return hash.ToHashCode();
}
}
False
True
A struct's default. We saw it above: Money empty = default; produces an unvalidated value. Don't make struct value objects DTO properties or the target of a parameterless deserializer.
with can skip validation. Putting validation in a property initializer of a positional record is very common, but with doesn't call the primary constructor; it copies the object and runs only the init accessor of the property that changes:
Address home = new("1 Baker St", "London", "NW1 6XE", "GB");
Address broken = home with { PostalCode = "" };
Console.WriteLine($"[{broken.PostalCode}]");
try { _ = home with { City = " " }; }
catch (ArgumentException ex) { Console.WriteLine(ex.Message); }
public sealed record Address(string Street, string City, string PostalCode, string Country)
{
public string PostalCode { get; init; } = string.IsNullOrWhiteSpace(PostalCode)
? throw new ArgumentException("Postal code is required.", nameof(PostalCode))
: PostalCode;
public string City
{
get;
init => field = string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("City is required.", nameof(City))
: value;
} = City;
}
[]
City is required. (Parameter 'City')
The PostalCode rule ran only on the constructor path; with let an empty postal code through. For City, the validation lives inside the init accessor thanks to C# 14's field keyword, and with has to go through it. We covered field in the C# series post "What's New in C# 14: Extension Members, field and Null-Conditional Assignment".
A struct without setters and EF Core. Money's properties only have getters, and EF Core doesn't map those by convention. When I left the line order.ComplexProperty(o => o.Total); without the column settings, building the model failed with:
System.InvalidOperationException: No suitable constructor was found for the type 'Order.Total#Money'. The following constructors had parameters that could not be bound to properties of the type:
Cannot bind 'amount', 'currency' in 'Order.Total#Money(decimal amount, string currency)'
Note that only mapped properties can be bound to constructor parameters. Navigations to related entities, including references to owned types, cannot be bound.
The fix is to write Property(m => m.Amount) and Property(m => m.Currency) explicitly, as above. EF then uses the constructor, which means every value read from the database goes through validation again. We'll see the good and the bad side of that in post 10.
A struct collection. I tried to write a campaign's threshold amounts to JSON as a List<Money>:
modelBuilder.Entity<Promotion>().ComplexCollection(p => p.Thresholds, money =>
{
money.ToJson();
money.Property(m => m.Amount);
money.Property(m => m.Currency);
});
InvalidOperationException: The complex type collection 'Promotion.Thresholds' cannot be configured because complex value type collections are not supported. See https://github.com/dotnet/efcore/issues/31411 for more information.
In EF Core 10, complex collection elements must be reference types. Write value objects that will live inside a collection as sealed record.
When Not to Use It
Turning every string into a value object turns the code into a museum of types. If a concept has no rules (an order note, a book's subtitle), a string is enough. Strongly typed ids have a cost too: extra setup for model binding, JSON serialization, Swagger schemas and EF converters. Using the split from post 03 of this series, in supporting and generic subdomains or in read-only reporting code that cost often doesn't pay off; in a core, rule-heavy context like Ordering it pays for itself in the first week.
A Small Experiment
★☆☆ Add an operator - to Money. What should happen if the result is negative: an exception, or a separate type that allows negative amounts (e.g. for refunds)? Write your decision down as one line in the glossary.
★★☆ Turn Address into an init accessor that validates with field, like City, and watch with { PostalCode = "" } throw. Also test that EF Core can still read the same class as a complex type.
★★★ Map SeriesBundle as a complex collection inside an entity in EF Core 10. How do the IReadOnlyList<BookId> property and the strongly typed id's converter behave inside JSON?
We now have small pieces that protect their own rules. But individually correct Money values and OrderItems can still add up to a wrong order: one with no items, one that mixes two currencies, or one that changes after it was cancelled. In the next post we move on to the always-valid domain model: protecting invariants with constructors, factories and guard clauses.