Chapter 8: Advanced Mapping and Inheritance - IndianTechnoEra
Latest update Android YouTube

Chapter 8: Advanced Mapping and Inheritance

Welcome to Chapter 8! Now that you've mastered the fundamentals of querying and data modification, it's time to explore advanced mapping techniques. This chapter covers how to map complex types, handle inheritance hierarchies, and leverage modern database features like JSON columns. These advanced patterns will help you create cleaner domain models and take full advantage of your database's capabilities.

8.1 Value Conversions

Value conversions allow you to map property values to and from the database. This is useful when your domain model uses types that aren't natively supported by the database.

8.1.1 Understanding Value Conversions

Value conversions transform data between the model and the database. For example, you might store an enum as a string in the database, or store a list of strings as a comma-separated column.

8.1.2 Built-in Value Converters

using Microsoft.EntityFrameworkCore.Storage.ValueConversion;

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; }
    
    // Store enum as string
    public BookStatus Status { get; set; }
    
    // Store DateTime as UTC
    public DateTime CreatedOn { get; set; }
}

public enum BookStatus
{
    Draft,
    Published,
    OutOfPrint
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Book>(b =>
    {
        // Convert enum to string (instead of int)
        b.Property(e => e.Status)
            .HasConversion<string>();
        
        // Or use explicit conversion
        b.Property(e => e.Status)
            .HasConversion(
                v => v.ToString(),
                v => (BookStatus)Enum.Parse(typeof(BookStatus), v));
        
        // Convert DateTime to UTC
        b.Property(e => e.CreatedOn)
            .HasConversion(
                v => v.ToUniversalTime(),
                v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
    });
}

8.1.3 Custom Value Conversions

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; }
    
    // Store list of tags as comma-separated string
    public List<string> Tags { get; set; } = new List<string>();
    
    // Store JSON string as object
    public BookMetadata Metadata { get; set; }
    
    // Store encrypted data
    public string EncryptedSensitiveData { get; set; }
}

public class BookMetadata
{
    public string Publisher { get; set; }
    public string Language { get; set; }
    public int? Edition { get; set; }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Book>(b =>
    {
        // Convert List<string> to comma-separated string
        b.Property(e => e.Tags)
            .HasConversion(
                v => string.Join(",", v),
                v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList());
        
        // Convert complex object to JSON string
        var jsonConverter = new ValueConverter<BookMetadata, string>(
            v => System.Text.Json.JsonSerializer.Serialize(v),
            v => System.Text.Json.JsonSerializer.Deserialize<BookMetadata>(v));
        
        b.Property(e => e.Metadata)
            .HasConversion(jsonConverter)
            .HasColumnType("nvarchar(max)");
        
        // Convert encrypted data (example with simple base64)
        b.Property(e => e.EncryptedSensitiveData)
            .HasConversion(
                v => Convert.ToBase64String(Encoding.UTF8.GetBytes(v)),
                v => Encoding.UTF8.GetString(Convert.FromBase64String(v)));
    });
}

8.1.4 Value Converters with Custom Types

// Custom value object
public readonly struct Money
{
    public Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }
    
    public decimal Amount { get; }
    public string Currency { get; }
    
    public override string ToString() => $"{Amount:F2} {Currency}";
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public Money Price { get; set; }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Custom value converter for Money type
    var moneyConverter = new ValueConverter<Money, string>(
        v => $"{v.Amount}|{v.Currency}",
        v =>
        {
            var parts = v.Split('|');
            return new Money(decimal.Parse(parts[0]), parts[1]);
        });
    
    modelBuilder.Entity<Product>()
        .Property(e => e.Price)
        .HasConversion(moneyConverter)
        .HasColumnName("Price")
        .HasColumnType("nvarchar(50)");
}

// Usage
using var db = new AppDbContext();
var product = new Product
{
    Name = "Book",
    Price = new Money(29.99m, "USD")
};
db.Products.Add(product);
db.SaveChanges();

8.1.5 Value Converters for Enum Collections

[Flags]
public enum BookFeatures
{
    None = 0,
    Illustrations = 1,
    AudioBook = 2,
    Ebook = 4,
    SignedCopy = 8
}

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; }
    public BookFeatures Features { get; set; }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Store flags enum as string (e.g., "Illustrations,AudioBook")
    modelBuilder.Entity<Book>()
        .Property(e => e.Features)
        .HasConversion(
            v => string.Join(",", Enum.GetValues<BookFeatures>().Where(f => v.HasFlag(f))),
            v => v.Split(',', StringSplitOptions.RemoveEmptyEntries)
                  .Select(f => Enum.Parse<BookFeatures>(f))
                  .Aggregate((a, b) => a | b));
}

8.2 Owned Types (Value Objects)

Owned types allow you to model value objects - types that have no identity of their own and are owned by another entity.

8.2.1 Understanding Owned Types

Owned types are mapped to the same table as their owner, or to a separate table. They don't have their own primary key and cannot exist independently.

8.2.2 Basic Owned Type Example

// Value object without identity
public class Address
{
    public string Street { get; set; }
    public string City { get; set; }
    public string State { get; set; }
    public string ZipCode { get; set; }
    public string Country { get; set; }
    
    // Value objects can have behavior
    public string GetFullAddress() => $"{Street}, {City}, {State} {ZipCode}, {Country}";
}

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }
    public Address ShippingAddress { get; set; }
    public Address BillingAddress { get; set; }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Configure Address as owned type
    modelBuilder.Entity<Customer>(c =>
    {
        // OwnsOne for reference navigation
        c.OwnsOne(c => c.ShippingAddress, address =>
        {
            address.Property(a => a.Street).HasColumnName("ShippingStreet").HasMaxLength(200);
            address.Property(a => a.City).HasColumnName("ShippingCity").HasMaxLength(100);
            address.Property(a => a.State).HasColumnName("ShippingState").HasMaxLength(50);
            address.Property(a => a.ZipCode).HasColumnName("ShippingZipCode").HasMaxLength(20);
            address.Property(a => a.Country).HasColumnName("ShippingCountry").HasMaxLength(50);
        });
        
        c.OwnsOne(c => c.BillingAddress, address =>
        {
            address.Property(a => a.Street).HasColumnName("BillingStreet").HasMaxLength(200);
            address.Property(a => a.City).HasColumnName("BillingCity").HasMaxLength(100);
            address.Property(a => a.State).HasColumnName("BillingState").HasMaxLength(50);
            address.Property(a => a.ZipCode).HasColumnName("BillingZipCode").HasMaxLength(20);
            address.Property(a => a.Country).HasColumnName("BillingCountry").HasMaxLength(50);
        });
    });
}

// Generated SQL creates columns:
// Customers table with:
// Id, Name, ShippingStreet, ShippingCity, ShippingState, ShippingZipCode, ShippingCountry,
// BillingStreet, BillingCity, BillingState, BillingZipCode, BillingCountry

8.2.3 Owned Types in Separate Tables

public class Order
{
    public int Id { get; set; }
    public DateTime OrderDate { get; set; }
    public Address ShippingAddress { get; set; }
    public List<OrderLine> Lines { get; set; } = new();
}

public class OrderLine
{
    public int Id { get; set; } // Owned types can have Ids in separate table
    public string ProductName { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>(o =>
    {
        // OwnsOne with separate table
        o.OwnsOne(o => o.ShippingAddress, address =>
        {
            address.ToTable("OrderAddresses");
            address.WithOwner().HasForeignKey("OrderId");
        });
        
        // OwnsMany for collection of owned types
        o.OwnsMany(o => o.Lines, line =>
        {
            line.ToTable("OrderLines");
            line.WithOwner().HasForeignKey("OrderId");
            line.HasKey(l => l.Id);
            line.Property(l => l.ProductName).HasMaxLength(200);
            line.Property(l => l.Quantity);
            line.Property(l => l.UnitPrice).HasColumnType("decimal(18,2)");
        });
    });
}

8.2.4 Nested Owned Types

public class Person
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ContactInfo Contact { get; set; }
}

public class ContactInfo
{
    public Email Email { get; set; }
    public PhoneNumber Phone { get; set; }
}

public class Email
{
    public string Address { get; set; }
    public bool IsVerified { get; set; }
}

public class PhoneNumber
{
    public string Number { get; set; }
    public string Extension { get; set; }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Person>(p =>
    {
        p.OwnsOne(p => p.Contact, contact =>
        {
            contact.OwnsOne(c => c.Email, email =>
            {
                email.Property(e => e.Address).HasColumnName("EmailAddress").HasMaxLength(200);
                email.Property(e => e.IsVerified).HasColumnName("EmailVerified");
            });
            
            contact.OwnsOne(c => c.Phone, phone =>
            {
                phone.Property(ph => ph.Number).HasColumnName("PhoneNumber").HasMaxLength(20);
                phone.Property(ph => ph.Extension).HasColumnName("PhoneExtension").HasMaxLength(10);
            });
        });
    });
}

8.2.5 Querying Owned Types

using var db = new AppDbContext();

// Query by owned type properties
var customersInCity = db.Customers
    .Where(c => c.ShippingAddress.City == "New York")
    .ToList();

// Update owned type
var customer = db.Customers.FirstOrDefault();
if (customer != null)
{
    customer.ShippingAddress = new Address
    {
        Street = "123 Main St",
        City = "Boston",
        State = "MA",
        ZipCode = "02101",
        Country = "USA"
    };
    db.SaveChanges();
    // The entire Address column set is updated
}

// Access owned type after query
foreach (var c in db.Customers.ToList())
{
    Console.WriteLine(c.ShippingAddress?.GetFullAddress());
}

8.3 Inheritance Mapping Strategies

EF Core supports mapping .NET inheritance hierarchies to database tables using three strategies.

8.3.1 TPH (Table Per Hierarchy) - Default

All classes in the hierarchy map to a single table, with a discriminator column to identify the type.

// Base class
public abstract class ContentItem
{
    public int Id { get; set; }
    public string Title { get; set; }
    public DateTime CreatedDate { get; set; }
    public string CreatedBy { get; set; }
}

// Derived classes
public class Article : ContentItem
{
    public string Body { get; set; }
    public int WordCount => Body?.Split().Length ?? 0;
}

public class Video : ContentItem
{
    public string VideoUrl { get; set; }
    public int DurationSeconds { get; set; }
    public string ThumbnailUrl { get; set; }
}

public class Podcast : ContentItem
{
    public string AudioUrl { get; set; }
    public int DurationMinutes { get; set; }
    public string Host { get; set; }
}

// Default configuration - TPH (no explicit configuration needed)
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Configure discriminator explicitly
    modelBuilder.Entity<ContentItem>()
        .HasDiscriminator<string>("ContentType")
        .HasValue<Article>("Article")
        .HasValue<Video>("Video")
        .HasValue<Podcast>("Podcast");
    
    // Or use default which uses class names
    // modelBuilder.Entity<ContentItem>().HasDiscriminator();
}

// Generated SQL creates one table: ContentItems with columns:
// Id, Title, CreatedDate, CreatedBy, ContentType (discriminator),
// Body, WordCount (Article specific)
// VideoUrl, DurationSeconds, ThumbnailUrl (Video specific)
// AudioUrl, DurationMinutes, Host (Podcast specific)

// Querying
using var db = new AppDbContext();

// Get all content items
var allContent = db.ContentItems.ToList();

// Get only articles
var articles = db.ContentItems.OfType<Article>().ToList();

// Filter by base type and then refine
var recentContent = db.ContentItems
    .Where(c => c.CreatedDate > DateTime.Now.AddDays(-7))
    .ToList();

// Add new content
db.ContentItems.Add(new Article
{
    Title = "New Article",
    Body = "Article content...",
    CreatedDate = DateTime.Now,
    CreatedBy = "Admin"
});
db.SaveChanges();

8.3.2 TPT (Table Per Type)

Each class has its own table, with the base table containing common columns and derived tables containing specific columns.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Configure TPT inheritance
    modelBuilder.Entity<ContentItem>()
        .ToTable("ContentItems");
    
    modelBuilder.Entity<Article>()
        .ToTable("Articles");
    
    modelBuilder.Entity<Video>()
        .ToTable("Videos");
    
    modelBuilder.Entity<Podcast>()
        .ToTable("Podcasts");
}

// Generated SQL creates separate tables:
// ContentItems: Id, Title, CreatedDate, CreatedBy
// Articles: Id (FK to ContentItems), Body, WordCount
// Videos: Id (FK to ContentItems), VideoUrl, DurationSeconds, ThumbnailUrl
// Podcasts: Id (FK to ContentItems), AudioUrl, DurationMinutes, Host

// Querying with TPT
using var db = new AppDbContext();

// When querying base type, SQL joins all derived tables
var allContent = db.ContentItems.ToList();
// SQL: SELECT c.*, a.*, v.*, p.* FROM ContentItems c
//      LEFT JOIN Articles a ON c.Id = a.Id
//      LEFT JOIN Videos v ON c.Id = v.Id
//      LEFT JOIN Podcasts p ON c.Id = p.Id

// Querying specific type is efficient
var articles = db.Articles.ToList();
// SQL: SELECT * FROM Articles a INNER JOIN ContentItems c ON a.Id = c.Id

8.3.3 TPC (Table Per Concrete Class) - EF Core 7+

Each concrete class has its own table containing all columns (including inherited ones). No base table exists.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Configure TPC inheritance
    modelBuilder.Entity<Article>()
        .ToTable("Articles")
        .UseTpcMappingStrategy();
    
    modelBuilder.Entity<Video>()
        .ToTable("Videos")
        .UseTpcMappingStrategy();
    
    modelBuilder.Entity<Podcast>()
        .ToTable("Podcasts")
        .UseTpcMappingStrategy();
    
    // Base class is not mapped to a table
    modelBuilder.Entity<ContentItem>().UseTpcMappingStrategy();
}

// Generated SQL creates separate tables each with all columns:
// Articles: Id, Title, CreatedDate, CreatedBy, Body, WordCount
// Videos: Id, Title, CreatedDate, CreatedBy, VideoUrl, DurationSeconds, ThumbnailUrl
// Podcasts: Id, Title, CreatedDate, CreatedBy, AudioUrl, DurationMinutes, Host

// Querying TPC
using var db = new AppDbContext();

// Querying base type uses UNION across all tables
var allContent = db.ContentItems.ToList();
// SQL: SELECT Id, Title, CreatedDate, CreatedBy, 'Article' AS Discriminator FROM Articles
//      UNION ALL
//      SELECT Id, Title, CreatedDate, CreatedBy, 'Video' FROM Videos
//      UNION ALL
//      SELECT Id, Title, CreatedDate, CreatedBy, 'Podcast' FROM Podcasts

// Querying specific type is efficient
var articles = db.Articles.ToList(); // Single table query

8.3.4 Comparing Inheritance Strategies

Strategy Performance (Queries) Performance (Writes) Schema Complexity Best For
TPH Fast (single table) Fast (single insert) Low (one table) Simple hierarchies, few columns per type
TPT Slower (multiple joins) Slower (multiple inserts) Moderate (multiple tables) Clean normalization, many columns per type
TPC Fast for type queries Fast (single insert) Moderate (multiple tables) High performance, independent types
// Example: Choosing based on scenario

// Scenario 1: Simple hierarchy with few extra properties -> TPH
public abstract class Notification { ... }
public class EmailNotification : Notification { public string EmailAddress { get; set; } }
public class SmsNotification : Notification { public string PhoneNumber { get; set; } }
// Use TPH (default)

// Scenario 2: Large hierarchy with many unique properties -> TPT
public abstract class Product { ... }
public class Book : Product { public string ISBN { get; set; } public int Pages { get; set; } }
public class Electronics : Product { public string Model { get; set; } public int Wattage { get; set; } }
// Use TPT for clean separation

// Scenario 3: Independent types that happen to share common fields -> TPC
public abstract class Event { ... }
public class Conference : Event { ... }
public class Webinar : Event { ... }
// Use TPC for performance

8.4 JSON Columns (EF Core 7+)

EF Core 7+ supports mapping to JSON columns, allowing you to store structured data in a single column.

8.4.1 Basic JSON Mapping

public class Blog
{
    public int Id { get; set; }
    public string Name { get; set; }
    
    // Store complex object as JSON
    public BlogMetadata Metadata { get; set; }
    
    // Store collection as JSON array
    public List<string> Tags { get; set; } = new();
    
    // Store dictionary as JSON
    public Dictionary<string, string> Settings { get; set; } = new();
}

public class BlogMetadata
{
    public string Theme { get; set; }
    public bool AllowComments { get; set; }
    public string[] Authors { get; set; }
    public SocialLinks SocialLinks { get; set; }
}

public class SocialLinks
{
    public string Twitter { get; set; }
    public string Facebook { get; set; }
    public string Instagram { get; set; }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>(b =>
    {
        // Configure JSON column for complex type
        b.OwnsOne(b => b.Metadata, metadata =>
        {
            metadata.ToJson(); // Store as JSON
            metadata.OwnsOne(m => m.SocialLinks);
        });
        
        // Store collection as JSON
        b.Property(b => b.Tags)
            .HasColumnType("nvarchar(max)") // JSON column
            .HasConversion(
                v => System.Text.Json.JsonSerializer.Serialize(v),
                v => System.Text.Json.JsonSerializer.Deserialize<List<string>>(v));
        
        // Or use built-in JSON support
        // b.Property(b => b.Tags).HasJsonConversion();
        
        // Store dictionary as JSON
        b.Property(b => b.Settings)
            .HasColumnType("nvarchar(max)");
    });
}

// Usage
using var db = new AppDbContext();

var blog = new Blog
{
    Name = "Tech Blog",
    Metadata = new BlogMetadata
    {
        Theme = "Dark",
        AllowComments = true,
        Authors = new[] { "John", "Jane" },
        SocialLinks = new SocialLinks
        {
            Twitter = "@techblog",
            Facebook = "techblog",
            Instagram = "techblog"
        }
    },
    Tags = new List<string> { "Technology", "Programming", "C#" },
    Settings = new Dictionary<string, string>
    {
        ["showAds"] = "false",
        ["emailFrequency"] = "daily"
    }
};

db.Blogs.Add(blog);
db.SaveChanges();

8.4.2 Querying JSON Columns

using var db = new AppDbContext();

// Query based on JSON property (EF Core 8+)
var darkThemeBlogs = db.Blogs
    .Where(b => b.Metadata.Theme == "Dark")
    .ToList();

// Query JSON array (tags contain "C#")
var cSharpBlogs = db.Blogs
    .Where(b => b.Tags.Contains("C#"))
    .ToList();

// Query dictionary values
var blogsWithoutAds = db.Blogs
    .Where(b => b.Settings["showAds"] == "false")
    .ToList();

// Project JSON data
var blogSummaries = db.Blogs
    .Select(b => new
    {
        b.Name,
        b.Metadata.Theme,
        FirstAuthor = b.Metadata.Authors.FirstOrDefault(),
        TagCount = b.Tags.Count
    })
    .ToList();

8.4.3 SQL Server JSON Features

// For SQL Server with JSON support, you can use raw SQL with JSON functions
var jsonResults = db.Blogs
    .FromSqlRaw(@"
        SELECT *
        FROM Blogs
        WHERE JSON_VALUE(Metadata, '$.Theme') = 'Dark'
          AND JSON_QUERY(Tags) LIKE '%C#%'
    ")
    .ToList();

// Update JSON property
db.Blogs
    .Where(b => b.Id == 1)
    .ExecuteUpdate(setters => setters
        .SetProperty(b => b.Metadata.Theme, "Light"));

8.5 Spatial Data

EF Core supports spatial data types (geography and geometry) through the NetTopologySuite package.

8.5.1 Setting Up Spatial Data

// Install packages:
// dotnet add package Microsoft.EntityFrameworkCore.SqlServer.NetTopologySuite
// dotnet add package NetTopologySuite

using NetTopologySuite.Geometries;

public class Location
{
    public int Id { get; set; }
    public string Name { get; set; }
    
    // Geographic point (longitude, latitude)
    public Point Coordinates { get; set; }
    
    // Geographic area
    public Polygon CoverageArea { get; set; }
}

public class Store
{
    public int Id { get; set; }
    public string Name { get; set; }
    public Point Location { get; set; }
}

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.UseSqlServer(
        connectionString,
        x => x.UseNetTopologySuite());
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Location>(l =>
    {
        l.Property(l => l.Coordinates)
            .HasColumnType("geography"); // Use geography for earth coordinates
        
        l.Property(l => l.CoverageArea)
            .HasColumnType("geography");
    });
    
    modelBuilder.Entity<Store>(s =>
    {
        s.Property(s => s.Location)
            .HasColumnType("geography");
    });
}

8.5.2 Querying Spatial Data

using var db = new AppDbContext();
using NetTopologySuite.Geometries;

// Create a point (longitude, latitude)
var myLocation = new Point(-73.9857, 40.7484) // New York coordinates
{
    SRID = 4326 // Standard SRID for GPS coordinates
};

// Find stores within 5 kilometers of my location
var nearbyStores = db.Stores
    .Where(s => s.Location.IsWithinDistance(myLocation, 5000))
    .ToList();

// Find locations in a specific area
var area = new Polygon(new LinearRing(new[]
{
    new Coordinate(-74.0, 40.7),
    new Coordinate(-74.0, 40.8),
    new Coordinate(-73.9, 40.8),
    new Coordinate(-73.9, 40.7),
    new Coordinate(-74.0, 40.7)
})) { SRID = 4326 };

var locationsInArea = db.Locations
    .Where(l => l.Coordinates.Within(area))
    .ToList();

// Calculate distances
var storesWithDistance = db.Stores
    .Select(s => new
    {
        s.Name,
        Distance = s.Location.Distance(myLocation)
    })
    .OrderBy(s => s.Distance)
    .ToList();

// Find nearest store
var nearestStore = db.Stores
    .OrderBy(s => s.Location.Distance(myLocation))
    .FirstOrDefault();

8.6 Table Splitting and Entity Splitting

8.6.1 Table Splitting (Multiple Entities to One Table)

Table splitting allows multiple entities to share the same table, useful for separating large objects.

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; }
    public decimal Price { get; set; }
    
    // Reference to details entity
    public BookDetails Details { get; set; }
}

public class BookDetails
{
    public int Id { get; set; }
    public string Description { get; set; }
    public string Publisher { get; set; }
    public int PageCount { get; set; }
    
    // Reference back to book
    public Book Book { get; set; }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Book>(b =>
    {
        b.ToTable("Books");
        b.HasOne(b => b.Details).WithOne(d => d.Book)
            .HasForeignKey<BookDetails>(d => d.Id);
    });
    
    modelBuilder.Entity<BookDetails>(d =>
    {
        d.ToTable("Books"); // Same table as Book!
        d.HasKey(d => d.Id);
    });
}

// Both entities map to the same Books table
// Book: Id, Title, Price
// BookDetails: Id, Description, Publisher, PageCount
// Total columns: Id, Title, Price, Description, Publisher, PageCount

// Usage
using var db = new AppDbContext();

var book = new Book
{
    Title = "EF Core Guide",
    Price = 39.99m,
    Details = new BookDetails
    {
        Description = "Comprehensive guide to EF Core",
        Publisher = "Tech Press",
        PageCount = 450
    }
};

db.Books.Add(book);
db.SaveChanges(); // Single INSERT

// Query with details
var books = db.Books
    .Include(b => b.Details)
    .ToList();

8.6.2 Entity Splitting (One Entity to Multiple Tables)

Entity splitting maps a single entity across multiple tables.

public class Employee
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
    public decimal Salary { get; set; }
    public string Department { get; set; }
    public DateTime HireDate { get; set; }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Employee>(e =>
    {
        e.ToTable("Employees")
            .SplitToTable("EmployeeDetails", table =>
            {
                table.Property(e => e.Salary);
                table.Property(e => e.Department);
                table.Property(e => e.HireDate);
            });
        
        e.Property(e => e.Id).HasColumnName("Id");
        e.Property(e => e.FirstName).HasColumnName("FirstName");
        e.Property(e => e.LastName).HasColumnName("LastName");
        e.Property(e => e.Email).HasColumnName("Email");
    });
}

// Employees table: Id, FirstName, LastName, Email
// EmployeeDetails table: Id, Salary, Department, HireDate
// Both tables share the same primary key

// Usage - everything works transparently
using var db = new AppDbContext();

var employee = new Employee
{
    FirstName = "John",
    LastName = "Doe",
    Email = "john@example.com",
    Salary = 75000m,
    Department = "Engineering",
    HireDate = new DateTime(2020, 1, 15)
};

db.Employees.Add(employee);
db.SaveChanges(); // Inserts into both tables

// Query joins automatically
var employees = db.Employees.ToList(); // JOIN on Id

8.7 Backing Fields and Encapsulation

EF Core can use backing fields to maintain encapsulation in your domain models.

public class Order
{
    private int _id;
    private string _orderNumber;
    private decimal _totalAmount;
    private List<OrderItem> _items = new();
    
    // Read-only property exposing backing field
    public int Id => _id;
    
    // Property with backing field
    public string OrderNumber
    {
        get => _orderNumber;
        private set => _orderNumber = value;
    }
    
    public decimal TotalAmount => _items.Sum(i => i.TotalPrice);
    
    // Collection exposed as read-only
    public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
    
    // Constructor with business logic
    public Order(string orderNumber)
    {
        if (string.IsNullOrWhiteSpace(orderNumber))
            throw new ArgumentException("Order number required");
        
        _orderNumber = orderNumber;
    }
    
    // Business methods
    public void AddItem(string productName, decimal price, int quantity)
    {
        if (price <= 0) throw new ArgumentException("Price must be positive");
        if (quantity <= 0) throw new ArgumentException("Quantity must be positive");
        
        _items.Add(new OrderItem(productName, price, quantity));
    }
    
    public void RemoveItem(int itemId)
    {
        var item = _items.FirstOrDefault(i => i.Id == itemId);
        if (item != null)
            _items.Remove(item);
    }
}

public class OrderItem
{
    private int _id;
    private string _productName;
    private decimal _unitPrice;
    private int _quantity;
    
    public int Id => _id;
    public string ProductName => _productName;
    public decimal UnitPrice => _unitPrice;
    public int Quantity => _quantity;
    public decimal TotalPrice => _unitPrice * _quantity;
    
    public OrderItem(string productName, decimal unitPrice, int quantity)
    {
        _productName = productName;
        _unitPrice = unitPrice;
        _quantity = quantity;
    }
    
    // Parameterless constructor for EF Core
    private OrderItem() { }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>(o =>
    {
        o.HasKey(o => o._id);
        o.Property(o => o._id)
            .HasColumnName("Id")
            .UseIdentityColumn();
        
        o.Property(o => o._orderNumber)
            .HasColumnName("OrderNumber")
            .HasMaxLength(50)
            .IsRequired();
        
        // Configure the collection with backing field
        o.HasMany(o => o._items)
            .WithOne()
            .HasForeignKey("OrderId")
            .OnDelete(DeleteBehavior.Cascade);
        
        // Ignore calculated property
        o.Ignore(o => o.TotalAmount);
    });
    
    modelBuilder.Entity<OrderItem>(oi =>
    {
        oi.HasKey(oi => oi._id);
        oi.Property(oi => oi._id)
            .HasColumnName("Id")
            .UseIdentityColumn();
        
        oi.Property(oi => oi._productName)
            .HasColumnName("ProductName")
            .HasMaxLength(200)
            .IsRequired();
        
        oi.Property(oi => oi._unitPrice)
            .HasColumnName("UnitPrice")
            .HasColumnType("decimal(18,2)");
        
        oi.Property(oi => oi._quantity)
            .HasColumnName("Quantity");
        
        oi.Ignore(oi => oi.TotalPrice);
        
        oi.Property("OrderId").IsRequired(); // Shadow property for FK
    });
}

// Usage with encapsulated domain
using var db = new AppDbContext();

var order = new Order("ORD-2024-001");
order.AddItem("Laptop", 999.99m, 1);
order.AddItem("Mouse", 29.99m, 2);

db.Orders.Add(order);
db.SaveChanges();

// Querying
var orders = db.Orders
    .Include(o => o._items) // Need to include backing field
    .ToList();

foreach (var o in orders)
{
    Console.WriteLine($"Order: {o.OrderNumber}, Total: {o.TotalAmount:C}");
    foreach (var item in o.Items)
    {
        Console.WriteLine($"  {item.ProductName} x{item.Quantity} = {item.TotalPrice:C}");
    }
}

8.8 Practical Examples: Real-World Mapping Scenarios

Scenario 1: E-Commerce Product Catalog

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public Money Price { get; set; } // Custom value object
    public ProductDetails Details { get; set; } // Owned type
    public List<string> Images { get; set; } // JSON collection
    public Dictionary<string, string> Attributes { get; set; } // JSON dictionary
    public ProductInventory Inventory { get; set; } // Split table
}

public class ProductDetails
{
    public string Description { get; set; }
    public string Manufacturer { get; set; }
    public string Sku { get; set; }
    public Dimensions Dimensions { get; set; }
}

public class Dimensions
{
    public decimal Length { get; set; }
    public decimal Width { get; set; }
    public decimal Height { get; set; }
    public string Unit { get; set; }
}

public class ProductInventory
{
    public int Id { get; set; }
    public int QuantityInStock { get; set; }
    public int ReorderLevel { get; set; }
    public DateTime LastRestocked { get; set; }
    public string Location { get; set; }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Product mapping with JSON and owned types
    modelBuilder.Entity<Product>(p =>
    {
        p.ToTable("Products");
        
        // Value converter for Money
        p.Property(p => p.Price)
            .HasConversion(
                v => $"{v.Amount}|{v.Currency}",
                v => new Money(decimal.Parse(v.Split('|')[0]), v.Split('|')[1]))
            .HasColumnName("Price")
            .HasColumnType("nvarchar(50)");
        
        // Owned type with nested owned type
        p.OwnsOne(p => p.Details, details =>
        {
            details.ToJson(); // Store as JSON
            details.OwnsOne(d => d.Dimensions);
        });
        
        // JSON collection
        p.Property(p => p.Images)
            .HasColumnType("nvarchar(max)");
        
        // JSON dictionary
        p.Property(p => p.Attributes)
            .HasColumnType("nvarchar(max)");
    });
    
    // Entity splitting for Inventory
    modelBuilder.Entity<ProductInventory>(i =>
    {
        i.ToTable("Products")
            .SplitToTable("ProductInventory", table =>
            {
                table.Property(i => i.QuantityInStock);
                table.Property(i => i.ReorderLevel);
                table.Property(i => i.LastRestocked);
                table.Property(i => i.Location);
            });
        
        i.HasKey(i => i.Id);
        i.Property(i => i.Id).HasColumnName("Id");
    });
}

8.9 Performance Considerations

8.9.1 Choosing the Right Strategy

Feature TPH TPT TPC JSON
Query Performance Best Worst (joins) Good Good (with indexes)
Write Performance Best Worst (multiple inserts) Best Good
Schema Normalization Low High Medium Low
Query Flexibility High High Medium Medium
Complex Queries Good Good Harder Limited

8.9.2 Indexing JSON Columns

// SQL Server computed columns for JSON properties
migrationBuilder.Sql(@"
    ALTER TABLE Blogs
    ADD Theme AS JSON_VALUE(Metadata, '$.Theme') PERSISTED;

    CREATE INDEX IX_Blogs_Theme ON Blogs(Theme);
");

// Or using computed columns in EF Core
modelBuilder.Entity<Blog>()
    .Property(b => b.Metadata.Theme)
    .HasComputedColumnSql("JSON_VALUE(Metadata, '$.Theme')", stored: true);

8.9.3 When to Use Each Technique

  • Value Converters: For simple type conversions (enum to string, custom types)
  • Owned Types: For value objects without identity, sharing owner's lifecycle
  • TPH: Default choice for simple inheritance hierarchies
  • TPT: When you need normalized schema and complex relationships
  • TPC: For high-performance scenarios with independent types
  • JSON Columns: For semi-structured data, optional fields, or when schema flexibility is needed
  • Spatial Data: For location-based queries and GIS applications
  • Table Splitting: To separate large entities while keeping them in one table
  • Entity Splitting: To store a single entity across tables (e.g., separating audit data)

Chapter Summary

Concept Key Takeaway
Value Conversions Map property types to/from database. Useful for enums, custom types, simple collections.
Owned Types Model value objects without identity. Mapped to same or separate tables.
TPH (Table Per Hierarchy) Single table with discriminator. Default strategy. Best performance for queries.
TPT (Table Per Type) Separate tables for each type. Clean normalization. More complex queries.
TPC (Table Per Concrete Class) Separate tables with all columns. No base table. Good for performance with independent types.
JSON Columns Store semi-structured data in a single column. Flexible schema evolution.
Spatial Data Geography/geometry types for location queries. Requires NetTopologySuite.
Table Splitting Multiple entities sharing one table. Useful for separating concerns.
Entity Splitting Single entity across multiple tables. Useful for separation of data.
Backing Fields Maintain encapsulation by mapping private fields. Enforce business logic.

What's Next?

Excellent work! You've now mastered advanced mapping techniques in EF Core. These skills will help you create clean, maintainable domain models that leverage the full power of modern databases.

In Chapter 9: Performance Optimization and Advanced Querying, we will:

  • Deep dive into query performance analysis
  • Master index optimization strategies
  • Learn about query caching and compiled queries
  • Understand batch operations and bulk updates
  • Explore connection resiliency and retry patterns
  • Implement caching strategies with EF Core
  • Profile and optimize N+1 queries

Get ready to make your EF Core applications lightning fast!


Post a Comment

Feel free to ask your query...
Cookie Consent
We serve cookies on this site to analyze traffic, remember your preferences, and optimize your experience.
Oops!
It seems there is something wrong with your internet connection. Please connect to the internet and start browsing again.
AdBlock Detected!
We have detected that you are using adblocking plugin in your browser.
The revenue we earn by the advertisements is used to manage this website, we request you to whitelist our website in your adblocking plugin.
Site is Blocked
Sorry! This site is not available in your country.