C# Academy · Lesson

Global Query Filters & Owned Entities

Apply tenant isolation and soft-delete with global query filters, and map value objects using owned entity types.

Lesson 4 of 413 steps

Global Query Filters & Owned Entities is a free C# Academy lesson on CoddyKit. This is lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the C# Academy learning path, and your progress syncs across the web and the CoddyKit app. The C# Academy course includes 4 lessons in total.

What Are Global Query Filters?

Global query filters are WHERE predicates automatically applied to every LINQ query for a given entity type. They're perfect for soft delete, multi-tenancy, and row-level security — without repeating the condition everywhere.

Soft Delete with Global Filter

Add an IsDeleted flag and configure a global filter so deleted records are never returned unless you explicitly ignore the filter.

// Entity with soft-delete flag
public class Post
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public bool IsDeleted { get; set; }
}

// Configure in OnModelCreating
modelBuilder.Entity<Post>()
    .HasQueryFilter(p => !p.IsDeleted);

// All queries now implicitly add WHERE IsDeleted = 0:
var posts = await _db.Posts.ToListAsync();

Multi-Tenancy with Global Filter

Use a filter that reads the current tenant from a scoped service. Every query is automatically scoped to the current tenant's data.

public class AppDbContext : DbContext
{
    private readonly ICurrentUserService _user;

    public AppDbContext(DbContextOptions<AppDbContext> opt, ICurrentUserService user)
        : base(opt) => _user = user;

    protected override void OnModelCreating(ModelBuilder mb)
    {
        mb.Entity<Order>().HasQueryFilter(
            o => o.TenantId == _user.TenantId);
    }
}
// Every Order query automatically filters by TenantId

Ignoring Global Filters

Sometimes you need to bypass the filter — e.g., admin queries for all tenants, or restoring a soft-deleted record. Use IgnoreQueryFilters() for that query only.

// Admin: see ALL posts including deleted
var allPosts = await _db.Posts
    .IgnoreQueryFilters()
    .ToListAsync();

// Restore a specific deleted post
var deleted = await _db.Posts
    .IgnoreQueryFilters()
    .FirstOrDefaultAsync(p => p.Id == 42);

if (deleted is not null)
{
    deleted.IsDeleted = false;
    await _db.SaveChangesAsync();
}

Filters on Navigation Properties

Global filters participate in JOINs too. If Posts has a filter, related Posts loaded via Include are also filtered automatically.

// Blog with soft-delete filter on Posts
var blog = await _db.Blogs
    .Include(b => b.Posts) // only non-deleted posts are included
    .FirstOrDefaultAsync(b => b.Id == 1);

// blog.Posts contains only active posts — the filter applied in the JOIN

Owned Entity Types: Concept

Owned entity types are classes that belong exclusively to a single owner entity. They have no primary key and are stored in the same table (by default). Perfect for value objects in DDD.

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public Address BillingAddress  { get; set; } = new();
    public Address ShippingAddress { get; set; } = new();
}

public class Address
{
    public string Street  { get; set; } = "";
    public string City    { get; set; } = "";
    public string ZipCode { get; set; } = "";
    public string Country { get; set; } = "";
}

Configuring Owned Entities

Use OwnsOne to configure a single owned entity. EF Core prefixes columns with the navigation property name by default.

modelBuilder.Entity<Customer>(entity =>
{
    entity.OwnsOne(c => c.BillingAddress, addr =>
    {
        addr.Property(a => a.Street).HasMaxLength(300);
        addr.Property(a => a.Country).HasMaxLength(2);
    });

    entity.OwnsOne(c => c.ShippingAddress);
});
// Table: Customers with columns:
// BillingAddress_Street, BillingAddress_City, ...
// ShippingAddress_Street, ShippingAddress_City, ...

Owned Entities in a Separate Table

Use ToTable() inside OwnsOne to store owned entities in their own table — useful when the data is large or optional.

modelBuilder.Entity<Customer>().OwnsOne(
    c => c.BillingAddress,
    addr => addr.ToTable("CustomerBillingAddresses"));

// Now BillingAddress columns are in a separate table
// with a FK back to CustomerId

OwnsMany for Collections of Value Objects

OwnsMany configures a collection of owned entities — for example, a list of email addresses or phone numbers stored in a child table.

public class Customer
{
    public int Id { get; set; }
    public IList<PhoneNumber> PhoneNumbers { get; set; } = new List<PhoneNumber>();
}

public class PhoneNumber
{
    public string Number { get; set; } = "";
    public string Type   { get; set; } = "";
}

modelBuilder.Entity<Customer>()
    .OwnsMany(c => c.PhoneNumbers,
        phone => phone.ToTable("CustomerPhones"));

Table-Per-Hierarchy vs Owned

Global filters also work well with TPH (Table-Per-Hierarchy) inheritance, where a discriminator column distinguishes subtypes. Apply a filter based on the discriminator for type-specific queries.

// Base entity
public abstract class Payment { public int Id { get; set; } }
public class CardPayment  : Payment { public string CardLast4 { get; set; } = ""; }
public class BankPayment  : Payment { public string IBAN { get; set; } = ""; }

// All Payment subtypes in one table with a Discriminator column
// No extra config needed — EF Core handles TPH by default
var cards = await _db.Set<CardPayment>().ToListAsync();
// SQL: SELECT * FROM Payments WHERE Discriminator = 'CardPayment'

Real-World: SaaS Tenant Isolation

A complete SaaS multi-tenancy setup combines global query filters with a scoped tenant context, ensuring every query is automatically scoped without any per-query code.

// Scoped tenant service
public class TenantContext
{
    public Guid TenantId { get; set; }
}

// DbContext uses it
modelBuilder.Entity<Invoice>().HasQueryFilter(
    inv => inv.TenantId == _tenantCtx.TenantId);
modelBuilder.Entity<Customer>().HasQueryFilter(
    c => c.TenantId == _tenantCtx.TenantId);

// Queries are automatically scoped:
var invoices = await _db.Invoices.ToListAsync();
// WHERE TenantId = 'current-tenant-guid'

Quick Check

Which method do you call to temporarily bypass a global query filter for a specific query?

Recap: Global Filters & Owned Entities

Key takeaways:

  • Global query filters automatically add WHERE predicates to every query for an entity
  • Ideal for soft delete, multi-tenancy, and row-level security
  • Bypass with IgnoreQueryFilters() for admin or restore operations
  • Owned entities model value objects — no PK, stored in owner's table
  • OwnsOne / OwnsMany configure owned relationships; use ToTable() to split
Free to start

Learn C# with an AI tutor — free

Write and run real code in your browser, get instant help from a 24/7 AI tutor, and pick up where you left off on the web or in the app.

Courses
93
Lessons
346

Frequently Asked Questions

Is the “Global Query Filters & Owned Entities” lesson free?

Yes — the full text of “Global Query Filters & Owned Entities” is free to read here on the web. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the C# Academy course, upgrade to CoddyKit PRO. The C# Academy course includes 4 lessons in total.

What will I learn in “Global Query Filters & Owned Entities”?

Apply tenant isolation and soft-delete with global query filters, and map value objects using owned entity types. You practise C# Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start C# Academy?

No prior experience is required. C# Academy on CoddyKit is structured for beginners through advanced learners, so you can start here or from the beginning and move at your own pace. This is lesson 4 of 4.

How long does the “Global Query Filters & Owned Entities” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this C# Academy lesson?

Yes. Every C# Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Eager, Lazy & Explicit Loading
  2. Raw SQL, Stored Procedures & Interpolation
  3. Compiled Queries & Performance
  4. Global Query Filters & Owned Entities
← Back to C# Academy