Learnixo
Back to blog
Backend Systemsintermediate

EF Core Value Converters and Owned Entities

Map domain types to database columns in EF Core: value converters for enums, Money, JSON, and custom types; owned entities for value objects; and combining both for rich domain models.

Asma Hafeez KhanMay 24, 20265 min read
.NETC#EF Corevalue convertersDDDdomain-driven designowned entities
Share:𝕏

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

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

C#
// 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");
}
C#
// 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+)

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

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

C#
// 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!;
}
C#
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 JOINs

Owned Entities in a Separate Table

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

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

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

Enjoyed this article?

Explore the Backend 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.