No history yet

Advanced Entity Modeling

Fine-Tuning with Fluent API

When conventions and data annotations aren't enough, you need more control over your data model. This is where the Fluent API comes in. It allows you to configure every detail of your entity-to-database mapping directly in code.

All Fluent API configurations happen within the OnModelCreating method of your DbContext. By overriding this method, you gain access to a ModelBuilder instance, which is your toolkit for defining relationships, constraints, and advanced mapping strategies.

// Inside YourApplicationDbContext.cs

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);

    // Advanced configurations will go here.
    modelBuilder.Entity<Product>(entity =>
    {
        // Example: Set a max length for the Name property
        entity.Property(p => p.Name).HasMaxLength(200);
    });
}

Hidden Fields with Shadow Properties

Sometimes you need to track data that isn't part of your core domain model. Think auditing fields like CreatedAt or LastModified. You want them in the database, but adding them to your C# entity class can feel like clutter. Shadow properties are the answer.

These properties exist in the EF Core model and the database, but not in your .NET class definition. They keep your domain logic clean while providing essential metadata at the persistence layer.

// Configuring a shadow property in OnModelCreating

modelBuilder.Entity<Post>(entity =>
{
    // This property does not exist on the Post class
    entity.Property<DateTime>("LastModified");
});

Since the property isn't on your class, you can't set it directly. Instead, you access it through EF Core's ChangeTracker. A common pattern is to override the SaveChanges method in your DbContext to automatically update these values before they're written to the database.

// In YourApplicationDbContext.cs

public override int SaveChanges()
{
    var entries = ChangeTracker
        .Entries()
        .Where(e => e.State == EntityState.Added || e.State == EntityState.Modified);

    foreach (var entry in entries)
    {
        // Check if the entity has the shadow property defined
        if (entry.Properties.Any(p => p.Metadata.Name == "LastModified"))
        {
            entry.Property("LastModified").CurrentValue = DateTime.UtcNow;
        }
    }

    return base.SaveChanges();
}

Mapping Complex Types

Your domain model might use custom types that don't have a direct equivalent in the database. For example, you might model a Money value object or use a C# enum for status fields. Value Converters bridge this gap, letting you define how a custom type should be converted to and from a database-supported primitive like a string or decimal.

A common use for value converters is mapping enum properties to their string representations in the database. This makes the database more readable than storing integer values.

// Example: Mapping a Status enum to a string
public enum OrderStatus { Pending, Shipped, Delivered, Canceled }

public class Order 
{
    public int OrderId { get; set; }
    public OrderStatus Status { get; set; }
}

// In OnModelCreating
modelBuilder.Entity<Order>()
    .Property(o => o.Status)
    .HasConversion<string>(); // EF Core converts the enum to its string name

You can also define custom conversion logic for your own value objects. For instance, you could create a Money class and tell EF Core how to store it as a single decimal column in the database.

Architectural Filters

Certain rules need to apply to almost every query you run. The two most common are soft-deletes (excluding records marked as IsDeleted) and multi-tenancy (only showing data for the current TenantId). Instead of adding a Where clause to every query, you can define a Global Query Filter.

This filter is automatically applied by EF Core to every query for a specific entity. It's a powerful way to enforce architectural constraints at the persistence level, making your application code simpler and less error-prone.

// In OnModelCreating

// Assume TenantId is a property on your DbContext
public int CurrentTenantId { get; set; }

// For a multi-tenant application
modelBuilder.Entity<Invoice>()
    .HasQueryFilter(i => i.TenantId == this.CurrentTenantId);

// For soft-deletes
modelBuilder.Entity<Product>()
    .HasQueryFilter(p => !p.IsDeleted);

You can temporarily disable a global filter for a specific query using the IgnoreQueryFilters() method.

Structuring Complex Data

Not all data neatly fits into separate tables with their own primary keys. Sometimes you have value objects or groups of properties that belong together but don't have their own identity. This is where owned entity types and table splitting come in handy.

The conceptual data model provides a high-level view of the entire database, focusing on the entities (objects), attributes (properties), and relationships between entities.

Owned Entity Types let you map complex types, like an Address object, to columns within the owner's table. The Address doesn't get its own table or primary key; it's entirely dependent on its owner (e.g., a Customer). This helps create rich domain models without complicating the database schema.

// Customer has an Address property
public class Customer
{
    public int CustomerId { get; set; }
    public string Name { get; set; }
    public Address ShippingAddress { get; set; } // Owned type
}

// Address class has no ID
public class Address
{
    public string Street { get; set; }
    public string City { get; set; }
    public string PostalCode { get; set; }
}

// In OnModelCreating
modelBuilder.Entity<Customer>().OwnsOne(c => c.ShippingAddress);
// This creates Street, City, and PostalCode columns in the Customers table.

Table Splitting is the reverse. It lets you take a single C# entity and split its properties across multiple database tables that share the same primary key. This is useful for performance optimization. You can separate frequently accessed columns from large, infrequently accessed ones (like a profile photo or a long description). This way, queries for common data don't have to pull back large, slow fields.

// One entity class, but data split into two tables
public class Product
{
    public int ProductId { get; set; }
    public string Name { get; set; } // In 'Products' table
    public decimal Price { get; set; } // In 'Products' table

    public string DetailedDescription { get; set; } // In 'ProductDetails' table
}

// In OnModelCreating
modelBuilder.Entity<Product>(eb => 
{
    eb.ToTable("Products"); // Main table
    eb.Property(p => p.Name).IsRequired();
    eb.Property(p => p.Price);

    // Split DetailedDescription into a separate table
    eb.ToTable("ProductDetails");
    eb.Property(p => p.DetailedDescription);
});
Quiz Questions 1/6

In which method of your DbContext do you configure entity mappings using the Fluent API?

Quiz Questions 2/6

What is the primary advantage of using a shadow property like LastModified?

These techniques allow you to build a data persistence layer that is both highly performant and a clean reflection of your application's domain.