Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
EF CoreOwned EntitiesValue ObjectsDDDASP.NET Core.NET
Share:𝕏

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 deleted

OwnsOne — Same Table

C#
// 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 table

OwnsOne — Nested Owned Entities

C#
// 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

C#
// 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 → patients

JSON Column Storage (.NET 8+)

C#
// 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 databases

Value Converter Alternative

C#
// 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

C#
// 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 OwnsMany for an audit history collection that could grow to thousands of entries per patient. The entire OwnsMany table 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). Use OwnsMany() 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 large OwnsMany collections that are always loaded with the aggregate — they become performance traps.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.