DEV_NET_CORE
GET_STARTED
.NETEntity Framework

Conventions, Fluent API, Owned/Complex Data, and Relationship Mapping

Overview

Entity Framework Core uses a model to understand how C# classes map to a database. That model includes entity types, properties, keys, indexes, relationships, owned types, complex types, table names, column names, constraints, and many other mapping details.

This topic covers four important EF Core modeling areas:

  1. Conventions: EF Core's default rules for discovering entities, keys, properties, relationships, table names, column names, required fields, and foreign keys.
  2. Fluent API: Explicit model configuration written in OnModelCreating or in separate configuration classes.
  3. Owned and complex data: Techniques for modeling value-object-like data such as addresses, money, audit metadata, contact information, and embedded details.
  4. Relationship mapping: Configuring one-to-many, one-to-one, many-to-many, required/optional relationships, foreign keys, navigations, shadow properties, and delete behavior.

This topic matters because EF Core is not just a database access library. It is an object-relational mapper. The quality of the EF Core model affects the database schema, migrations, query translation, change tracking, performance, data integrity, and maintainability.

In real applications, you often start with simple conventions. For example, a Customer entity with an Id property and an Orders collection can often be mapped automatically. But production systems usually need explicit configuration for table names, column types, indexes, required fields, relationships, value objects, delete behavior, constraints, and legacy database schemas.

This topic is important for interviews because it tests whether a developer understands how EF Core builds the model and how to control it. Interviewers often ask:

  • What does EF Core configure by convention?
  • When should you use Fluent API instead of data annotations?
  • What is the difference between owned entity types and complex types?
  • How do you configure one-to-many, one-to-one, and many-to-many relationships?
  • What are navigations and foreign keys?
  • What are shadow foreign keys?
  • What is the difference between principal and dependent entities?
  • How do nullable reference types affect required relationships?
  • How do you avoid accidental cascade deletes?
  • How do you keep entity configuration maintainable in large projects?

A strong answer should show that you know when conventions are enough, when explicit Fluent API is safer, and how relationship and value-object mapping decisions affect both code and database design.

Core Concepts

EF Core Model Building

EF Core builds a metadata model that describes how your C# object model maps to the database.

The model includes:

  • Entity types.
  • Primary keys.
  • Alternate keys.
  • Properties.
  • Column names and column types.
  • Required and optional fields.
  • Relationships.
  • Foreign keys.
  • Navigations.
  • Indexes.
  • Owned entity types.
  • Complex types.
  • Table and schema mappings.
  • Delete behavior.
  • Concurrency tokens.
  • Value conversions.
  • Query filters.

A basic DbContext might look like this:

Code
using Microsoft.EntityFrameworkCore;

public sealed class AppDbContext : DbContext
{
    public DbSet<Customer> Customers => Set<Customer>();
    public DbSet<Order> Orders => Set<Order>();

    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options)
    {
    }
}

public sealed class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;

    public List<Order> Orders { get; } = new();
}

public sealed class Order
{
    public int Id { get; set; }
    public DateTime OrderedAtUtc { get; set; }

    public int CustomerId { get; set; }
    public Customer Customer { get; set; } = null!;
}

EF Core can infer a lot from this model:

  • Customer and Order are entities because they are exposed through DbSet.
  • Id is the primary key by convention.
  • Customer.Orders and Order.Customer are navigations.
  • Order.CustomerId is the foreign key.
  • A one-to-many relationship exists from Customer to Order.

Conventions are useful, but they are not a replacement for understanding the generated model.

Conventions

Conventions are EF Core's default rules for discovering and configuring the model.

Common conventions include:

ConventionExample
DbSet<T> types are included as entitiesDbSet<Customer> includes Customer
Id or <TypeName>Id is discovered as primary keyCustomer.Id, Customer.CustomerId
Public properties with getters and setters are mappedName, Price, CreatedAtUtc
Navigation properties are discoveredCustomer.Orders, Order.Customer
Foreign keys are discovered by nameCustomerId, OrderId
Nullable properties are optionalstring? Description
Non-nullable value types are requiredint Quantity, decimal Price
Foreign key properties get indexes by conventionOrder.CustomerId
Table names often come from DbSet names or entity namesCustomers, Orders

Example:

Code
public sealed class Blog
{
    public int Id { get; set; }
    public string Url { get; set; } = string.Empty;

    public List<Post> Posts { get; } = new();
}

public sealed class Post
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;

    public int BlogId { get; set; }
    public Blog Blog { get; set; } = null!;
}

EF Core can infer:

  • Blog.Id is the primary key.
  • Post.Id is the primary key.
  • Blog.Posts is a collection navigation.
  • Post.Blog is a reference navigation.
  • Post.BlogId is the foreign key.
  • The relationship is required because BlogId is non-nullable.

Conventions are best when your model follows normal EF Core naming patterns. If your schema is complex, legacy, or security-sensitive, explicit configuration is often better.

Configuration Precedence

EF Core model configuration usually comes from three sources:

  1. Conventions
  2. Data annotations
  3. Fluent API

The Fluent API is the most powerful and has the highest priority. It can override conventions and most data annotation configurations.

Example using data annotations:

Code
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

[Table("app_customers")]
public sealed class Customer
{
    [Key]
    public int CustomerId { get; set; }

    [Required]
    [MaxLength(200)]
    public string Name { get; set; } = string.Empty;
}

Equivalent Fluent API:

Code
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>(builder =>
    {
        builder.ToTable("app_customers");

        builder.HasKey(c => c.CustomerId);

        builder.Property(c => c.Name)
            .IsRequired()
            .HasMaxLength(200);
    });
}

In larger applications, Fluent API is often preferred because it keeps persistence configuration out of domain classes and supports more complete configuration.

Fluent API

The Fluent API is EF Core's explicit configuration API.

It is usually written inside OnModelCreating:

Code
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>(builder =>
    {
        builder.ToTable("Customers");

        builder.HasKey(c => c.Id);

        builder.Property(c => c.Name)
            .IsRequired()
            .HasMaxLength(200);

        builder.HasIndex(c => c.Email)
            .IsUnique();
    });
}

The Fluent API is used for:

  • Table names.
  • Schemas.
  • Column names.
  • Column types.
  • Required and optional properties.
  • Maximum lengths.
  • Precision and scale.
  • Indexes.
  • Unique constraints.
  • Keys and alternate keys.
  • Relationships.
  • Foreign keys.
  • Delete behavior.
  • Owned entities.
  • Complex types.
  • Value conversions.
  • Backing fields.
  • Query filters.
  • Concurrency tokens.

Fluent API is especially important when conventions would be ambiguous.

Organizing Fluent API with IEntityTypeConfiguration<T>

For large applications, putting all mapping code inside OnModelCreating becomes hard to maintain. A common pattern is to create separate configuration classes.

Example:

Code
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
    public void Configure(EntityTypeBuilder<Customer> builder)
    {
        builder.ToTable("Customers");

        builder.HasKey(c => c.Id);

        builder.Property(c => c.Name)
            .IsRequired()
            .HasMaxLength(200);

        builder.HasMany(c => c.Orders)
            .WithOne(o => o.Customer)
            .HasForeignKey(o => o.CustomerId)
            .IsRequired();
    }
}

Register all configurations:

Code
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}

This approach keeps each entity's mapping close to its own configuration and avoids a huge DbContext.

Entity Types

An entity type is a type that EF Core tracks and maps to a database object such as a table or view.

Example:

Code
public sealed class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
}

Common ways a type becomes an entity:

  • It is exposed as DbSet<T> in the context.
  • It is configured in OnModelCreating.
  • It is discovered through a navigation property from another entity.

Example:

Code
public sealed class AppDbContext : DbContext
{
    public DbSet<Product> Products => Set<Product>();
}

An entity usually has identity, which means EF Core can track one instance as representing one database row. This is different from complex types, which do not have their own identity.

Keys

A primary key uniquely identifies each entity instance.

By convention, EF Core discovers Id or <EntityName>Id as the primary key.

Example:

Code
public sealed class Customer
{
    public int Id { get; set; }
}

Explicit configuration:

Code
builder.HasKey(c => c.Id);

Composite key:

Code
public sealed class OrderLine
{
    public int OrderId { get; set; }
    public int LineNumber { get; set; }

    public string ProductName { get; set; } = string.Empty;
}

builder.HasKey(ol => new { ol.OrderId, ol.LineNumber });

Composite keys are common for join entities, line items, and legacy schemas.

Alternate Keys

An alternate key is a unique identifier other than the primary key. It can be used as the target of a foreign key.

Example:

Code
public sealed class Country
{
    public int Id { get; set; }
    public string Code { get; set; } = string.Empty;
}

public sealed class Customer
{
    public int Id { get; set; }
    public string CountryCode { get; set; } = string.Empty;
    public Country Country { get; set; } = null!;
}

Configuration:

Code
modelBuilder.Entity<Country>()
    .HasAlternateKey(c => c.Code);

modelBuilder.Entity<Customer>()
    .HasOne(c => c.Country)
    .WithMany()
    .HasForeignKey(c => c.CountryCode)
    .HasPrincipalKey(c => c.Code);

Use alternate keys carefully. In many cases, a unique index is enough unless another entity needs to reference the property as a foreign key target.

Properties and Column Mapping

EF Core maps entity properties to database columns.

Example:

Code
builder.Property(p => p.Name)
    .HasColumnName("product_name")
    .HasMaxLength(200)
    .IsRequired();

Decimal precision:

Code
builder.Property(p => p.Price)
    .HasPrecision(18, 2);

Date/time column type:

Code
builder.Property(o => o.OrderedAtUtc)
    .HasColumnType("datetime2");

Column default value:

Code
builder.Property(o => o.CreatedAtUtc)
    .HasDefaultValueSql("SYSUTCDATETIME()");

Common mistake:

Code
public decimal Price { get; set; }

Without precision configuration, different providers may choose defaults that are not what you expect. For money-like values, configure precision explicitly.

Required and Optional Properties

EF Core uses CLR nullability and nullable reference types to infer whether a property is required.

Example with nullable reference types enabled:

Code
public sealed class Customer
{
    public int Id { get; set; }

    public string Name { get; set; } = string.Empty; // Required by convention

    public string? MiddleName { get; set; } // Optional by convention
}

Explicit configuration:

Code
builder.Property(c => c.Name)
    .IsRequired();

builder.Property(c => c.MiddleName)
    .IsRequired(false);

For value types:

Code
public int Quantity { get; set; }      // Required
public int? DiscountPercent { get; set; } // Optional

A common migration issue happens when nullable reference types are enabled in an existing project. Properties that were previously treated as optional may become required, which can generate schema changes.

Indexes

Indexes improve lookup, filtering, joining, and ordering performance.

By convention, EF Core creates indexes for foreign key properties.

Explicit index:

Code
builder.HasIndex(c => c.Email);

Unique index:

Code
builder.HasIndex(c => c.Email)
    .IsUnique();

Composite index:

Code
builder.HasIndex(o => new { o.TenantId, o.OrderNumber })
    .IsUnique();

A practical production example:

Code
builder.HasIndex(o => new { o.TenantId, o.CreatedAtUtc });

This supports queries like:

Code
var recentOrders = await context.Orders
    .Where(o => o.TenantId == tenantId)
    .OrderByDescending(o => o.CreatedAtUtc)
    .Take(50)
    .ToListAsync();

Indexes are not only for performance. Unique indexes also enforce data integrity.

A navigation is a C# property that lets you move between related entities.

Reference navigation:

Code
public Customer Customer { get; set; } = null!;

Collection navigation:

Code
public List<Order> Orders { get; } = new();

Example relationship:

Code
public sealed class Customer
{
    public int Id { get; set; }
    public List<Order> Orders { get; } = new();
}

public sealed class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public Customer Customer { get; set; } = null!;
}

Navigations are not database columns. They are object model properties that EF Core uses to understand and traverse relationships.

Foreign Keys

A foreign key property stores the key value of a related principal entity.

Example:

Code
public sealed class Order
{
    public int Id { get; set; }

    public int CustomerId { get; set; }
    public Customer Customer { get; set; } = null!;
}

Here, CustomerId is the foreign key.

Explicit configuration:

Code
builder.HasOne(o => o.Customer)
    .WithMany(c => c.Orders)
    .HasForeignKey(o => o.CustomerId);

Foreign key values are useful because they allow you to change relationships without loading the related entity:

Code
order.CustomerId = newCustomerId;

Some domain models hide foreign keys for a cleaner object model, but exposing them often makes EF Core usage simpler and more explicit.

Principal and Dependent Entities

In a relationship, the principal entity is the parent or referenced entity. The dependent entity contains the foreign key.

Example:

Code
public sealed class Customer
{
    public int Id { get; set; }
    public List<Order> Orders { get; } = new();
}

public sealed class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public Customer Customer { get; set; } = null!;
}

Here:

  • Customer is the principal.
  • Order is the dependent.
  • Order.CustomerId is the foreign key.

Understanding principal and dependent is important for configuring one-to-one relationships, cascade delete behavior, and required relationships.

Shadow Properties and Shadow Foreign Keys

A shadow property is a property that exists in the EF Core model but not in the CLR class.

Example:

Code
public sealed class Post
{
    public int Id { get; set; }
    public Blog Blog { get; set; } = null!;
}

There is no BlogId property, but EF Core can create a shadow foreign key.

Configuration:

Code
builder.HasOne(p => p.Blog)
    .WithMany(b => b.Posts)
    .HasForeignKey("BlogId");

You can access shadow properties in queries using EF.Property<T>:

Code
var posts = await context.Posts
    .Where(p => EF.Property<int>(p, "BlogId") == blogId)
    .ToListAsync();

Shadow foreign keys can keep domain classes cleaner, but they can also make queries, debugging, and DTO mapping less obvious. For many business applications, explicit foreign key properties are easier to maintain.

One-to-Many Relationships

A one-to-many relationship means one principal entity relates to many dependent entities.

Example:

Code
public sealed class Blog
{
    public int Id { get; set; }
    public string Url { get; set; } = string.Empty;

    public List<Post> Posts { get; } = new();
}

public sealed class Post
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;

    public int BlogId { get; set; }
    public Blog Blog { get; set; } = null!;
}

Configuration:

Code
modelBuilder.Entity<Blog>(builder =>
{
    builder.HasMany(b => b.Posts)
        .WithOne(p => p.Blog)
        .HasForeignKey(p => p.BlogId)
        .IsRequired();
});

Optional relationship:

Code
public int? BlogId { get; set; }
public Blog? Blog { get; set; }

Configuration:

Code
builder.HasMany(b => b.Posts)
    .WithOne(p => p.Blog)
    .HasForeignKey(p => p.BlogId)
    .IsRequired(false);

One-to-many is the most common EF Core relationship.

One-to-One Relationships

A one-to-one relationship means one entity is related to at most one other entity.

Example:

Code
public sealed class User
{
    public int Id { get; set; }
    public UserProfile Profile { get; set; } = null!;
}

public sealed class UserProfile
{
    public int Id { get; set; }

    public int UserId { get; set; }
    public User User { get; set; } = null!;

    public string DisplayName { get; set; } = string.Empty;
}

Configuration:

Code
modelBuilder.Entity<User>()
    .HasOne(u => u.Profile)
    .WithOne(p => p.User)
    .HasForeignKey<UserProfile>(p => p.UserId)
    .IsRequired();

The important part is HasForeignKey<UserProfile>. In a one-to-one relationship, EF Core often needs help identifying which side is dependent.

Primary-key-to-primary-key one-to-one:

Code
public sealed class User
{
    public int Id { get; set; }
    public UserProfile Profile { get; set; } = null!;
}

public sealed class UserProfile
{
    public int Id { get; set; }
    public User User { get; set; } = null!;
}

Configuration:

Code
modelBuilder.Entity<User>()
    .HasOne(u => u.Profile)
    .WithOne(p => p.User)
    .HasForeignKey<UserProfile>(p => p.Id);

One-to-one relationships are more ambiguous than one-to-many relationships, so explicit configuration is often recommended.

Many-to-Many Relationships

A many-to-many relationship means many entities on one side can relate to many entities on the other side.

Example:

Code
public sealed class Student
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;

    public List<Course> Courses { get; } = new();
}

public sealed class Course
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;

    public List<Student> Students { get; } = new();
}

EF Core can create a join table by convention.

Explicit configuration:

Code
modelBuilder.Entity<Student>()
    .HasMany(s => s.Courses)
    .WithMany(c => c.Students)
    .UsingEntity("CourseStudent");

Many-to-many with an explicit join entity:

Code
public sealed class StudentCourse
{
    public int StudentId { get; set; }
    public Student Student { get; set; } = null!;

    public int CourseId { get; set; }
    public Course Course { get; set; } = null!;

    public DateTime EnrolledAtUtc { get; set; }
}

Configuration:

Code
modelBuilder.Entity<StudentCourse>(builder =>
{
    builder.HasKey(sc => new { sc.StudentId, sc.CourseId });

    builder.HasOne(sc => sc.Student)
        .WithMany()
        .HasForeignKey(sc => sc.StudentId);

    builder.HasOne(sc => sc.Course)
        .WithMany()
        .HasForeignKey(sc => sc.CourseId);

    builder.Property(sc => sc.EnrolledAtUtc)
        .IsRequired();
});

Use skip navigations for simple many-to-many relationships. Use an explicit join entity when the relationship itself has data, such as EnrolledAtUtc, Role, SortOrder, AssignedBy, or IsPrimary.

Required vs Optional Relationships

A required relationship means the dependent must have a principal.

Example:

Code
public int CustomerId { get; set; }
public Customer Customer { get; set; } = null!;

Optional relationship:

Code
public int? CustomerId { get; set; }
public Customer? Customer { get; set; }

Configuration:

Code
builder.HasOne(o => o.Customer)
    .WithMany(c => c.Orders)
    .HasForeignKey(o => o.CustomerId)
    .IsRequired();

For optional:

Code
builder.HasOne(o => o.Customer)
    .WithMany(c => c.Orders)
    .HasForeignKey(o => o.CustomerId)
    .IsRequired(false);

Required relationships often result in non-nullable foreign key columns. Optional relationships often result in nullable foreign key columns.

Delete Behavior and Cascade Delete

Delete behavior controls what happens to dependents when a principal is deleted.

Common delete behaviors include:

Delete BehaviorMeaning
CascadeDelete dependents when principal is deleted
RestrictPrevent delete if dependents exist
NoActionLet the database enforce constraints
SetNullSet nullable foreign key to null
ClientCascadeCascade in EF Core change tracker but not database

Example:

Code
builder.HasMany(c => c.Orders)
    .WithOne(o => o.Customer)
    .HasForeignKey(o => o.CustomerId)
    .OnDelete(DeleteBehavior.Restrict);

Cascade delete can be useful for true parent-child relationships, such as order and order lines. But it can be dangerous for important aggregate roots or shared data.

Good practice:

  • Use cascade delete intentionally.
  • Avoid accidental cascade delete across large object graphs.
  • Be especially careful with required relationships.
  • Review generated migrations and database constraints.
  • For business-critical data, consider soft delete or restricted delete.

Owned Entity Types

An owned entity type is an entity type that belongs to another entity. It cannot exist independently from its owner.

Owned types are useful for modeling data that is part of an aggregate.

Example:

Code
public sealed class Order
{
    public int Id { get; set; }
    public ShippingAddress ShippingAddress { get; set; } = new();
}

public sealed class ShippingAddress
{
    public string Street { get; set; } = string.Empty;
    public string City { get; set; } = string.Empty;
    public string PostalCode { get; set; } = string.Empty;
}

Configuration:

Code
modelBuilder.Entity<Order>(builder =>
{
    builder.OwnsOne(o => o.ShippingAddress, address =>
    {
        address.Property(a => a.Street)
            .HasMaxLength(200)
            .IsRequired();

        address.Property(a => a.City)
            .HasMaxLength(100)
            .IsRequired();

        address.Property(a => a.PostalCode)
            .HasMaxLength(20)
            .IsRequired();
    });
});

Owned reference types are often mapped to the same table as the owner by default, using columns such as:

Code
ShippingAddress_Street
ShippingAddress_City
ShippingAddress_PostalCode

Owned types can also be mapped to separate tables depending on configuration.

OwnsOne

OwnsOne maps a single owned reference.

Example:

Code
public sealed class Customer
{
    public int Id { get; set; }
    public Address Address { get; set; } = new();
}

public sealed class Address
{
    public string Line1 { get; set; } = string.Empty;
    public string City { get; set; } = string.Empty;
}

Configuration:

Code
modelBuilder.Entity<Customer>(builder =>
{
    builder.OwnsOne(c => c.Address, address =>
    {
        address.Property(a => a.Line1)
            .HasColumnName("AddressLine1")
            .HasMaxLength(200);

        address.Property(a => a.City)
            .HasColumnName("AddressCity")
            .HasMaxLength(100);
    });
});

If the owned type is truly part of the owner and should be saved/deleted with the owner, OwnsOne is a good fit.

OwnsMany

OwnsMany maps a collection of owned items.

Example:

Code
public sealed class Order
{
    public int Id { get; set; }
    public List<OrderNote> Notes { get; } = new();
}

public sealed class OrderNote
{
    public int Id { get; set; }
    public string Text { get; set; } = string.Empty;
}

Configuration:

Code
modelBuilder.Entity<Order>(builder =>
{
    builder.OwnsMany(o => o.Notes, note =>
    {
        note.ToTable("OrderNotes");

        note.WithOwner()
            .HasForeignKey("OrderId");

        note.HasKey("OrderId", "Id");

        note.Property(n => n.Text)
            .HasMaxLength(1000)
            .IsRequired();
    });
});

Owned collections usually need a key. A common pattern is a composite key that includes the owner's key plus an owned item identifier.

Limitations and Trade-Offs of Owned Entity Types

Owned entity types are powerful, but they have limitations and trade-offs.

Important points:

  • Owned types are still entity types in EF Core.
  • They are dependent on the owner.
  • They cannot have their own DbSet<T>.
  • They are not shared independently across owners.
  • They are usually deleted when the owner is deleted.
  • Their lifecycle is tied to the owner.
  • They can make migrations more complex if overused.
  • Collections of owned types require careful key design.

Owned types are best for composition inside aggregates.

Good examples:

  • Address inside customer.
  • Money inside order line.
  • Audit metadata inside entity.
  • Order details inside order.
  • Contact information inside customer.

Poor examples:

  • Shared lookup data.
  • Independent entities.
  • Data that needs its own repository.
  • Data that many owners share by identity.
  • Data with a lifecycle independent of the owner.

Complex Types

Complex types model structured data that has no identity of its own.

They are useful for value-object-like data such as:

  • Address.
  • Money.
  • Coordinates.
  • Date range.
  • Audit metadata.
  • Person name.
  • Phone number.
  • Contact information.

Example:

Code
public sealed class Customer
{
    public int Id { get; set; }
    public Address Address { get; set; } = new();
}

public sealed record Address(
    string Line1,
    string City,
    string PostalCode);

Configuration:

Code
modelBuilder.Entity<Customer>(builder =>
{
    builder.ComplexProperty(c => c.Address, address =>
    {
        address.Property(a => a.Line1)
            .HasMaxLength(200)
            .IsRequired();

        address.Property(a => a.City)
            .HasMaxLength(100)
            .IsRequired();

        address.Property(a => a.PostalCode)
            .HasMaxLength(20)
            .IsRequired();
    });
});

Complex types are not entity types. They do not have keys and are not tracked by identity. They are part of the containing entity.

In modern EF Core, complex types are the preferred option for many value-object scenarios where the type has no identity and should not be treated as an entity.

Complex Types vs Owned Entity Types

Complex types and owned entity types can look similar, but they have different semantics.

FeatureComplex TypeOwned Entity Type
Has its own identity/keyNoYes, even if hidden
Tracked as separate entityNoYes
Can have DbSet<T>NoNo
Shared instance allowedMore naturalNot appropriate for shared entity instances
Best forValue-object-like dataAggregate-owned entity data
Configured withComplexProperty or [ComplexType]OwnsOne, OwnsMany, or [Owned]
Relationship semanticsNot a relationshipOwnership relationship
CollectionsProvider/version dependent; usually more limitedOwnsMany supports owned collections
Separate table mappingNot generally the main model; depends on EF/provider featuresPossible
JSON column mappingSupported in modern EF/provider scenariosAlso possible in some provider scenarios

Use complex types when the object has no identity and is just structured data.

Use owned entity types when the object is part of the aggregate but still benefits from entity-like mapping behavior, ownership relationships, or owned collections.

Example complex type:

Code
public sealed record Money(decimal Amount, string Currency);

Example owned entity type:

Code
public sealed class OrderLine
{
    public int Id { get; set; }
    public string ProductName { get; set; } = string.Empty;
    public Money UnitPrice { get; set; } = new(0, "USD");
}

In this example, Money is likely a complex type. OrderLine may be an owned collection because each order line has its own identity within an order.

Mapping Value Objects

Domain-driven design often uses value objects. In EF Core, value objects are commonly mapped as complex types or owned types.

Example value object:

Code
public sealed record Money(decimal Amount, string Currency);

Entity:

Code
public sealed class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;

    public Money Price { get; set; } = new(0, "USD");
}

Complex type mapping:

Code
modelBuilder.Entity<Product>(builder =>
{
    builder.ComplexProperty(p => p.Price, money =>
    {
        money.Property(m => m.Amount)
            .HasPrecision(18, 2)
            .HasColumnName("PriceAmount");

        money.Property(m => m.Currency)
            .HasMaxLength(3)
            .HasColumnName("PriceCurrency");
    });
});

This keeps Money as part of Product rather than as a separate table or entity.

Table Splitting

Table splitting maps multiple entity types to the same database table row.

Owned types often use table splitting when mapped into the owner's table.

Example:

Code
public sealed class Order
{
    public int Id { get; set; }
    public OrderDetails Details { get; set; } = new();
}

public sealed class OrderDetails
{
    public string ShippingAddress { get; set; } = string.Empty;
    public string BillingAddress { get; set; } = string.Empty;
}

Configuration with owned type:

Code
modelBuilder.Entity<Order>(builder =>
{
    builder.OwnsOne(o => o.Details, details =>
    {
        details.Property(d => d.ShippingAddress)
            .HasColumnName("ShippingAddress");

        details.Property(d => d.BillingAddress)
            .HasColumnName("BillingAddress");
    });
});

The database may have one Orders table containing both order and detail columns.

Table splitting is useful for encapsulation and value-object-like modeling, but it can complicate optional data, nullability, and migrations if not designed carefully.

JSON Column Mapping

Modern EF Core versions and providers can map structured owned or complex data into JSON columns in some scenarios.

Conceptual example:

Code
public sealed class Customer
{
    public int Id { get; set; }
    public ContactDetails ContactDetails { get; set; } = new();
}

public sealed class ContactDetails
{
    public string Email { get; set; } = string.Empty;
    public string Phone { get; set; } = string.Empty;
}

Provider-specific configuration may allow this structured object to be stored in a single JSON column instead of many relational columns.

Why use JSON mapping:

  • The data is naturally document-shaped.
  • The data is usually loaded with the parent.
  • The data does not need many relational joins.
  • The schema changes more frequently than normal columns.
  • The provider supports efficient JSON querying and indexing.

Trade-offs:

  • Relational constraints may be weaker.
  • Querying can be provider-specific.
  • Indexing JSON fields requires provider-specific knowledge.
  • Migrations and schema validation may be less obvious.
  • Reporting and SQL-based analysis may be harder.

For interview answers, mention that JSON mapping is useful but should not be used to avoid proper relational modeling when relationships, constraints, and query patterns need normalized tables.

Backing Fields and Encapsulation

EF Core can map to backing fields, which helps preserve domain encapsulation.

Example:

Code
public sealed class Order
{
    private readonly List<OrderLine> _lines = new();

    public int Id { get; private set; }

    public IReadOnlyCollection<OrderLine> Lines => _lines;

    public void AddLine(string productName, int quantity)
    {
        if (quantity <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(quantity));
        }

        _lines.Add(new OrderLine(productName, quantity));
    }
}

public sealed class OrderLine
{
    private OrderLine()
    {
    }

    public OrderLine(string productName, int quantity)
    {
        ProductName = productName;
        Quantity = quantity;
    }

    public int Id { get; private set; }
    public string ProductName { get; private set; } = string.Empty;
    public int Quantity { get; private set; }
}

Mapping:

Code
modelBuilder.Entity<Order>(builder =>
{
    builder.HasMany(typeof(OrderLine), "_lines")
        .WithOne()
        .OnDelete(DeleteBehavior.Cascade);

    builder.Navigation("_lines")
        .UsePropertyAccessMode(PropertyAccessMode.Field);
});

This allows EF Core to use the backing field while application code uses methods that enforce invariants.

Value Conversions

Value conversions convert between a CLR type and a provider type.

Example enum conversion:

Code
public enum OrderStatus
{
    Draft,
    Submitted,
    Paid,
    Cancelled
}

Mapping:

Code
builder.Property(o => o.Status)
    .HasConversion<string>()
    .HasMaxLength(50);

Example strongly typed ID:

Code
public readonly record struct CustomerId(Guid Value);

public sealed class Customer
{
    public CustomerId Id { get; set; }
    public string Name { get; set; } = string.Empty;
}

Mapping:

Code
builder.Property(c => c.Id)
    .HasConversion(
        id => id.Value,
        value => new CustomerId(value));

Value conversions are helpful for domain-friendly types, but they are not a replacement for relationship mapping. If a type has multiple fields, a complex type or owned type may be better than a single value converter.

Data Annotations vs Fluent API

Data annotations are simple and close to the model.

Example:

Code
public sealed class Product
{
    public int Id { get; set; }

    [MaxLength(200)]
    public string Name { get; set; } = string.Empty;
}

Fluent API is more powerful and keeps persistence configuration separate.

Example:

Code
builder.Property(p => p.Name)
    .HasMaxLength(200)
    .IsRequired();

Comparison:

AspectData AnnotationsFluent API
LocationOn entity classIn EF configuration
ComplexityGood for simple rulesBest for complex mappings
SeparationMixes persistence details into modelKeeps mapping separate
CapabilityLimitedFull EF Core configuration
Team preferenceUseful in small appsPreferred in larger apps

For interviews, a strong answer is: use conventions for simple defaults, data annotations for simple validation/mapping if acceptable, and Fluent API for production-grade, complex, or centralized EF configuration.

Relationship Mapping Patterns

Common relationship mapping methods:

MethodMeaning
HasOneEntity has one related entity
HasManyEntity has many related entities
WithOneOther side has one navigation
WithManyOther side has many navigation
HasForeignKeyConfigures the dependent foreign key
HasPrincipalKeyConfigures an alternate principal key
IsRequiredConfigures required relationship
OnDeleteConfigures delete behavior
UsingEntityConfigures many-to-many join entity/table
OwnsOneConfigures owned reference
OwnsManyConfigures owned collection
ComplexPropertyConfigures complex type

Example one-to-many:

Code
builder.HasMany(c => c.Orders)
    .WithOne(o => o.Customer)
    .HasForeignKey(o => o.CustomerId)
    .OnDelete(DeleteBehavior.Restrict);

Example one-to-one:

Code
builder.HasOne(u => u.Profile)
    .WithOne(p => p.User)
    .HasForeignKey<UserProfile>(p => p.UserId);

Example many-to-many:

Code
builder.HasMany(s => s.Courses)
    .WithMany(c => c.Students)
    .UsingEntity("StudentCourse");

Relationship Mapping Without Navigations

EF Core can map relationships without navigations.

Example:

Code
public sealed class AuditLog
{
    public int Id { get; set; }
    public int UserId { get; set; }
}

Configuration:

Code
modelBuilder.Entity<AuditLog>()
    .HasOne<User>()
    .WithMany()
    .HasForeignKey(a => a.UserId);

This creates a database relationship even though AuditLog does not have a User navigation.

This can be useful when:

  • You want a foreign key constraint but not object traversal.
  • The relationship is used mostly for integrity.
  • Loading the related entity is rarely needed.
  • You want to avoid accidental joins or serialization loops.

Delete Behavior and Aggregate Boundaries

In domain-driven design, aggregate boundaries influence relationship mapping.

Example aggregate:

Code
Order
 └── OrderLine

If OrderLine cannot exist without Order, cascade delete may be appropriate.

Example:

Code
builder.HasMany(o => o.Lines)
    .WithOne()
    .HasForeignKey("OrderId")
    .OnDelete(DeleteBehavior.Cascade);

Shared reference data should usually not cascade.

Example:

Code
Product -> Category

Deleting a category should probably not automatically delete all products.

Configuration:

Code
builder.HasOne(p => p.Category)
    .WithMany(c => c.Products)
    .HasForeignKey(p => p.CategoryId)
    .OnDelete(DeleteBehavior.Restrict);

Good relationship mapping should reflect business ownership, not just object references.

Common Mistakes

Common EF Core modeling mistakes include:

  • Relying on conventions when the relationship is ambiguous.
  • Forgetting to configure the dependent side in one-to-one relationships.
  • Using Cascade delete accidentally.
  • Using owned types for data that should be independent.
  • Using regular entities for value objects that have no identity.
  • Confusing complex types with owned entity types.
  • Using records as EF entities without understanding identity and tracking semantics.
  • Exposing EF entities directly as API request models.
  • Forgetting indexes for common query paths.
  • Not configuring decimal precision for money values.
  • Enabling nullable reference types in an existing project without reviewing migrations.
  • Creating multiple navigations between the same two types without explicit configuration.
  • Using many-to-many skip navigations when the join table needs additional data.
  • Hiding all foreign keys and then making queries/debugging harder.
  • Putting all Fluent API configuration in a huge OnModelCreating method.
  • Not reviewing generated migrations.

Best Practices

Start with conventions when the model is simple and follows standard naming.

Use Fluent API for production mappings, complex relationships, legacy schemas, owned types, complex types, delete behavior, indexes, constraints, and value conversions.

Organize mappings with IEntityTypeConfiguration<T>.

Use explicit foreign key properties when it improves clarity.

Use nullable reference types intentionally.

Configure precision for decimal values.

Configure indexes based on real query patterns.

Use owned entity types for aggregate-owned components with owner-dependent lifecycle.

Use complex types for value-object-like structured data with no identity.

Use explicit join entities when many-to-many relationships contain extra data.

Configure delete behavior deliberately.

Review generated migrations before applying them.

Keep API DTOs separate from EF entities.

Use integration tests to verify important mappings, constraints, and delete behavior.

Interview Practice

PreviousJWT Bearer Auth, Claims, Scopes, and Policy-Based AuthorizationNext UpData Seeding Choices, Including HasData vs Runtime Seed Logic