How to Model Value Objects With EF Core

How to Model Value Objects With EF Core

By

7 min read··

ddddotnetef-core

Persist value objects in EF Core with a value conversion for a single column, a complex type for a group of values, or an owned type when you need entity-style mapping such as a separate table. Choose against your EF Core version and provider: optional complex properties and complex JSON support arrive in EF Core 10.

What Is a Value Object?

A value object is defined by its properties, not by an identity. Two Money objects with the same amount and currency are equal, regardless of where they exist in memory. If you're unsure whether something is a value object at all, start with entity vs value object.

public sealed record Money(decimal Amount, string Currency)
{
    public static Money Zero(string currency) => new(0, currency);

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Cannot add different currencies");

        return new Money(Amount + other.Amount, Currency);
    }
}

This record demonstrates equality and arithmetic; it does not validate supported currencies or monetary precision. The mapping examples belong in DbContext.OnModelCreating, use Microsoft.EntityFrameworkCore, and assume a configured relational provider. Entity declarations show persistence-relevant properties; add your application's constructors and domain methods around them.

Choose one mapping per property; the following sections show alternatives rather than configurations to apply together.

Option 1: Owned Types

Owned types map a value object's properties as columns in the owning entity's table:

public class Order
{
    public Guid Id { get; private set; }
    public Money TotalAmount { get; private set; } = null!;
    public Address ShippingAddress { get; private set; } = null!;
}

public sealed record Address(
    string Street, string City, string State, string ZipCode, string Country);

Configuration

modelBuilder.Entity<Order>(builder =>
{
    builder.HasKey(o => o.Id);

    builder.OwnsOne(o => o.TotalAmount, money =>
    {
        money.Property(m => m.Amount)
            .HasColumnName("total_amount")
            .HasPrecision(18, 2);

        money.Property(m => m.Currency)
            .HasColumnName("total_currency")
            .HasMaxLength(3);
    });

    builder.Navigation(o => o.TotalAmount).IsRequired();

    builder.OwnsOne(o => o.ShippingAddress, address =>
    {
        address.Property(a => a.Street).HasColumnName("shipping_street");
        address.Property(a => a.City).HasColumnName("shipping_city");
        address.Property(a => a.State).HasColumnName("shipping_state");
        address.Property(a => a.ZipCode).HasColumnName("shipping_zip");
        address.Property(a => a.Country).HasColumnName("shipping_country");
    });
    builder.Navigation(o => o.ShippingAddress).IsRequired();
});

The value object's properties are flattened into the entity's table. Column types and generated DDL depend on your provider and configuration.

When to Use Owned Types

  • Multi-property value objects like Address, Money, DateRange
  • When you need to query individual properties
  • When the value object should be nullable (the entire owned type can be null)

One gotcha: EF Core doesn't allow the same owned instance to be shared by two owners. Sharing it across ownership relationships can produce tracking warnings or save failures. Because value objects are immutable, the fix is cheap: create a new instance (or use a with expression on the record).

I go deeper on this strategy in Owned Types in EF Core.

Option 2: Complex Types (EF Core 8+)

Complex types map grouped values without introducing a hidden entity key. To replace the owned mapping in the first example, configure both complex properties:

modelBuilder.Entity<Order>(builder =>
{
    builder.ComplexProperty(o => o.ShippingAddress);
    builder.ComplexProperty(o => o.TotalAmount, money =>
    {
        money.Property(m => m.Amount)
            .HasColumnName("total_amount")
            .HasPrecision(18, 2);

        money.Property(m => m.Currency)
            .HasColumnName("total_currency")
            .HasMaxLength(3);
    });
});

Differences from owned types:

  • EF Core 8 and 9 require non-null complex properties; EF Core 10 supports optional ones
  • Complex types cannot contain navigation properties
  • Complex types cannot be stored in a separate table
  • Instances can be shared because EF does not track a separate identity for each value

For optional values, EF Core 10's complex-type support requires at least one required member to distinguish a missing value from an object whose members are all null. Nullable reference annotations alone produce compiler warnings; they do not add runtime null checks.

When to Use Complex Types

  • Grouped values without independent identity
  • Simple value objects without relationships
  • Required values on EF Core 8/9, or required and optional values on EF Core 10

Option 3: Value Conversions

Value conversions map a single property to a column using a converter:

public sealed record Email
{
    public string Value { get; }

    public Email(string value)
    {
        if (string.IsNullOrWhiteSpace(value) || !value.Contains('@'))
            throw new ArgumentException("Invalid email", nameof(value));

        Value = value;
    }
}

The email check is deliberately minimal; it demonstrates where validation belongs, not a complete address-validation policy. It does not prove that a mailbox exists.

Configuration

modelBuilder.Entity<Customer>(builder =>
{
    builder.Property(c => c.Email)
        .HasConversion(
            email => email.Value,                  // To database
            value => new Email(value))             // From database
        .HasMaxLength(256);
});

Or define an EF Core ValueConverter class:

using Microsoft.EntityFrameworkCore.Storage.ValueConversion;

public class EmailConverter : ValueConverter<Email, string>
{
    public EmailConverter()
        : base(
            email => email.Value,
            value => new Email(value))
    {
    }
}

// Registration
modelBuilder.Entity<Customer>(builder =>
{
    builder.Property(c => c.Email)
        .HasConversion<EmailConverter>()
        .HasMaxLength(256);
});

Apply Globally

protected override void ConfigureConventions(
    ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder
        .Properties<Email>()
        .HaveConversion<EmailConverter>()
        .HaveMaxLength(256);
}

Now every Email property in every entity uses the converter automatically.

When to Use Value Conversions

  • Single-property value objects like Email, PhoneNumber, CustomerId
  • Strongly typed IDs
  • Enum-like value objects

Comparison

StrategyStorage and QueryingMain Constraint
Owned typeInline columns, separate table, or provider-supported JSON; query mapped membersHidden entity identity; one instance cannot be shared
Complex typeInline columns; JSON and complex collections from EF Core 10No separate table or entity navigations; optional properties require EF Core 10
Value conversionOne property converted to one column; compare the mapped propertyQueries into members of the converted object are not translated

A value converter can serialize multiple values into one column, but EF cannot translate member access inside that converted object. For example, compare customer.Email == email; do not expect customer.Email.Value.EndsWith(...) to become SQL. Mutable wrappers may also need a value comparer; the immutable Email record already supplies value equality.

Collections of Value Objects

For a collection stored in a separate table, use OwnsMany. This Order declaration replaces the earlier mapping-only example:

public class Order
{
    public Guid Id { get; private set; }
    public IReadOnlyList<LineItem> LineItems => _lineItems.AsReadOnly();
    private readonly List<LineItem> _lineItems = new();
}

public sealed record LineItem(Guid ProductId, int Quantity, decimal UnitPrice);
modelBuilder.Entity<Order>(builder =>
{
    builder.OwnsMany(o => o.LineItems, lineItem =>
    {
        lineItem.ToTable("order_line_items");
        lineItem.WithOwner().HasForeignKey("OrderId");
        lineItem.Property<Guid>("Id").ValueGeneratedOnAdd();
        lineItem.HasKey("OrderId", "Id");
        lineItem.Property(li => li.UnitPrice).HasPrecision(18, 2);
    });
});

The shadow GUID key identifies each stored row without adding business identity to LineItem. EF generates that key on the client; this also avoids relying on an auto-incrementing integer inside a composite key, which SQLite does not support.

Alternatively, use owned JSON mapping on a provider that supports it. SQL Server added this support in EF Core 7; SQLite followed in EF Core 8. Replace the separate-table configuration with:

modelBuilder.Entity<Order>(builder =>
{
    builder.OwnsMany(o => o.LineItems, lineItem =>
    {
        lineItem.ToJson(); // Stored as JSON column
    });
});

EF Core 10 also provides ComplexCollection with ToJson for identity-free JSON collections. Check your provider's support before choosing a JSON model; do not apply ToTable and ToJson to the same collection.

Practical Example

Combining all three strategies in one entity:

public class Customer
{
    public Guid Id { get; private set; }
    public Email Email { get; private set; } = null!;        // Value conversion
    public Address? BillingAddress { get; private set; }   // Optional owned type
    public FullName Name { get; private set; } = null!;      // Complex type
}

public sealed record FullName(string First, string Last);

Configure those properties in OnModelCreating:

modelBuilder.Entity<Customer>(builder =>
{
    // Value conversion for single-property VO
    builder.Property(c => c.Email)
        .HasConversion<EmailConverter>();

    // Owned type for nullable multi-property VO
    builder.OwnsOne(c => c.BillingAddress, address =>
    {
        address.Property(a => a.Street).HasColumnName("billing_street");
        address.Property(a => a.City).HasColumnName("billing_city");
    });

    // Complex type for non-null multi-property VO
    builder.ComplexProperty(c => c.Name, name =>
    {
        name.Property(n => n.First).HasColumnName("first_name");
        name.Property(n => n.Last).HasColumnName("last_name");
    });
});

Summary

Value object persistence in EF Core:

  1. Value conversions for single-property value objects - Email, PhoneNumber, strongly typed IDs
  2. Owned types when you need separate-table mapping or compatibility with earlier EF Core releases
  3. Complex types for grouped values without identity; nullability and JSON support depend on the EF Core version
  4. OwnsMany for a collection table, or supported JSON mapping for a document-shaped collection
  5. Apply globally with ConfigureConventions to avoid repetitive configuration

Choose the approach that matches your value object's structure and nullability requirements. Keeping those mappings outside the type preserves persistence ignorance without pretending the database has no constraints.

Thanks for reading.

And stay awesome!


Frequently Asked Questions

How do you persist value objects with EF Core?

EF Core offers three strategies: owned types (OwnsOne/OwnsMany), complex types (EF Core 8 and later), and value conversions. Which one fits depends on whether the value object has one or many properties and whether it can be null.

What is the difference between owned types and complex types in EF Core?

Complex types have no identity and can share an instance. Owned types are entities with a hidden key and cannot share an instance. Both support inline storage; owned types can also use a separate table. Optional complex properties and complex JSON mapping require EF Core 10 or later.

When should you use a value conversion in EF Core?

For single-property value objects like Email, PhoneNumber, or strongly typed IDs. The converter maps the wrapper type to its underlying primitive column and back.

How do you store a collection of value objects in EF Core?

Use OwnsMany for a separate collection table. JSON mapping depends on the provider: owned JSON support began with SQL Server in EF Core 7, and EF Core 10 adds complex collections mapped to JSON.

Can a complex type be null in EF Core?

EF Core 8 and 9 require complex properties to be non-null. EF Core 10 adds optional complex properties. An optional complex type must contain at least one required property so EF can distinguish a null object from an object with null fields.

  • Persistence Ignorance With EF Core: How Close Can You Get?

    EF Core can persist rich domain models without public setters or mapping attributes. Private constructors, backing fields, and external configuration keep database concerns out of business methods. Here are the mappings and the compromises they still require.

  • Specification Pattern in C# With EF Core

    The Specification pattern encapsulates query logic into reusable objects. Combined with EF Core, it eliminates query duplication and keeps your repositories clean. Here is how to implement it in C#.

  • Strongly Typed IDs in C# to Prevent Primitive Obsession

    Passing Guid parameters around is error-prone. Strongly typed IDs wrap primitives in domain-specific types so you cannot accidentally pass an OrderId where a CustomerId is expected. Here is how to implement them in C# with EF Core support.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.