EF Core Owned Entities — Mapping Value Objects
Map domain value objects as EF Core owned entities: OwnsOne, OwnsMany, table splitting, JSON column storage, and the patterns for keeping value objects in the domain while persisting them correctly.
Why Owned Entities
Value objects in DDD have no identity of their own — they are defined by their values. Address, Money, DosageValue, WarfarinDose are value objects. EF Core owned entities map these to the parent entity's table (or a separate table) without requiring a separate primary key.
Owned entity: NOT an independent entity
No DbSet Cannot be queried independently
No primary key Belongs entirely to the owner
Lives in owner's table (or its own table with OwnsMany)
Lifecycle tied to owner Delete owner → owned entity deletedOwnsOne — Same Table
// Domain: Address value object
public sealed class Address
{
public string Street { get; private set; }
public string City { get; private set; }
public string Country { get; private set; }
public string PostCode { get; private set; }
private Address() { }
public Address(string street, string city, string country, string postCode)
{
Street = street;
City = city;
Country = country;
PostCode = postCode;
}
}
// Patient owns Address — stored in the patients table
public sealed class PatientConfiguration : IEntityTypeConfiguration<Patient>
{
public void Configure(EntityTypeBuilder<Patient> builder)
{
builder.OwnsOne(p => p.HomeAddress, address =>
{
address.Property(a => a.Street)
.HasColumnName("home_street")
.HasMaxLength(200);
address.Property(a => a.City)
.HasColumnName("home_city")
.HasMaxLength(100);
address.Property(a => a.Country)
.HasColumnName("home_country")
.HasMaxLength(100);
address.Property(a => a.PostCode)
.HasColumnName("home_postcode")
.HasMaxLength(10);
});
}
}
// Generates columns: home_street, home_city, home_country, home_postcode in patients tableOwnsOne — Nested Owned Entities
// Prescription → DosageValue → DosageUnit (nested)
public sealed class DosageValue
{
public decimal Amount { get; private set; }
public string Unit { get; private set; } // "mg", "mcg", "units/kg"
}
public sealed class WarfarinDose
{
public DosageValue Dose { get; private set; }
public string Instructions { get; private set; }
}
// Configuration: nested OwnsOne
builder.OwnsOne(p => p.CurrentDose, dose =>
{
dose.OwnsOne(d => d.Dose, dosage =>
{
dosage.Property(v => v.Amount)
.HasColumnName("dose_amount")
.HasColumnType("decimal(10,4)");
dosage.Property(v => v.Unit)
.HasColumnName("dose_unit")
.HasMaxLength(20);
});
dose.Property(d => d.Instructions)
.HasColumnName("dose_instructions")
.HasMaxLength(500);
});OwnsMany — Collection of Value Objects
// Patient has a list of phone numbers (value objects)
public sealed class PhoneNumber
{
public string Type { get; private set; } // "mobile", "home", "work"
public string Number { get; private set; }
}
// Configuration: OwnsMany — stored in a separate table
builder.OwnsMany(p => p.PhoneNumbers, phone =>
{
phone.ToTable("patient_phone_numbers");
phone.WithOwner().HasForeignKey("patient_id");
phone.Property(ph => ph.Type)
.HasMaxLength(20)
.IsRequired();
phone.Property(ph => ph.Number)
.HasMaxLength(30)
.IsRequired();
phone.HasIndex(ph => ph.Number);
});
// Generates table: patient_phone_numbers with FK patient_id → patientsJSON Column Storage (.NET 8+)
// Store complex value object as a JSON column (SQL Server 2022+ / PostgreSQL)
// Useful for: audit history, flexible structures, arrays of value objects
public sealed class AllergyRecord
{
public string Allergen { get; private set; }
public string Reaction { get; private set; }
public DateTime RecordedAt { get; private set; }
public string Severity { get; private set; }
}
// Patient.Allergies stored as JSON in a single column
builder.OwnsMany(p => p.Allergies, allergy =>
{
allergy.ToJson(); // store the whole collection as JSON
// No separate table, no FK — serialized directly in patients.allergies column
});
// Querying still works:
var patients = await _db.Patients
.Where(p => p.Allergies.Any(a => a.Allergen == "Warfarin"))
.ToListAsync(ct);
// EF Core translates this to SQL JSON_VALUE / OPENJSON on supported databasesValue Converter Alternative
// For simple single-column value objects: use a value converter instead of owned entity
public sealed record PatientMrn(string Value);
builder.Property(p => p.Mrn)
.HasConversion(
mrn => mrn.Value,
value => new PatientMrn(value))
.HasMaxLength(20);
// Owned entity vs value converter:
// Owned entity: multiple properties, complex structure, OwnsOne/OwnsMany
// Value converter: single column, simple wrapper type (ID, MRN, money amount)Null Handling for Owned Entities
// If the owned entity can be null, EF stores null columns
// All columns of the owned entity are nullable in the DB even if the CLR type is non-nullable
// Must configure explicitly
builder.OwnsOne(p => p.HomeAddress, address =>
{
address.Property(a => a.Street).IsRequired(); // will be NOT NULL in DB
address.Property(a => a.City).IsRequired();
address.Property(a => a.Country).IsRequired();
address.Property(a => a.PostCode).IsRequired();
});
// If Patient.HomeAddress can be null (patient hasn't provided an address):
// All home_street, home_city, etc. columns will be nullable.
// EF returns null for the owned entity if all columns are null.Production issue I've seen: A team used
OwnsManyfor an audit history collection that could grow to thousands of entries per patient. The entireOwnsManytable was loaded whenever the patient aggregate was loaded, because EF Core includes owned collections by default. For one ward query that loaded 50 patients, this pulled 50,000 audit rows into memory. The fix was to either use JSON storage (ToJson()), or query the audit table separately rather than treating it as an owned collection.
Key Takeaway
Use
OwnsOne()for value objects stored in the parent table (Address, DosageValue). UseOwnsMany()for collections of value objects in a child table. Use.ToJson()in .NET 8+ for flexible collections on supporting databases. Use value converters for single-column wrappers (IDs, MRNs). Avoid largeOwnsManycollections that are always loaded with the aggregate — they become performance traps.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.