Projections and Query Views
Source-generated expression projections, computed filters, and aggregation views — all translatable to SQL.
[Projectable] — Expression Projections
Section titled “[Projectable] — Expression Projections”The Problem
Section titled “The Problem”Computed properties on entities (like FullName, IsOverdue, TotalWithTax) work fine in C#, but EF Core cannot translate regular C# properties to SQL. If you use order.IsOverdue in a Where() clause, EF Core throws because it does not know how to convert that property access into a SQL expression.
The workaround is writing Expression<Func<T, bool>> manually — verbose, error-prone, and disconnected from the property definition. The expression lives in a separate file, the property lives on the entity, and there is nothing enforcing they stay in sync.
The Solution
Section titled “The Solution”Mark a property with [Projectable] and the source generator produces a corresponding Expression<Func<T, TResult>> that EF Core can translate to SQL.
[Entity<Guid>]public partial class Invoice{ public DateTimeOffset DueDate { get; private set; } public DateTimeOffset? PaidAt { get; private set; } public int InvoiceNumber { get; private set; }
[Projectable] public bool IsOverdue => PaidAt == null && DueDate < DateTimeOffset.UtcNow;
[Projectable] public string DisplayName => $"INV-{InvoiceNumber}";}What the Source Generator Produces
Section titled “What the Source Generator Produces”public static partial class InvoiceExpr{ public static readonly Expression<Func<Invoice, bool>> IsOverdue = e => e.PaidAt == null && e.DueDate < DateTimeOffset.UtcNow;
public static readonly Expression<Func<Invoice, string>> DisplayName = e => "INV-" + e.InvoiceNumber;}The generated expression mirrors the property body exactly. One definition, two representations: the C# property for in-memory use, the expression for SQL translation.
// In LINQ queries — translates to SQLvar overdueInvoices = await dbContext.Invoices .Where(InvoiceExpr.IsOverdue) .ToListAsync();
// In projections — server-side evaluationvar dtos = await dbContext.Invoices .Select(e => new InvoiceDto { Id = e.PersistenceId, Display = InvoiceExpr.DisplayName.Compile()(e) // or use LINQKit }) .ToListAsync();
// In OrderBy — server-side sortingvar sorted = await dbContext.Invoices .OrderByDescending(InvoiceExpr.IsOverdue) .ToListAsync();Constraints
Section titled “Constraints”The property body must be a single expression that EF Core can translate to SQL:
| Allowed | Not Allowed |
|---|---|
Property access (e.Name) | Method calls (e.Name.ToUpper() — unless EF-mapped) |
Arithmetic operators (+, -, *, /) | Complex control flow (if/else blocks) |
Comparisons (==, !=, <, >) | LINQ methods (e.Items.Select(...)) |
Ternary (? :) | Local variable declarations |
Null coalescing (??) | new object creation |
String concatenation ($"...", +) | External method calls |
If the body cannot be translated to SQL, EF Core will throw at runtime. The source generator does not validate translatability — it trusts the developer to write expressions EF Core understands.
Why This Matters
Section titled “Why This Matters”Without [Projectable], teams end up with one of two bad patterns:
- Client evaluation: Loading entire tables into memory to evaluate computed properties. Works for small datasets, collapses at scale.
- Expression duplication: Maintaining the same logic as both a C# property and a separate
Expression<Func<T, TResult>>. They drift apart silently.
[Projectable] eliminates both by generating the expression from the property body. One source of truth, zero drift.
[ComputedFilter] — Reusable Query Filters
Section titled “[ComputedFilter] — Reusable Query Filters”The Problem
Section titled “The Problem”You have a boolean condition — “is overdue”, “is active”, “has outstanding balance” — that you need in multiple places: repository queries, specifications, API endpoint filters, reporting views. Writing the same Where() expression in each location means duplication and drift. When the business rule changes (“overdue” now means 30 days past due instead of any past due), you have to find and update every copy.
The Solution
Section titled “The Solution”[ComputedFilter] implies [Projectable] and additionally generates a specification and a query extension method:
[Entity<Guid>]public partial class Invoice{ public DateTimeOffset DueDate { get; private set; } public DateTimeOffset? PaidAt { get; private set; }
[ComputedFilter] public bool IsOverdue => PaidAt == null && DueDate < DateTimeOffset.UtcNow;
[ComputedFilter] public bool IsHighValue => Subtotal > 10_000m;}What the Source Generator Produces
Section titled “What the Source Generator Produces”Three outputs from a single property:
// 1. Expression (same as Projectable)public static partial class InvoiceExpr{ public static readonly Expression<Func<Invoice, bool>> IsOverdue = e => e.PaidAt == null && e.DueDate < DateTimeOffset.UtcNow;}
// 2. Specification (composable)public static partial class InvoiceSpecs{ public static Specification<Invoice> IsOverdue() => Spec<Invoice>.Where(InvoiceExpr.IsOverdue);}
// 3. Extension method (fluent)public static partial class InvoiceQueryExtensions{ public static IQueryable<Invoice> WhereIsOverdue(this IQueryable<Invoice> query) => query.Where(InvoiceExpr.IsOverdue);}// As extension method — fluent, discoverablevar overdue = await dbContext.Invoices .WhereIsOverdue() .ToListAsync();
// As specification — composablevar spec = InvoiceSpecs.IsOverdue().And(InvoiceSpecs.IsHighValue());var results = await repository.FindAsync(spec);
// As expression — in projections or custom queriesvar dtos = await dbContext.Invoices .Select(e => new { e.Id, IsOverdue = InvoiceExpr.IsOverdue }) .ToListAsync();Relationship to [Projectable]
Section titled “Relationship to [Projectable]”[ComputedFilter] is a superset of [Projectable]:
| Feature | [Projectable] | [ComputedFilter] |
|---|---|---|
| Generates expression | Yes | Yes |
| Generates specification | No | Yes |
| Generates extension method | No | Yes |
| Return type restriction | Any | bool only |
If the property returns a non-boolean type, use [Projectable]. If it returns bool and you want reusable filtering, use [ComputedFilter].
Why This Matters
Section titled “Why This Matters”A single [ComputedFilter] annotation produces three reusable artifacts from one property definition. The business rule lives in exactly one place. When “overdue” changes from “past due date” to “30 days past due date”, you change the property body and every consumer — specifications, extension methods, expressions — updates automatically at compile time.
[QueryView<TRoot>] — Aggregation Views
Section titled “[QueryView<TRoot>] — Aggregation Views”The Problem
Section titled “The Problem”Reporting queries need aggregations: “total revenue per customer”, “average order value by month”, “count of overdue invoices per region”. Writing these as raw LINQ with GroupBy, Sum, Average is verbose and hard to maintain. The LINQ query itself becomes the specification — unnamed, untestable, buried inside a service method.
When the report needs to join multiple entities, the complexity compounds. You end up with deeply nested GroupBy + Select + anonymous types that are difficult to read and impossible to reuse.
The Solution
Section titled “The Solution”Declare the shape of the result as a class, annotate it with the data source and aggregation rules, and let the source generator produce the LINQ query.
[QueryView<Order>][Join<Customer>(Via = "CustomerId")][GroupBy<Customer>(Properties = "Region")][GroupBy<Order>(Properties = "Status")]public partial class OrderSummaryView{ [From<Customer>(Property = "Region")] public string Region { get; set; } = "";
[From<Order>(Property = "Status")] public OrderStatus Status { get; set; }
[From<Order>(Property = "Total")] public decimal TotalRevenue { get; set; } // SUM inferred from aggregation context
[From<Order>] public int OrderCount { get; set; } // COUNT}Attributes
Section titled “Attributes”[QueryView<TRoot>]
Section titled “[QueryView<TRoot>]”Marks the class as a query view. TRoot is the primary entity — the starting IQueryable<TRoot> that the generated query begins from.
| Property | Type | Description |
|---|---|---|
| — | Generic TRoot | The root entity for the query |
[Join<TTarget>]
Section titled “[Join<TTarget>]”Declares entity joins. The same attribute used in [Query<T>] works here.
| Property | Type | Description |
|---|---|---|
Via | string | Navigation property or foreign key path |
Multiple [Join] attributes can be stacked on the same view class.
[GroupBy<TEntity>]
Section titled “[GroupBy<TEntity>]”Specifies which properties to group by. Multiple [GroupBy] attributes combine into a composite grouping key.
| Property | Type | Description |
|---|---|---|
Properties | string | Comma-separated property names to group by |
Via | string? | Navigation path for nested grouping |
[From<TEntity>]
Section titled “[From<TEntity>]”Maps a view property from a source entity property. Properties not in a [GroupBy] are treated as aggregations.
| Property | Type | Description |
|---|---|---|
Property | string? | Source property name (defaults to the view property name if omitted) |
Aggregation Inference
Section titled “Aggregation Inference”The source generator infers the aggregation function from context:
| View Property Type | In GroupBy? | Inferred Aggregation |
|---|---|---|
int | No | COUNT |
decimal, double, float | No | SUM |
| Any | Yes | None (grouping key) |
To override the inferred aggregation, use the [Aggregate] attribute:
[From<Order>(Property = "Total")][Aggregate(AggregateFunction.Average)]public decimal AverageOrderValue { get; set; }
[From<Order>(Property = "Total")][Aggregate(AggregateFunction.Max)]public decimal LargestOrder { get; set; }// Generated extension method on DbContext or IQueryablevar summary = await dbContext.QueryView<OrderSummaryView>().ToListAsync();
// With additional filteringvar summary = await dbContext.QueryView<OrderSummaryView>() .Where(v => v.Region == "EU") .OrderByDescending(v => v.TotalRevenue) .ToListAsync();Why This Matters
Section titled “Why This Matters”The view class is the specification. Its name describes what it computes (OrderSummaryView), its properties describe the output shape, and its attributes describe the data source. The generated LINQ is correct by construction — no hand-written GroupBy chains to get wrong.
Combining Projectable, ComputedFilter, and QueryView
Section titled “Combining Projectable, ComputedFilter, and QueryView”These three features compose naturally because they all produce the same artifact: Expression<Func<T, TResult>>.
// Entity with computed properties[Entity<Guid>]public partial class Invoice{ public decimal Subtotal { get; private set; } public decimal TaxRate { get; private set; } public Guid CustomerId { get; private set; }
[Projectable] public decimal TotalWithTax => Subtotal * (1 + TaxRate);
[ComputedFilter] public bool IsHighValue => TotalWithTax > 10_000m;}The [ComputedFilter] on IsHighValue can reference the [Projectable] property TotalWithTax because both are translated to expressions. The source generator resolves the dependency: IsHighValue’s expression inlines the TotalWithTax expression, producing a single SQL-translatable expression tree.
// Use in a query — both expressions translate to SQLvar highValue = await dbContext.Invoices .WhereIsHighValue() .OrderByDescending(InvoiceExpr.TotalWithTax) .ToListAsync();
// Use in a view — aggregation over computed properties[QueryView<Invoice>][GroupBy<Invoice>(Properties = "CustomerId")]public partial class CustomerRevenueSummary{ public Guid CustomerId { get; set; } public decimal TotalRevenue { get; set; } // SUM of TotalWithTax public int InvoiceCount { get; set; } // COUNT}All expressions are translated to SQL by EF Core — no in-memory evaluation, no N+1 queries.
Design Decisions
Section titled “Design Decisions”Why static classes for expressions?
Section titled “Why static classes for expressions?”Expressions must be static fields to be reusable in LINQ. Instance properties cannot be passed to Where(), Select(), or OrderBy() — LINQ requires Expression<Func<T, TResult>>, not Func<T, TResult>. Static fields on a dedicated {Entity}Expr class make them discoverable and keep the entity class clean.
Why specifications from ComputedFilter?
Section titled “Why specifications from ComputedFilter?”Specifications are composable (And, Or, Not) and testable (you can assert what a specification matches without a database). Generating them from [ComputedFilter] means every boolean computed property automatically becomes a reusable, composable query building block.
Why declare views as classes?
Section titled “Why declare views as classes?”The alternative — fluent builder APIs — hides the output shape inside method chains. A class declaration makes the output shape visible at a glance: property names, types, and mappings are all declared together. The class also serves as the DTO for the query result, eliminating a mapping step.
Why infer aggregation from types?
Section titled “Why infer aggregation from types?”Most aggregations follow a predictable pattern: count integers, sum decimals. Inferring from types reduces annotation noise for the common case while [Aggregate] provides an escape hatch for Average, Min, Max, and other functions.