← Open in the full interactive course (progress tracking, search & more)

Intermediate taught you to query and save with EF Core. Part VIII teaches you to do it fast, safe, and correct — at production scale.

Take stock of what you already know how to do. You can define entities and relationships, generate and apply migrations, query with LINQ, tell tracked queries apart from no-tracking ones, wrap a save in an implicit transaction, and reach for the repository pattern with an honest sense of when it earns its keep. That's a real, working command of Entity Framework Core — enough to build a correct application.

But "correct" and "production-ready at scale" are not the same finish line. A query that's correct but generates a cartesian-product-shaped result set under the hood. A save that's correct in isolation but silently clobbers another user's concurrent edit. A DbContext that's correct but is quietly starving your connection pool under real load. None of that shows up in a small demo — all of it shows up the first time your application meets real traffic, real concurrent users, and a database that has opinions about how you talk to it.

This lesson orients you to Part VIII — Enterprise Data — and then teaches four EF Core mechanisms you haven't seen yet: global query filters, owned entity types, shadow properties, and interceptors.

What Is It?

The Simple Explanation

Part VIII, Enterprise Data, is where the EF Core and database-access knowledge you built across Intermediate Part VI and this book's earlier LINQ lessons gets pushed to production depth: faster queries, transactions and concurrency handled rigorously instead of briefly, connection behavior you actually understand instead of trust blindly, and honest judgment about when EF Core is the right tool at all versus when raw SQL or a stored procedure genuinely serves you better.

The Technical Definition

Concretely, this Part covers: query optimization and split queries, EF Core's compiled-query mechanism, transactions spanning multiple SaveChanges() calls plus isolation levels, optimistic and pessimistic concurrency in depth, connection pool sizing and DbContext pooling, stored procedures from EF Core, bulk insert/update/delete operations, caching strategies specific to data access, a grounded comparison of raw SQL versus EF Core, and a capstone on troubleshooting real database performance problems. This lesson itself teaches four EF Core modeling and pipeline features that the rest of the Part — and real applications — lean on: global query filters, owned entity types, shadow properties, and interceptors.

Why Does It Exist?

The Problem — Intermediate Depth Was Deliberately Introductory

Go back and look honestly at Intermediate Part VI. Transactions were covered as "here's the implicit one you get for free, and here's the explicit one for multiple saves" — correct, but it never touched isolation levels. Concurrency was "here's a RowVersion token and the exception it throws" — correct, but it never showed you the actual conflict-resolution code a real incident demands. Connections were "trust the pool" — correct, but it never explained pool sizing, or what an AddDbContextPool even is. That scoping wasn't an oversight — it was a deliberate choice to keep the Intermediate book teachable, with an explicit promise that this depth would come later, in the Advanced tier.

The Solution — a Dedicated Part for the Depth That Was Deferred

Part VIII is that promise being kept. Every lesson in it either goes deeper on something Intermediate deliberately kept shallow, or introduces a genuinely new EF Core mechanism that only makes sense once you already have solid query, tracking, and transaction fundamentals under you — which, having finished Intermediate and Advanced Parts I through VII, you do.

Big Picture

PART VIII — ENTERPRISE DATA, THE ROAD AHEAD
This lesson — Advanced EF Core
Query Optimization → Compiled Queries
Transactions → Concurrency
Database Connection Management
Stored Procedures → Bulk Operations → Caching Strategies → SQL vs. EF Core → DB Performance Troubleshooting (capstone)

Every lesson in this list assumes you're comfortable with what Intermediate Part VI and this book's LINQ-to-EF-Core lessons already taught — none of it re-teaches DbContext, entities, migrations, or basic LINQ translation from scratch.

How It Works — Four New EF Core Mechanisms

1. Global Query Filters — a Filter Applied to Every Query, Automatically

A global query filter is a WHERE condition you attach once, in OnModelCreating, to an entity type — and EF Core silently applies it to every single query against that entity, everywhere in your codebase, without any individual LINQ query needing to remember it.

public class AppDbContext : DbContext { protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Product>() .HasQueryFilter(p => !p.IsDeleted); // applied to EVERY query against Product } }

The classic real-world use case is soft delete. Instead of physically DELETE-ing a row (and losing the history, or breaking foreign keys pointing at it), you flag it with an IsDeleted boolean and keep the row. Without a global filter, every single query anywhere in the application that touches Product would need to remember to add .Where(p => !p.IsDeleted) — and the day one developer forgets, in one obscure report query, deleted products silently reappear. A global filter removes that entire class of bug at the source: every context.Products query, everywhere, already excludes soft-deleted rows, automatically.

// No .Where() needed anywhere below — the filter is already applied List<Product> active = await context.Products.ToListAsync(); // Deliberately need the deleted rows too (an admin "restore" screen, say)? List<Product> everything = await context.Products .IgnoreQueryFilters() .ToListAsync();

IgnoreQueryFilters() is the explicit escape hatch — you have to opt out on purpose, which is exactly the right default: silent inclusion of deleted data would be the dangerous mistake; silent exclusion, overridable when genuinely needed, is the safe one.

2. Owned Entity Types — Mapping a Value Object Onto Its Owner's Table

Recall Advanced Part VI's Domain-Driven Design lesson: a Value Object — like Money or an Address — has no identity of its own; it's defined entirely by its data, and two Value Objects with identical values are simply the same value. EF Core's owned entity type (also called a complex type, for the simplest cases) is the mapping technique built specifically for that shape: the Value Object's properties are stored as columns on its owner's table, not as a separate table with its own primary key.

public class Order { public int Id { get; set; } public Address ShippingAddress { get; set; } = null!; // a Value Object } public class Address // no Id — it has no independent identity { public string Street { get; set; } = ""; public string City { get; set; } = ""; public string PostalCode { get; set; } = ""; } // In OnModelCreating: modelBuilder.Entity<Order>().OwnsOne(o => o.ShippingAddress);

OwnsOne tells EF Core: don't give Address its own table — instead, flatten Street, City, and PostalCode onto the Orders table itself, typically as ShippingAddress_Street, ShippingAddress_City, and so on. There's no separate Addresses table, no foreign key, no join needed to read an order's address — it's right there on the same row, which is exactly correct for something with no identity of its own. This is the natural EF Core home for the Value Objects Advanced Part VI taught you to model in your domain layer in the first place.

3. Shadow Properties — a Column With No Corresponding C# Property

A shadow property exists in EF Core's model, and as a real column in the database — but it has no corresponding property on your C# entity class at all. EF Core tracks its value internally, purely by name, and you read or write it through the change tracker's API rather than through ordinary property access.

protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Order>() .Property<DateTime>("LastModifiedUtc"); // no such property on Order itself } // Reading/writing it: context.Entry(order).Property("LastModifiedUtc").CurrentValue = DateTime.UtcNow;

This is commonly used for audit columns (CreatedUtc, LastModifiedUtc, ModifiedBy) or foreign keys you don't want cluttering the domain model — the Order class itself stays focused purely on order-domain concepts, while EF Core quietly manages bookkeeping columns behind the scenes. It's a genuine trade-off: you gain a cleaner domain model, at the cost of that data being reachable only through the change tracker, not through ordinary, discoverable C# properties.

4. Interceptors — a Hook Into EF Core's Command Pipeline

An interceptor (implementing IInterceptor, most commonly via the DbCommandInterceptor base class) is a hook that lets you observe — or even modify — the actual database commands EF Core is about to execute, before they run.

public class SlowQueryLoggingInterceptor : DbCommandInterceptor { public override async ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync( DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result, CancellationToken cancellationToken = default) { // Observe (or, less commonly, rewrite) the command before it runs Console.WriteLine(command.CommandText); return await base.ReaderExecutingAsync(command, eventData, result, cancellationToken); } } // Registered once, at DbContext configuration: options.AddInterceptors(new SlowQueryLoggingInterceptor());

You won't build a full interceptor-based diagnostic yet — that's exactly the kind of tool this Part's later lessons on troubleshooting and logging put to real use. For now, just know the hook exists: it's how EF Core lets you plug into "a command is about to run" as a first-class extensibility point, rather than reaching for something hacky.

Simple Example — Soft Delete, End to End

public class Product { public int Id { get; set; } public string Name { get; set; } = ""; public bool IsDeleted { get; set; } } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted); } // "Deleting" a product — no DELETE statement at all: Product product = await context.Products.FindAsync(productId); product.IsDeleted = true; await context.SaveChangesAsync(); // Every later query — anywhere in the app — already excludes it: List<Product> catalog = await context.Products.ToListAsync(); // product is not here

Nobody who writes context.Products.Where(...) six months from now, in a part of the codebase that has never heard of soft delete, needs to remember anything. The filter is structural, not a convention someone has to follow.

Real-World Example — an Order With a Value Object and Audit Columns

A realistic e-commerce Order entity combining several of today's mechanisms at once:

public class Order { public int Id { get; set; } public bool IsDeleted { get; set; } public Money Total { get; set; } // Value Object, owned public Address ShippingAddress { get; set; } = null!; // Value Object, owned } public readonly record struct Money(decimal Amount, string Currency); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Order>(order => { order.HasQueryFilter(o => !o.IsDeleted); // soft delete, every query order.OwnsOne(o => o.Total); // Money flattened onto Orders order.OwnsOne(o => o.ShippingAddress); // Address flattened onto Orders order.Property<DateTime>("LastModifiedUtc"); // shadow property — no C# field }); }

Nothing here touches how Order reads in your domain code — order.Total.Amount, order.ShippingAddress.City — but underneath, the actual Orders table carries Total_Amount, Total_Currency, ShippingAddress_Street, and a LastModifiedUtc column that no C# property ever exposes directly. An interceptor registered on this same DbContext could log every generated UPDATE against this table without touching a single line of Order-related business logic.

Analogy

A Building's Standing Policies and Hidden Infrastructure

A global query filter is like a building's standing front-desk policy — "nobody signs in a visitor without ID," applied automatically to every visitor, every day, without the receptionist needing to remember the rule each time; it's built into how the front desk works, not into each individual receptionist's memory.

An owned entity type is like a room's built-in fixtures — the sink and the counter aren't separately addressed rooms of their own with their own room numbers; they're just part of the room they belong to, described together with it.

A shadow property is like the building's wiring behind the walls — it's genuinely there, doing real work, but it isn't part of the furniture anyone in the room sees or touches directly; you'd need to open a panel (the change tracker API) to access it.

An interceptor is like a security camera at the loading dock — it doesn't change what gets delivered, but it watches (or, for the rare case, can flag) every delivery as it happens, giving you visibility you'd otherwise have no way to get.

Under the Hood

HOW THE MODEL BUILDER TURNS THESE INTO SQL
1. Global query filters are woven into every LINQ query's expression tree, before translation
2. Owned types share the owner's table by default — one row, multiple prefixed column groups
3. Shadow properties live in the model's metadata, keyed by string name
4. Interceptors sit directly in the pipeline between "SaveChanges/query decided what to run" and "ADO.NET actually runs it"

Common Confusion

1. "Owned entity types are just a fancy word for a regular relationship"

A regular one-to-one relationship gives the related type its own identity, its own table (usually), and its own primary key — it's still an Entity in DDD terms. An owned type has no identity, no separate table by default, and cannot exist independently of its owner — you can't query a bare Address on its own; it only ever shows up attached to the Order (or whatever) that owns it. That's the DDD Value Object distinction, expressed directly in the EF Core mapping.

2. "A global query filter is the same as adding .Where() to a shared base repository method"

A shared base method only helps the callers who actually go through it. A global query filter is enforced by the model itself — every query against that entity, through any DbSet<T> access, from any code anywhere in the application, gets it automatically, including code a future teammate writes without knowing your repository conventions exist.

Common Mistakes

Mistake 1 — Forgetting a global filter exists, then being confused why a row "disappeared"

Adding a soft-delete filter, then months later debugging "why does this product not show up in my raw SQL-equivalent LINQ query" without remembering the filter is silently active. Document global filters clearly at the entity definition, and reach for IgnoreQueryFilters() deliberately — and visibly — whenever a query genuinely needs to see filtered-out rows.

Mistake 2 — Modeling a real Entity as an owned type just to avoid a join

Making Customer an owned type of Order to avoid a foreign key, when a Customer obviously has an independent identity, is queried on its own, and is shared across many orders. Reserve owned types for genuine Value Objects with no independent identity and no reason to ever be queried standalone — everything else is a real relationship.

Mistake 3 — Reaching for a shadow property when a normal property would be clearer

Hiding business-relevant data (like Status) as a shadow property purely to keep the class "clean," making it invisible to anyone reading the entity's C# definition. Reserve shadow properties for genuinely infrastructural concerns — audit timestamps, technical foreign keys — not for data your domain logic actually needs to reason about directly.

When Should I Use It?

SituationReach for
Soft delete, multi-tenant row isolation, or any rule that must apply to every query against an entityGlobal query filter
A concept with no identity, defined entirely by its data (Money, Address, DateRange)Owned entity type
Audit/technical columns your domain code never needs to read directlyShadow property
Observing or logging every command EF Core sends, application-wideInterceptor
A concept with real, independent identity, queried on its ownA normal entity and relationship — none of the above
Rule of thumb: These four tools solve four narrow, specific problems. Reach for each only when its specific problem is the one you actually have — not as a default modeling style for every entity.

Mental Model

Global query filter = a rule baked into the model, not remembered by every caller.
Owned entity type = a Value Object, stored as part of its owner's row.
Shadow property = a real column with no C# property to match it.
Interceptor = a hook that sees every command before it runs.

Remember: this Part goes deeper on what Intermediate deliberately kept shallow — nothing here replaces those fundamentals, it builds on them.

Key Takeaway


Check Your Understanding

You've met four new EF Core mechanisms and the roadmap for the rest of this Part. Let's confirm it clicked.

1. A team implements soft delete by adding an IsDeleted column and asking every developer to remember to add .Where(x => !x.IsDeleted) to every query. What EF Core feature removes the need for that convention entirely?

Show answer

Correct: B

Why B is correct: A global query filter, defined once with HasQueryFilter, is automatically applied to every query against that entity — removing the need for any individual developer to remember to add the condition themselves.

Why A is incorrect: A shadow property is about a column with no C# property, not about automatically filtering query results.

Why C is incorrect: Owned entity types map Value Objects onto their owner's table — unrelated to automatically filtering rows.

Why D is incorrect: An interceptor observes or modifies commands before execution — it isn't the mechanism for automatically excluding rows from every query.

Reinforcement: Soft delete is the textbook use case for a global query filter specifically because it removes reliance on every caller remembering a convention.

2. Why is an owned entity type the natural EF Core mapping for a DDD Value Object like Money, rather than a normal one-to-one relationship?

Show answer

Correct: B

Why B is correct: A Value Object is defined entirely by its data and has no independent identity — exactly what an owned entity type models: no separate table, no primary key of its own, stored as part of the owner's row.

Why A is incorrect: This is backwards — a Value Object specifically should NOT be independently queryable or have its own identity; that's what distinguishes it from an Entity.

Why C is incorrect: The lesson never claims owned types are universally faster — the fit is about correctly modeling identity, not raw performance.

Why D is incorrect: Ordinary relationships handle decimal values fine — that's not the distinguishing factor at all.

Reinforcement: Choosing owned types versus real relationships should follow the same identity question Advanced Part VI's DDD lesson taught: does this thing have identity, or is it just data?

3. An entity has a LastModifiedUtc column tracked by EF Core, but no corresponding property exists anywhere on the C# entity class. What is this an example of?

Show answer

Correct: A

Why A is correct: A shadow property is defined exactly this way — it exists in EF Core's model and as a real database column, but has no backing C# property; it's accessed through the change tracker's API by name.

Why B is incorrect: An owned entity type maps a whole Value Object class's properties onto the owner's table — this scenario describes a single column with no class involved at all.

Why C is incorrect: A global query filter affects which rows a query returns, not whether a column has a backing C# property.

Why D is incorrect: A compiled query is a pre-compiled LINQ query delegate, covered in a later lesson — unrelated to model column mapping.

Reinforcement: Shadow properties are the specific tool for "a real column, tracked by EF Core, deliberately kept off the domain class."

4. What is the most accurate description of what an EF Core interceptor gives you?

Show answer

Correct: B

Why B is correct: An interceptor (implementing IInterceptor, commonly via DbCommandInterceptor) sits in the pipeline between EF Core deciding what to run and ADO.NET actually running it, letting you observe or modify the command.

Why A is incorrect: Interceptors don't provide automatic retry behavior on their own — that would need to be explicitly implemented as custom logic inside one, not something the mechanism gives you by default.

Why C is incorrect: Filtering which entities a query returns is what a global query filter does, not an interceptor.

Why D is incorrect: The change tracker is a separate, core EF Core component — interceptors hook the command pipeline, not tracking.

Reinforcement: Interceptors are a pipeline hook for observing/modifying commands — this lesson previews them; later lessons in this Part put them to concrete use for logging and troubleshooting.

You're oriented for Part VIII, and you've added four real EF Core tools to your kit. Next up: making the queries you already know how to write genuinely fast.


dotnetmadeeasy.com — Learn C# and .NET, the right way.