Complex Types in EF Core: Cleaner Domain Models and Reusable Value Objects

Article Sponsors

EF Core too slow? Insert data up to 14x faster and cut save time by 94%. Boost performance with extension methods fully integrated into EF Core — Bulk Insert, Update, Delete, and Merge.

Join 5,000+ developers who’ve trusted our library since 2014.

👉 Try it now — and feel the difference

Designing a good data model is not only about tables and columns. It is also about representing business concepts clearly inside the codebase.

In many applications, some objects do not need their own identity or separate database table. They simply group related values together and belong to another entity.

Examples include:

  • Address
  • Money
  • ContactDetails
  • GeoLocation
  • DateRange

These are ideal candidates for Complex Types in EF Core.

Complex types allow storing grouped values inside the parent entity while keeping the domain model clean and expressive. Instead of scattering many related properties across an entity, they can be organized into meaningful objects.

This article explains what complex types are, when to use them, how to configure them, and what improvements are available in newer EF Core versions.

What Are Complex Types in EF Core?

A complex type is a class whose values are stored as part of its parent entity.

Unlike normal entities, complex types:

  • Do not have a primary key
  • Do not have independent identity
  • Do not need their own table
  • Cannot exist without a parent entity

Think of them as structured value containers inside an entity.

Instead of doing this:

C#
public sealed class Customer
{
    public Guid Id { get; set; }

    public string Street { get; set; } = null!;
    public string City { get; set; } = null!;
    public string PostalCode { get; set; } = null!;
    public string Country { get; set; } = null!;
}

You can design a cleaner model:

C#
public sealed class Customer
{
    public Guid Id { get; set; }

    public Address Address { get; set; } = default!;
}
C#
public sealed record Address(string Street, string City, string PostalCode, string Country);

This structure is easier to understand and maintain.

When Were Complex Types Introduced in EF Core?

Complex Types were introduced in EF Core 8.

Before EF Core 8, developers often used Owned Entity Types for similar scenarios. Owned types worked well, but they were still treated more like entities in many situations.

EF Core 8 introduced proper complex type support, making value-object style modeling more natural and aligned with domain-driven design patterns.

This was an important improvement for developers who wanted cleaner models without forcing every object to behave like an entity.

Why Use Complex Types?

Complex types solve common design problems.

  • Cleaner Entities – Instead of large entities with many unrelated fields, properties can be grouped logically.
  • Better Reusability – The same type can be reused across multiple entities.
  • Easier Maintenance – When address rules change, updates happen in one place.
  • Stronger Domain Language – Objects like Money, Address, or Coordinates reflect real business language.
  • Fewer Unnecessary Tables – Values stay in the parent table, reducing joins and schema complexity.
EF Core performance optimization sponsor banner showing bulk insert, update, delete, and merge features with 14x faster data operations.

Creating a Complex Type Using Fluent API

C#
public sealed class Customer
{
    public Guid Id { get; set; }

    public string Name { get; set; } = null!;
    public Address Address { get; set; } = default!;
}

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

Configure in DbContext:

C#
public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
    public void Configure(EntityTypeBuilder<Customer> builder)
    {
        builder.HasKey(c => c.Id);
        
        builder.Property(u => u.Name).HasMaxLength(200).IsRequired();

        builder.ComplexProperty(c => c.Address);
    }
}

EF Core now maps the Address values into the Customers table.

Mapping Complex Types Using Data Annotations

EF Core also supports mapping with attributes.

C#
public sealed class Customer
{
    public Guid Id { get; set; }

    public string Name { get; set; } = null!;

    public Address Address { get; set; } = default!;
}

[ComplexType]
public sealed record Address(string Street, string City, string PostalCode, string Country);

This approach is simple and useful when the model should stay close to the domain classes.

Configuring Complex Type Properties

You can configure inner properties too.

C#
public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
    public void Configure(EntityTypeBuilder<Customer> builder)
    {
        builder.HasKey(c => c.Id);
        
        builder.Property(u => u.Name).HasMaxLength(200).IsRequired();

        builder.ComplexProperty(c => c.Address, address =>
        {
            address.Property(a => a.Street).HasMaxLength(150).IsRequired();
            address.Property(a => a.City).HasMaxLength(100).IsRequired();
            address.Property(a => a.PostalCode).HasMaxLength(20).IsRequired();
            address.Property(a => a.Country).HasMaxLength(100).IsRequired();
        });
    }
}

This keeps validation and schema rules centralized.

Nested Complex Types

Complex types can contain other complex types.

C#
public sealed record Address(string Street, Coordinates Coordinates);

public sealed record Coordinates(decimal Latitude, decimal Longitude);

This is useful when representing richer structures.

Reusing Complex Types

The same type can be reused across multiple entities.

C#
public sealed class Customer
{
    public Address Address { get; set; } = default!;
}

public sealed class Supplier
{
    public Address Address { get; set; } = default!;
}

public sealed class Warehouse
{
    public Address Address { get; set; } = default!;
}

This improves consistency across the application.

Complex Types vs Owned Entities

Before complex types, owned entities were commonly used.

Use Complex Types When:
  • No key is needed
  • No separate lifecycle exists
  • Object is defined by values
  • Stored in same table
Use Owned Entities When:
  • Advanced mapping is required
  • Separate table may be useful
  • Relationship behavior is needed

Querying Complex Type Properties

You can query nested values naturally.

C#
var customersInLK = await dbContext.Customers
    .Where(c => c.Address.Country == "Sri Lanka")
    .Select(c => new { c.Id, c.Name, c.Address.City })
    .ToListAsync(ct);

This keeps queries readable.

Updating Complex Type Values

Updating values is straightforward.

C#
customer.Address.City = "Colombo";

await dbContext.SaveChangesAsync(ct);

EF Core updates the related columns.

Limitations of Complex Types

Complex types are useful, but not suitable for every scenario.

  • No Identity – They do not have primary keys.
  • No Independent Lifecycle – They cannot exist separately from the parent entity.
  • No Shared Relationships – Multiple entities should not reference the same complex type instance as if it were an entity relationship.
  • Not for Everything – If the object behaves like a standalone record, use an entity instead.

Best Practices

  • Use for Real Value Objects – Good examples include Address, Money, and Coordinates.
  • Keep Them Focused – Avoid oversized complex types with too many unrelated fields.
  • Prefer Clear Naming -Use business-friendly names that make intent obvious.
  • Avoid Over-Nesting -Too many nested levels can make models harder to read.
  • Let Domain Rules Lead Design – Use complex types because they fit the business model, not only because the feature exists.

Summary

Complex types in EF Core are an excellent feature for modeling grouped values inside entities.

They help reduce clutter, improve readability, and better represent real business concepts. Instead of forcing every object into a table with its own identity, complex types allow a cleaner and more natural design.

Introduced in EF Core 8 and fully usable in modern versions like EF Core 10, they are a valuable tool for building maintainable applications.

Used correctly, they make the codebase cleaner and easier to evolve.

Takeaways

  • Complex Types were introduced in EF Core 8
  • They are ideal for value objects like Address or Money
  • They do not require primary keys or separate tables
  • Values are stored in the parent entity table
  • Both Fluent API and Data Annotations can configure them
  • EF Core 10 continues improving the overall modeling experience
  • Use entities instead when identity or relationships are required
This article is sponsored by ZZZ Projects.

Thousands of developers fixed EF Core performance — with one library: Entity Framework Extensions.

👉 Insert data 14x faster with Bulk Insert

Found this article useful? Share it with your network and spark a conversation.