EF Core Owned Entities and Complex Types — Value Objects in Your Domain
Use EF Core owned entities and complex types (EF Core 8) to map DDD value objects: Address, Money, DateRange. Understand the difference between owned entities and complex types, table splitting, JSON column mapping, and collection-of-owned patterns.
EF Core Owned Entities and Complex Types — Value Objects in Your Domain
In Domain-Driven Design, value objects represent concepts that have no identity — only value. An Address is the same regardless of which object holds it; what matters is its content (street, city, postal code). EF Core maps these with owned entities (EF Core 2.0+) and complex types (EF Core 8). Getting this right removes the need for flattened columns, reduces duplication, and makes your domain model honest.
What you'll learn:
- Owned entities: table splitting and separate tables
- Complex types (EF Core 8): the lighter-weight alternative
- Collections of owned entities
- JSON column mapping for flexible value objects
- Common mistakes and how to avoid them
The Problem: Flattened Value Objects
Without owned entities, value objects end up as flat columns or separate tables with IDs — neither of which maps to the domain model:
// Flattened — violates DDD, exposes DB structure in domain
public class Order
{
public int Id { get; private set; }
// Shipping address — flat columns instead of a value object
public string ShippingStreet { get; private set; } = "";
public string ShippingCity { get; private set; } = "";
public string ShippingCountry { get; private set; } = "";
public string ShippingPostalCode { get; private set; } = "";
}With owned entities, the domain model can use a proper value object:
public class Order
{
public int Id { get; private set; }
public Address ShippingAddress { get; private set; } = null!;
public Money Total { get; private set; } = null!;
}1. Owned Entities
An owned entity is a type that always belongs to another entity (the owner). It has no identity of its own — EF Core manages it as part of the owner.
Define the value object
// Domain/ValueObjects/Address.cs
public class Address
{
public string Street { get; private set; }
public string City { get; private set; }
public string PostalCode { get; private set; }
public string Country { get; private set; }
private Address() { } // EF Core needs this
public Address(string street, string city, string postalCode, string country)
{
Street = street;
City = city;
PostalCode = postalCode;
Country = country;
}
// Value equality
public bool Equals(Address? other) =>
other is not null &&
Street == other.Street &&
City == other.City &&
PostalCode == other.PostalCode &&
Country == other.Country;
}Configure as owned (table splitting — same table as owner)
// Infrastructure/Persistence/Configurations/OrderConfiguration.cs
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("orders");
builder.HasKey(o => o.Id);
// OwnsOne maps to the same table by default
builder.OwnsOne(o => o.ShippingAddress, address =>
{
address.Property(a => a.Street)
.HasColumnName("shipping_street")
.HasMaxLength(200)
.IsRequired();
address.Property(a => a.City)
.HasColumnName("shipping_city")
.HasMaxLength(100)
.IsRequired();
address.Property(a => a.PostalCode)
.HasColumnName("shipping_postal_code")
.HasMaxLength(20)
.IsRequired();
address.Property(a => a.Country)
.HasColumnName("shipping_country")
.HasColumnType("char(2)") // ISO country code
.IsRequired();
});
// Multiple owned entities of different types
builder.OwnsOne(o => o.BillingAddress, address =>
{
address.Property(a => a.Street).HasColumnName("billing_street");
address.Property(a => a.City).HasColumnName("billing_city");
address.Property(a => a.PostalCode).HasColumnName("billing_postal_code");
address.Property(a => a.Country).HasColumnName("billing_country");
});
}
}Result: the orders table has shipping_street, shipping_city, shipping_postal_code, shipping_country, billing_street, etc. columns — no join needed.
Owned entity in a separate table
builder.OwnsOne(o => o.ShippingAddress, address =>
{
address.ToTable("order_shipping_addresses");
// PK is the same as the owner's PK by default
});Separate table makes sense when the owned value is large, rarely needed, or you want to keep the owner table narrow.
2. Money Value Object
public class Money
{
public decimal Amount { get; private set; }
public string Currency { get; private set; }
private Money() { }
public Money(decimal amount, string currency)
{
if (amount < 0) throw new ArgumentException("Amount cannot be negative");
Amount = amount;
Currency = currency.ToUpper();
}
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot add different currencies");
return new Money(Amount + other.Amount, Currency);
}
public static Money operator +(Money a, Money b) => a.Add(b);
public static Money Zero(string currency) => new(0, currency);
}builder.OwnsOne(o => o.Total, money =>
{
money.Property(m => m.Amount)
.HasColumnName("total_amount")
.HasColumnType("decimal(18,2)")
.IsRequired();
money.Property(m => m.Currency)
.HasColumnName("total_currency")
.HasMaxLength(3)
.IsRequired();
});3. Complex Types (EF Core 8)
EF Core 8 introduced complex types — a lighter alternative to owned entities. The key differences:
| Feature | Owned Entity | Complex Type | |---|---|---| | Can have navigation properties | Yes | No | | Can be a collection element | Yes | EF Core 8+ with limitations | | Can be null | Yes | No (always has a value) | | Requires private constructor | Yes | No | | Shadow properties | Yes | No | | Independent table | Yes | No |
Complex types are for simple value objects with no navigation properties and no nullability:
// No private constructor needed
public record Coordinates(double Latitude, double Longitude);public class DeliveryRoute
{
public int Id { get; private set; }
public Coordinates StartPoint { get; private set; } = null!;
public Coordinates EndPoint { get; private set; } = null!;
public int DistanceKm { get; private set; }
}public class DeliveryRouteConfiguration : IEntityTypeConfiguration<DeliveryRoute>
{
public void Configure(EntityTypeBuilder<DeliveryRoute> builder)
{
builder.ComplexProperty(r => r.StartPoint, coords =>
{
coords.Property(c => c.Latitude).HasColumnName("start_lat");
coords.Property(c => c.Longitude).HasColumnName("start_lng");
});
builder.ComplexProperty(r => r.EndPoint, coords =>
{
coords.Property(c => c.Latitude).HasColumnName("end_lat");
coords.Property(c => c.Longitude).HasColumnName("end_lng");
});
}
}Complex types cannot be null — EF Core assumes a value is always present. Use owned entities when nullability is required.
4. Collections of Owned Entities
public class Order
{
public int Id { get; private set; }
private readonly List<OrderLine> _lines = new();
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
public void AddLine(string sku, int quantity, Money unitPrice)
{
_lines.Add(new OrderLine(sku, quantity, unitPrice));
}
}
public class OrderLine
{
public string Sku { get; private set; } = "";
public int Quantity { get; private set; }
public Money UnitPrice { get; private set; } = null!;
private OrderLine() { }
public OrderLine(string sku, int quantity, Money unitPrice)
{
Sku = sku;
Quantity = quantity;
UnitPrice = unitPrice;
}
}builder.OwnsMany(o => o.Lines, line =>
{
line.ToTable("order_lines");
// EF Core generates a shadow FK (order_id) and a shadow PK
line.WithOwner().HasForeignKey("order_id");
line.Property<int>("id").ValueGeneratedOnAdd();
line.HasKey("id");
line.Property(l => l.Sku).HasMaxLength(50).IsRequired();
line.Property(l => l.Quantity).IsRequired();
line.OwnsOne(l => l.UnitPrice, money =>
{
money.Property(m => m.Amount).HasColumnName("unit_price").HasColumnType("decimal(18,2)");
money.Property(m => m.Currency).HasColumnName("unit_currency").HasMaxLength(3);
});
});OwnsMany stores the collection in a separate table. The collection is loaded with the owner (no lazy loading — owned entities are always loaded eagerly).
5. JSON Column Mapping (EF Core 8)
For complex, variable-structure value objects, storing as a JSON column avoids over-normalising:
public class Product
{
public int Id { get; private set; }
public string Name { get; private set; } = "";
public ProductAttributes Attributes { get; private set; } = null!;
}
public class ProductAttributes
{
public string? Colour { get; set; }
public string? Size { get; set; }
public double? WeightKg { get; set; }
public List<string> Tags { get; set; } = new();
public Dictionary<string, string> CustomFields { get; set; } = new();
}builder.OwnsOne(p => p.Attributes, attrs =>
{
attrs.ToJson(); // stores the entire Attributes object as a JSON column
});Result: the products table has an attributes column containing:
{"colour":"red","size":"M","weightKg":0.5,"tags":["sale","new"],"customFields":{"origin":"UK"}}PostgreSQL and SQL Server both support JSON querying on this column. EF Core translates some LINQ queries into JSON path operators:
// Translates to JSON path query in PostgreSQL
var redProducts = await ctx.Products
.Where(p => p.Attributes.Colour == "red")
.ToListAsync();6. Common Mistakes
Making owned entity properties nullable when they shouldn't be:
// Wrong — Address should never be null on a ShippedOrder
builder.OwnsOne(o => o.ShippingAddress, address =>
{
address.Property(a => a.Street).IsRequired(false); // makes no domain sense
});
// Correct — street is always required
address.Property(a => a.Street).IsRequired();Forgetting to require owned navigation in query:
Owned entities are loaded automatically when querying the owner. Unlike regular navigations, they don't need Include — but they also can't be loaded independently.
Using owned entities for entities that need their own identity:
// Wrong: Customer shouldn't be owned — it has its own lifecycle
builder.OwnsOne(o => o.Customer); // incorrect
// Correct: use a FK relationship
builder.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId);Owned entities are for value objects — concepts defined by their attributes, with no independent lifecycle. If you can ask "is this the same X?" by comparing an ID rather than comparing all properties, it's an entity, not a value object.
Migrations: When you add or change owned entity properties, EF Core adds/alters columns on the owner's table. These migrations look the same as regular column additions — review them carefully to ensure column names match your conventions.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.