# How to Model Value Objects With EF Core

> Value objects have no identity - they are defined by their properties. EF Core gives you three ways to persist them: owned types, complex types, and value conversions. Here is when to use each.

Published: 2026-09-22. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/value-objects-ef-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**](https://milanjovanovic.tech/blog/value-objects-in-dotnet-ddd-fundamentals) 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**](https://milanjovanovic.tech/blog/entity-vs-value-object-ddd).

```csharp
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:

```csharp
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

```csharp
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**](https://milanjovanovic.tech/blog/owned-types-ef-core-ddd).

## Option 2: Complex Types (EF Core 8+)

[**Complex types**](https://milanjovanovic.tech/blog/complex-types-ef-core) map grouped values without introducing a hidden entity key.
To replace the owned mapping in the first example, configure both complex properties:

```csharp
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**](https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-10.0/whatsnew#complex-types) 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:

```csharp
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

```csharp
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:

```csharp
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

```csharp
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**](https://milanjovanovic.tech/blog/strongly-typed-ids-csharp)
- Enum-like value objects

## Comparison

| Strategy | Storage and Querying | Main Constraint |
| --- | --- | --- |
| Owned type | Inline columns, separate table, or provider-supported JSON; query mapped members | Hidden entity identity; one instance cannot be shared |
| Complex type | Inline columns; JSON and complex collections from EF Core 10 | No separate table or entity navigations; optional properties require EF Core 10 |
| Value conversion | One property converted to one column; compare the mapped property | Queries into members of the converted object are not translated |

A [**value converter**](https://learn.microsoft.com/en-us/ef/core/modeling/value-conversions#limitations) 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:

```csharp
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);
```

```csharp
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:

```csharp
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:

```csharp
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`:

```csharp
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**](https://milanjovanovic.tech/blog/persistence-ignorance-ef-core-ddd) 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.
