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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 hereNobody 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.
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.
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.
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.
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.
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.
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.
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.
| Situation | Reach for |
|---|---|
| Soft delete, multi-tenant row isolation, or any rule that must apply to every query against an entity | Global 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 directly | Shadow property |
| Observing or logging every command EF Core sends, application-wide | Interceptor |
| A concept with real, independent identity, queried on its own | A normal entity and relationship — none of the above |
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?
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?
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?
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?
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.