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:
- Conventions: EF Core's default rules for discovering entities, keys, properties, relationships, table names, column names, required fields, and foreign keys.
- Fluent API: Explicit model configuration written in
OnModelCreatingor in separate configuration classes. - Owned and complex data: Techniques for modeling value-object-like data such as addresses, money, audit metadata, contact information, and embedded details.
- 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:
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:
CustomerandOrderare entities because they are exposed throughDbSet.Idis the primary key by convention.Customer.OrdersandOrder.Customerare navigations.Order.CustomerIdis the foreign key.- A one-to-many relationship exists from
CustomertoOrder.
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:
Example:
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.Idis the primary key.Post.Idis the primary key.Blog.Postsis a collection navigation.Post.Blogis a reference navigation.Post.BlogIdis the foreign key.- The relationship is required because
BlogIdis 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:
- Conventions
- Data annotations
- 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:
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:
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:
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:
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:
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:
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:
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:
public sealed class Customer
{
public int Id { get; set; }
}
Explicit configuration:
builder.HasKey(c => c.Id);
Composite key:
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:
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:
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:
builder.Property(p => p.Name)
.HasColumnName("product_name")
.HasMaxLength(200)
.IsRequired();
Decimal precision:
builder.Property(p => p.Price)
.HasPrecision(18, 2);
Date/time column type:
builder.Property(o => o.OrderedAtUtc)
.HasColumnType("datetime2");
Column default value:
builder.Property(o => o.CreatedAtUtc)
.HasDefaultValueSql("SYSUTCDATETIME()");
Common mistake:
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:
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:
builder.Property(c => c.Name)
.IsRequired();
builder.Property(c => c.MiddleName)
.IsRequired(false);
For value types:
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:
builder.HasIndex(c => c.Email);
Unique index:
builder.HasIndex(c => c.Email)
.IsUnique();
Composite index:
builder.HasIndex(o => new { o.TenantId, o.OrderNumber })
.IsUnique();
A practical production example:
builder.HasIndex(o => new { o.TenantId, o.CreatedAtUtc });
This supports queries like:
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.
Navigations
A navigation is a C# property that lets you move between related entities.
Reference navigation:
public Customer Customer { get; set; } = null!;
Collection navigation:
public List<Order> Orders { get; } = new();
Example relationship:
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:
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:
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:
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:
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:
Customeris the principal.Orderis the dependent.Order.CustomerIdis 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:
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:
builder.HasOne(p => p.Blog)
.WithMany(b => b.Posts)
.HasForeignKey("BlogId");
You can access shadow properties in queries using EF.Property<T>:
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:
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:
modelBuilder.Entity<Blog>(builder =>
{
builder.HasMany(b => b.Posts)
.WithOne(p => p.Blog)
.HasForeignKey(p => p.BlogId)
.IsRequired();
});
Optional relationship:
public int? BlogId { get; set; }
public Blog? Blog { get; set; }
Configuration:
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:
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:
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:
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:
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:
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:
modelBuilder.Entity<Student>()
.HasMany(s => s.Courses)
.WithMany(c => c.Students)
.UsingEntity("CourseStudent");
Many-to-many with an explicit join entity:
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:
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:
public int CustomerId { get; set; }
public Customer Customer { get; set; } = null!;
Optional relationship:
public int? CustomerId { get; set; }
public Customer? Customer { get; set; }
Configuration:
builder.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId)
.IsRequired();
For optional:
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:
Example:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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.
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:
public sealed record Money(decimal Amount, string Currency);
Example owned entity type:
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:
public sealed record Money(decimal Amount, string Currency);
Entity:
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:
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:
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:
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:
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:
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:
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:
public enum OrderStatus
{
Draft,
Submitted,
Paid,
Cancelled
}
Mapping:
builder.Property(o => o.Status)
.HasConversion<string>()
.HasMaxLength(50);
Example strongly typed ID:
public readonly record struct CustomerId(Guid Value);
public sealed class Customer
{
public CustomerId Id { get; set; }
public string Name { get; set; } = string.Empty;
}
Mapping:
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:
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:
builder.Property(p => p.Name)
.HasMaxLength(200)
.IsRequired();
Comparison:
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:
Example one-to-many:
builder.HasMany(c => c.Orders)
.WithOne(o => o.Customer)
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
Example one-to-one:
builder.HasOne(u => u.Profile)
.WithOne(p => p.User)
.HasForeignKey<UserProfile>(p => p.UserId);
Example many-to-many:
builder.HasMany(s => s.Courses)
.WithMany(c => c.Students)
.UsingEntity("StudentCourse");
Relationship Mapping Without Navigations
EF Core can map relationships without navigations.
Example:
public sealed class AuditLog
{
public int Id { get; set; }
public int UserId { get; set; }
}
Configuration:
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:
Order
└── OrderLine
If OrderLine cannot exist without Order, cascade delete may be appropriate.
Example:
builder.HasMany(o => o.Lines)
.WithOne()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);
Shared reference data should usually not cascade.
Example:
Product -> Category
Deleting a category should probably not automatically delete all products.
Configuration:
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
Cascadedelete 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
OnModelCreatingmethod. - 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.