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
| 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 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:
- Value conversions for single-property value objects -
Email,PhoneNumber, strongly typed IDs - Owned types when you need separate-table mapping or compatibility with earlier EF Core releases
- Complex types for grouped values without identity; nullability and JSON support depend on the EF Core version
- OwnsMany for a collection table, or supported JSON mapping for a document-shaped collection
- Apply globally with
ConfigureConventionsto 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.



