Skip to content

Projections and Query Views

Source-generated expression projections, computed filters, and aggregation views — all translatable to SQL.


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.

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}";
}
Invoice.Projectable.g.cs
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 SQL
var overdueInvoices = await dbContext.Invoices
.Where(InvoiceExpr.IsOverdue)
.ToListAsync();
// In projections — server-side evaluation
var dtos = await dbContext.Invoices
.Select(e => new InvoiceDto
{
Id = e.PersistenceId,
Display = InvoiceExpr.DisplayName.Compile()(e) // or use LINQKit
})
.ToListAsync();
// In OrderBy — server-side sorting
var sorted = await dbContext.Invoices
.OrderByDescending(InvoiceExpr.IsOverdue)
.ToListAsync();

The property body must be a single expression that EF Core can translate to SQL:

AllowedNot 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.

Without [Projectable], teams end up with one of two bad patterns:

  1. Client evaluation: Loading entire tables into memory to evaluate computed properties. Works for small datasets, collapses at scale.
  2. 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”

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.

[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;
}

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, discoverable
var overdue = await dbContext.Invoices
.WhereIsOverdue()
.ToListAsync();
// As specification — composable
var spec = InvoiceSpecs.IsOverdue().And(InvoiceSpecs.IsHighValue());
var results = await repository.FindAsync(spec);
// As expression — in projections or custom queries
var dtos = await dbContext.Invoices
.Select(e => new { e.Id, IsOverdue = InvoiceExpr.IsOverdue })
.ToListAsync();

[ComputedFilter] is a superset of [Projectable]:

Feature[Projectable][ComputedFilter]
Generates expressionYesYes
Generates specificationNoYes
Generates extension methodNoYes
Return type restrictionAnybool only

If the property returns a non-boolean type, use [Projectable]. If it returns bool and you want reusable filtering, use [ComputedFilter].

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.


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.

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
}

Marks the class as a query view. TRoot is the primary entity — the starting IQueryable<TRoot> that the generated query begins from.

PropertyTypeDescription
Generic TRootThe root entity for the query

Declares entity joins. The same attribute used in [Query<T>] works here.

PropertyTypeDescription
ViastringNavigation property or foreign key path

Multiple [Join] attributes can be stacked on the same view class.

Specifies which properties to group by. Multiple [GroupBy] attributes combine into a composite grouping key.

PropertyTypeDescription
PropertiesstringComma-separated property names to group by
Viastring?Navigation path for nested grouping

Maps a view property from a source entity property. Properties not in a [GroupBy] are treated as aggregations.

PropertyTypeDescription
Propertystring?Source property name (defaults to the view property name if omitted)

The source generator infers the aggregation function from context:

View Property TypeIn GroupBy?Inferred Aggregation
intNoCOUNT
decimal, double, floatNoSUM
AnyYesNone (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 IQueryable
var summary = await dbContext.QueryView<OrderSummaryView>().ToListAsync();
// With additional filtering
var summary = await dbContext.QueryView<OrderSummaryView>()
.Where(v => v.Region == "EU")
.OrderByDescending(v => v.TotalRevenue)
.ToListAsync();

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 SQL
var 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.


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.

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.

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.

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.