.NET & C# Development · Lesson 150 of 229
EF Core Value Converters and Owned Entities
EF Core Value Converters and Owned Entities
EF Core bridges the gap between your domain types and how the database stores them. Value converters and owned entities let you keep a rich domain model without sacrificing relational storage.
Value Converters
A value converter tells EF Core how to transform a value when reading from or writing to the database.
Enum to String
// Without converter: stored as int (0, 1, 2) — unreadable in DB
// With converter: stored as "Pending", "Paid", "Cancelled" — human-readable
public enum OrderStatus { Pending, Paid, Shipped, Cancelled }
protected override void OnModelCreating(ModelBuilder model)
{
model.Entity<Order>()
.Property(o => o.Status)
.HasConversion<string>(); // built-in string converter for enums
}Custom Value Object — Money
// Domain value object — not an entity, has no ID
public record Money(decimal Amount, string Currency)
{
public override string ToString() => $"{Amount} {Currency}";
}
public class Order
{
public int Id { get; set; }
public Money Total { get; set; } = new(0, "USD");
}// Store Money as "99.99 USD" in a single column
var moneyConverter = new ValueConverter<Money, string>(
money => $"{money.Amount} {money.Currency}",
raw =>
{
var parts = raw.Split(' ');
return new Money(decimal.Parse(parts[0]), parts[1]);
});
model.Entity<Order>()
.Property(o => o.Total)
.HasConversion(moneyConverter)
.HasColumnType("VARCHAR(30)");JSON Column (EF Core 7+)
// Store a complex object as a JSON column — no separate table needed
public class Order
{
public int Id { get; set; }
public List<OrderItem> Items { get; set; } = [];
public ShippingInfo Shipping { get; set; } = new();
}
public record ShippingInfo(string Carrier, string TrackingNumber, DateTime? EstimatedDelivery);
protected override void OnModelCreating(ModelBuilder model)
{
// EF Core 7+ native JSON column support
model.Entity<Order>().OwnsOne(o => o.Shipping, shipping =>
{
shipping.ToJson(); // stored as JSON in one column
});
model.Entity<Order>().OwnsMany(o => o.Items, items =>
{
items.ToJson(); // stored as JSON array in one column
});
}
// Generates schema:
// Orders: Id, Shipping (JSON column), Items (JSON column)
// Queries on JSON properties are translated to native JSON operators (PostgreSQL jsonb)Encrypted Column
// Transparent encryption — encrypt before writing, decrypt after reading
public class EncryptedStringConverter(IEncryptionService encryption)
: ValueConverter<string, string>(
value => encryption.Encrypt(value),
stored => encryption.Decrypt(stored))
{ }
model.Entity<Patient>()
.Property(p => p.SocialSecurityNumber)
.HasConversion<EncryptedStringConverter>();Owned Entities — Value Objects in DDD
Owned entities map a value object's properties into the owner's table. They have no independent identity.
// Value objects — immutable, identity by value not ID
[Owned]
public class Address
{
public string Street { get; init; } = "";
public string City { get; init; } = "";
public string PostCode { get; init; } = "";
public string Country { get; init; } = "";
}
[Owned]
public class PhoneNumber
{
public string CountryCode { get; init; } = "";
public string Number { get; init; } = "";
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = "";
public Address BillingAddress { get; set; } = null!;
public Address ShippingAddress { get; set; } = null!;
public PhoneNumber Phone { get; set; } = null!;
}protected override void OnModelCreating(ModelBuilder model)
{
model.Entity<Customer>().OwnsOne(c => c.BillingAddress, addr =>
{
// Column name prefix to disambiguate from ShippingAddress
addr.Property(a => a.Street) .HasColumnName("BillingStreet");
addr.Property(a => a.City) .HasColumnName("BillingCity");
addr.Property(a => a.PostCode) .HasColumnName("BillingPostCode");
addr.Property(a => a.Country) .HasColumnName("BillingCountry");
});
model.Entity<Customer>().OwnsOne(c => c.ShippingAddress, addr =>
{
addr.Property(a => a.Street) .HasColumnName("ShippingStreet");
addr.Property(a => a.City) .HasColumnName("ShippingCity");
addr.Property(a => a.PostCode) .HasColumnName("ShippingPostCode");
addr.Property(a => a.Country) .HasColumnName("ShippingCountry");
});
model.Entity<Customer>().OwnsOne(c => c.Phone, phone =>
{
phone.Property(p => p.CountryCode).HasColumnName("PhoneCountryCode");
phone.Property(p => p.Number) .HasColumnName("PhoneNumber");
});
}
// Generated Customers table columns:
// Id, Name,
// BillingStreet, BillingCity, BillingPostCode, BillingCountry,
// ShippingStreet, ShippingCity, ShippingPostCode, ShippingCountry,
// PhoneCountryCode, PhoneNumber
// — all in one table, no JOINsOwned Entities in a Separate Table
// Split owned entity to a separate table if the owner table gets too wide
model.Entity<Customer>().OwnsOne(c => c.BillingAddress, addr =>
{
addr.ToTable("CustomerBillingAddresses"); // separate table, still owned
});Value Comparer — Required for Mutable Types
EF Core uses value comparers to detect changes for change tracking. For types that are not natively comparable, you must supply one.
// Without a comparer, EF Core cannot tell if a List<string> changed
model.Entity<Product>()
.Property(p => p.Tags)
.HasConversion(
tags => string.Join(',', tags),
raw => raw.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList())
.Metadata.SetValueComparer(
new ValueComparer<List<string>>(
(a, b) => a!.SequenceEqual(b!),
c => c.Aggregate(0, (acc, v) => HashCode.Combine(acc, v.GetHashCode())),
c => c.ToList()));Applying Converters Globally
// Apply enum-to-string conversion to all enums in the model
protected override void ConfigureConventions(ModelConfigurationBuilder config)
{
// All enums → string columns (no per-property configuration needed)
config.Properties<Enum>().HaveConversion<string>();
// All DateTime → UTC kind
config.Properties<DateTime>()
.HaveConversion<UtcDateTimeConverter>();
}
public class UtcDateTimeConverter()
: ValueConverter<DateTime, DateTime>(
d => d.Kind == DateTimeKind.Utc ? d : d.ToUniversalTime(),
d => DateTime.SpecifyKind(d, DateTimeKind.Utc))
{ }Interview Answer
"Value converters let EF Core transform domain types to storage types. Common uses: enum-to-string (readable DB values), custom value objects like Money stored as a formatted string, JSON columns for complex objects (EF Core 7+ OwnsOne/OwnsMany with ToJson()), and transparent encryption. Owned entities ([Owned] attribute or OwnsOne in OnModelCreating) map value objects into the owner's table — no separate table, no FK, no ID. Multiple owned entities of the same type get column name prefixes to disambiguate. For mutable types used in converters (like List), provide a ValueComparer so EF Core can detect changes for change tracking. Global conventions (ConfigureConventions) apply a converter to all properties of a type — one line to make all enums store as strings, or all DateTimes store as UTC, without per-property configuration."