Migrating from Projectables
ExpressiveSharp is the successor to EntityFrameworkCore.Projectables. It keeps the same core concept -- mark members with an attribute, get companion expression trees via source generation -- but is rebuilt with significantly broader C# syntax support, a customizable transformer pipeline, and no coupling to EF Core.
This guide covers a complete step-by-step migration, including automated code fixers that handle most of the mechanical changes.
Why Migrate
- Modern C# syntax in LINQ chains -- Use null-conditional operators (
?.), switch expressions, pattern matching, and more directly in.Where(),.Select(),.OrderBy()viaIExpressiveQueryable<T>. - Broader C# syntax in
[Expressive]members -- Switch expressions, pattern matching (constant, type, relational, logical, property, positional), string interpolation, tuples, and constructor projections all work out of the box. - Not EF Core specific -- Works standalone with any LINQ provider, or use
ExpressionPolyfill.Createto build expression trees without a queryable. - More accurate code generation -- The source generator now analyzes code at the semantic level rather than rewriting syntax.
- Customizable transformers -- The
IExpressionTreeTransformerinterface lets you plug in your own expression tree transformations. - Simpler configuration -- No
CompatibilityMode.UseExpressives()handles all the EF Core defaults automatically.
Package Changes
| Old Package | New Package | Notes |
|---|---|---|
EntityFrameworkCore.Projectables | ExpressiveSharp.EntityFrameworkCore | Direct replacement -- includes core as a dependency |
EntityFrameworkCore.Projectables.Abstractions | (included above) | No longer a separate package |
EntityFrameworkCore.Projectables.Generator | (included above) | Generator ships as an analyzer inside the package |
# Remove old packages
dotnet remove package EntityFrameworkCore.Projectables
dotnet remove package EntityFrameworkCore.Projectables.Abstractions
# Add new package
dotnet add package ExpressiveSharp.EntityFrameworkCoreAutomated Migration with Code Fixers
ExpressiveSharp.EntityFrameworkCore includes built-in Roslyn analyzers that detect old Projectables API usage and offer automatic code fixes:
| Diagnostic | Detects | Auto-fix |
|---|---|---|
EXP1001 | [Projectable] attribute | Renames to [Expressive], removes obsolete properties |
EXP1002 | UseProjectables(...) call | Replaces with UseExpressives() |
EXP1003 | using EntityFrameworkCore.Projectables* | Replaces with using ExpressiveSharp* |
Automated bulk fix
After installing the package, build your solution -- warnings will appear on all Projectables API usage. Use Fix All in Solution (lightbulb menu in your IDE) to apply all fixes at once.
Namespace Changes
| Old | New |
|---|---|
using EntityFrameworkCore.Projectables; | using ExpressiveSharp; |
using EntityFrameworkCore.Projectables.Extensions; | using ExpressiveSharp; |
using EntityFrameworkCore.Projectables.Infrastructure; | (removed) |
The EF Core extension methods (UseExpressives, AsExpressiveDbSet) live in the Microsoft.EntityFrameworkCore namespace, which you likely already import.
API Changes
Attribute Rename
// Before
[Projectable]
public double Total => Price * Quantity;
// After
[Expressive]
public double Total => Price * Quantity;DbContext Configuration
// Before
options.UseSqlServer(connectionString)
.UseProjectables(opts =>
{
opts.CompatibilityMode(CompatibilityMode.Full);
});
// After -- no compatibility mode; optional callback for plugins
options.UseSqlServer(connectionString)
.UseExpressives();An optional configuration callback is available for registering plugins:
options.UseSqlServer(connectionString)
.UseExpressives(opts => opts.AddPlugin(new MyPlugin()));UseExpressives() automatically registers six transformers as global defaults (ReplaceThrowWithDefault, ConvertLoopsToLinq, RemoveNullConditionalPatterns, FlattenTupleComparisons, FlattenConcatArrayCalls, FlattenBlockExpressions), sets up the query compiler decorator, and configures model conventions. ReplaceThrowWithDefault can be opted out via o => o.PreserveThrowExpressions().
Null-Conditional Handling
Projectables had a three-value enum controlling null-conditional behavior:
NullConditionalRewriteSupport | Behavior |
|---|---|
None | Null-conditional operators not allowed |
Ignore | A?.B becomes A.B (strip the null check) |
Rewrite | A?.B becomes A != null ? A.B : default |
ExpressiveSharp always generates the faithful ternary pattern (A != null ? A.B : default). The RemoveNullConditionalPatterns transformer, applied globally by UseExpressives(), strips it before queries reach the database. No per-member configuration needed.
// Before
[Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Rewrite)]
public string? CustomerName => Customer?.Name;
// After -- just remove the property; UseExpressives() handles it globally
[Expressive]
public string? CustomerName => Customer?.Name;INFO
Both the old Ignore and Rewrite behaviors converge to the same result in ExpressiveSharp. The transformer strips the explicit null check, and the database handles null propagation natively via LEFT JOIN.
Changed and Removed Properties
| Old Property | Migration |
|---|---|
UseMemberBody = "SomeMethod" | Replace with [ExpressiveProperty] or plain [ExpressiveFor]. See Migrating UseMemberBody below. |
AllowBlockBody = true | Keep -- block bodies remain opt-in. Set per-member or globally via Expressive_AllowBlockBody MSBuild property. |
ExpandEnumMethods = true | Remove -- enum method expansion is enabled by default. |
CompatibilityMode.Full / .Limited | Remove -- only the full approach exists. |
Migrating UseMemberBody
In Projectables, UseMemberBody let you point one member's expression body at another member -- typically to work around syntax limitations or to provide an expression-tree-friendly alternative for projection middleware (HotChocolate, AutoMapper) that required a writable target.
ExpressiveSharp offers two replacement shapes, depending on your scenario:
[ExpressiveProperty]-- the closest analogue: you write only the formula; the generator synthesizes the settable target property on apartialclass. The property participates in projection middleware because it has aninitaccessor. Best fit when you want a dedicated property backed purely by an expression.- Plain
[ExpressiveFor]-- when the target property already exists (or lives on an external type you do not own). No property is synthesized; the stub maps to an existing member.
Pick based on whether you want the generator to declare the target property for you.
Option A -- [ExpressiveProperty] (formula-only, property is generated):
// Before (Projectables)
[Projectable(UseMemberBody = nameof(FullNameProjection))]
public string FullName { get; init; }
private string FullNameProjection => $"{LastName}, {FirstName}";
// After (ExpressiveSharp) -- partial class, stub only; FullName is generated
public partial class Customer
{
[ExpressiveProperty("FullName")]
private string FullNameExpression => $"{LastName}, {FirstName}";
}The generator picks between a coalesce shape (non-nullable targets) and a ternary+flag shape (nullable targets) so materialized null stays distinguishable from "not materialized." See the [ExpressiveProperty] reference and the Projection Middleware recipe.
Target name must be a string literal
The target property does not exist during the generator's pass, so nameof(FullName) fails to resolve. Always pass the name as a string literal: [ExpressiveProperty("FullName")].
Option B -- plain [ExpressiveFor] (target property already exists, or lives on an external type):
Scenario 1: Same-type member with an alternative body
Use the co-located form: a property stub on the same class combined with the single-argument attribute. this is the receiver naturally -- the migration reads almost identically to UseMemberBody.
// Before (Projectables)
public string FullName => $"{FirstName} {LastName}".Trim().ToUpper();
[Projectable(UseMemberBody = nameof(FullNameProjection))]
public string FullName => ...;
private string FullNameProjection => $"{FirstName} {LastName}";
// After (ExpressiveSharp)
using ExpressiveSharp.Mapping;
public string FullName => $"{FirstName} {LastName}".Trim().ToUpper();
[ExpressiveFor(nameof(FullName))]
private string FullNameExpression => $"{FirstName} {LastName}";Scenario 2: External/third-party type methods
[ExpressiveFor] also enables a use case that UseMemberBody never supported -- providing expression tree bodies for methods on types you do not own:
db
.LineItems
.Where(i => Math.Clamp((double)i.UnitPrice, 20, 100) > 50)
// Setup
public static class MathExpressives
{
// Make Math.Clamp usable in EF Core queries
[ExpressiveSharp.Mapping.ExpressiveFor(typeof(Math), nameof(Math.Clamp))]
static double Clamp(double value, double min, double max)
=> value < min ? min : (value > max ? max : value);
}SELECT "l"."Id", "l"."OrderId", "l"."ProductId", "l"."Quantity", "l"."UnitPrice"
FROM "LineItems" AS "l"
WHERE CASE
WHEN CAST("l"."UnitPrice" AS REAL) < 20.0 THEN 20.0
WHEN CAST("l"."UnitPrice" AS REAL) > 100.0 THEN 100.0
ELSE CAST("l"."UnitPrice" AS REAL)
END > 50.0Scenario 3: Constructors
using ExpressiveSharp.Mapping;
[ExpressiveForConstructor(typeof(OrderDto))]
static OrderDto CreateDto(int id, string name)
=> new OrderDto { Id = id, Name = name };Key differences from UseMemberBody:
UseMemberBody (Projectables) | [ExpressiveFor] (ExpressiveSharp) | |
|---|---|---|
| Scope | Same type only | Same type or any accessible type (including external/third-party) |
| Syntax | Property on [Projectable] | Separate attribute on a stub method |
| Target member | Must be in the same class | Co-located (single-arg form, this is receiver) or cross-type (two-arg form) |
| Namespace | EntityFrameworkCore.Projectables | ExpressiveSharp.Mapping |
| Constructors | Not supported | [ExpressiveForConstructor] |
TIP
Many UseMemberBody use cases in Projectables existed because of syntax limitations. Since ExpressiveSharp supports switch expressions, pattern matching, string interpolation, and block bodies, you may be able to put [Expressive] directly on the member and delete the helper entirely.
MSBuild Properties
| Old Property | Migration |
|---|---|
Projectables_NullConditionalRewriteSupport | Remove -- UseExpressives() handles this globally |
Projectables_ExpandEnumMethods | Remove -- always enabled |
Projectables_AllowBlockBody | Rename to Expressive_AllowBlockBody |
The InterceptorsNamespaces MSBuild property needed for method interceptors is set automatically.
Breaking Changes
Namespace change -- All
EntityFrameworkCore.Projectables.*namespaces becomeExpressiveSharp.*. This is a project-wide find-and-replace (or use theEXP1003code fixer).Attribute rename --
[Projectable]becomes[Expressive](use theEXP1001code fixer).NullConditionalRewriteSupportenum removed -- ExpressiveSharp always generates faithful null-conditional ternaries.UseExpressives()globally registers theRemoveNullConditionalPatternstransformer to strip them.ProjectableOptionsBuilderreplaced byExpressiveOptionsBuilder--UseProjectables(opts => { ... })becomesUseExpressives()(orUseExpressives(opts => opts.AddPlugin(...))for plugin registration).UseMemberBodyproperty removed -- Replaced by[ExpressiveFor]fromExpressiveSharp.Mapping.CompatibilityModeremoved -- ExpressiveSharp always uses the full query-compiler-decoration approach.AllowBlockBodyretained (opt-in) -- Block bodies requireAllowBlockBody = trueper-member or the MSBuild propertyExpressive_AllowBlockBody.UseExpressives()registersFlattenBlockExpressionsfor runtime.MSBuild properties
Projectables_*removed -- Remove anyProjectables_NullConditionalRewriteSupport,Projectables_ExpandEnumMethods, orProjectables_AllowBlockBodyfrom.csproj/Directory.Build.props.Package consolidation -- Remove all old packages and install
ExpressiveSharp.EntityFrameworkCore.Target framework -- ExpressiveSharp targets .NET 8.0, .NET 9.0, and .NET 10.0. If you are on .NET 6 or 7, you will need to upgrade.
Feature Comparison
| Feature | Projectables | ExpressiveSharp |
|---|---|---|
| Attribute | [Projectable] | [Expressive] |
| Expression-bodied properties/methods | Yes | Yes |
| Block-bodied methods | Opt-in | Opt-in |
Null-conditional ?. | NullConditionalRewriteSupport enum | Always emitted; UseExpressives() strips for EF Core |
| Switch expressions | No | Yes |
| Pattern matching | No | Yes (constant, type, relational, logical, property, positional) |
| String interpolation | No | Yes |
| Tuple literals | No | Yes |
| Constructor projections | No | Yes |
| Inline expression creation | No | ExpressionPolyfill.Create(...) |
| Modern syntax in LINQ chains | No | Yes (IExpressiveQueryable<T>) |
| Custom transformers | No | IExpressionTreeTransformer interface |
ExpressiveDbSet<T> | No | Yes |
| External member mapping | UseMemberBody (same type only) | [ExpressiveFor] (any type) |
| SQL window functions | No | Yes (RelationalExtensions package) |
| EF Core specific | Yes | No -- works standalone |
| Compatibility modes | Full / Limited | Full only (simpler) |
| Code generation approach | Syntax tree rewriting | Semantic (IOperation) analysis |
| Target frameworks | .NET 6+ | .NET 8 / .NET 9 / .NET 10 |
New Features Available After Migration
After migrating, you gain access to features that Projectables never had. Here are some highlights:
Modern Syntax in LINQ Chains
Use IExpressiveQueryable<T> or ExpressiveDbSet<T> to write LINQ queries with modern C# syntax:
db
.Orders
.Where(o => o.Customer.Email != null)
.Select(o => new { o.Id, Name = o.Customer.Name ?? "Unknown" })SELECT "o"."Id", "c"."Name"
FROM "Orders" AS "o"
INNER JOIN "Customers" AS "c" ON "o"."CustomerId" = "c"."Id"
WHERE "c"."Email" IS NOT NULLSee Modern Syntax in LINQ Chains.
ExpressionPolyfill.Create
Create expression trees inline without needing an attribute:
db
.Customers
.Where(ExpressionPolyfill.Create((Customer c) => c.Email?.Length > 5))SELECT "c"."Id", "c"."Country", "c"."Email", "c"."JoinedAt", "c"."Name"
FROM "Customers" AS "c"
WHERE length("c"."Email") > 5Switch Expressions and Pattern Matching
db
.Products
.Select(p => new { p.Name, Grade = p.GetGrade() })
// Setup
public static class ProductExt
{
[Expressive]
public static string GetGrade(this Product p) => p.ListPrice switch
{
>= 100m => "Premium",
>= 50m => "Standard",
_ => "Budget",
};
}SELECT "p"."Name", CASE
WHEN ef_compare("p"."ListPrice", '100.0') >= 0 THEN 'Premium'
WHEN ef_compare("p"."ListPrice", '50.0') >= 0 THEN 'Standard'
ELSE 'Budget'
END AS "Grade"
FROM "Products" AS "p"db
.LineItems
.Where(i => i.IsSpecialLine())
// Setup
public static class LineItemExt
{
[Expressive]
public static bool IsSpecialLine(this LineItem i) => i is { Quantity: > 100, UnitPrice: >= 50m };
}SELECT "l"."Id", "l"."OrderId", "l"."ProductId", "l"."Quantity", "l"."UnitPrice"
FROM "LineItems" AS "l"
WHERE "l"."Quantity" > 100 AND ef_compare("l"."UnitPrice", '50.0') >= 0See Scoring and Classification.
Constructor Projections
db
.Orders
.Select(o => OrderSummaryBuilder.From(o))
// Setup
public sealed class OrderSummary
{
public int Id { get; init; }
public decimal Total { get; init; }
}
public static class OrderSummaryBuilder
{
[Expressive]
public static OrderSummary From(Order o) => new OrderSummary
{
Id = o.Id,
Total = o.Items.Sum(i => i.UnitPrice * i.Quantity),
};
}SELECT "o"."Id", (
SELECT COALESCE(ef_sum(ef_multiply("l"."UnitPrice", CAST("l"."Quantity" AS TEXT))), '0.0')
FROM "LineItems" AS "l"
WHERE "o"."Id" = "l"."OrderId") AS "Total"
FROM "Orders" AS "o"See DTO Projections with Constructors.
External Member Mapping
db
.LineItems
.Where(i => Math.Abs(i.Quantity) > 0)
// Setup
public static class MathExpressives
{
[ExpressiveSharp.Mapping.ExpressiveFor(typeof(Math), nameof(Math.Abs))]
static int Abs(int value) => value < 0 ? -value : value;
}SELECT "l"."Id", "l"."OrderId", "l"."ProductId", "l"."Quantity", "l"."UnitPrice"
FROM "LineItems" AS "l"
WHERE CASE
WHEN "l"."Quantity" < 0 THEN -"l"."Quantity"
ELSE "l"."Quantity"
END > 0Custom Transformers
db
.LineItems
.Select(i => new { i.Id, Adjusted = i.AdjustedTotal() })
// Setup
public class MyTransformer : ExpressiveSharp.IExpressionTreeTransformer
{
public Expression Transform(Expression expression)
{
return expression; // your custom transformation
}
}
public static class LineItemExt
{
[Expressive(Transformers = new[] { typeof(MyTransformer) })]
public static decimal AdjustedTotal(this LineItem i) => i.UnitPrice * i.Quantity * 1.1m;
}SELECT "l"."Id", ef_multiply(ef_multiply("l"."UnitPrice", CAST("l"."Quantity" AS TEXT)), '1.1') AS "Adjusted"
FROM "LineItems" AS "l"SQL Window Functions
using ExpressiveSharp.EntityFrameworkCore.RelationalExtensions.WindowFunctions;
var ranked = dbContext.Orders.Select(o => new
{
o.Id,
Rank = WindowFunction.Rank(
Window.PartitionBy(o.CustomerId)
.OrderByDescending(o.PlacedAt))
});See Window Functions and Ranking.
Quick Migration Checklist
Before you begin
Make sure you have a clean working tree (commit or stash your changes) and a passing test suite on the Projectables codebase before starting the migration.
- Remove all
EntityFrameworkCore.Projectables*NuGet packages - Add
ExpressiveSharp.EntityFrameworkCore - Build -- the built-in migration analyzers will flag all Projectables API usage
- Use Fix All in Solution for each diagnostic (
EXP1001,EXP1002,EXP1003) to auto-fix - Remove any
Projectables_*MSBuild properties from.csproj/Directory.Build.props - Replace any
UseMemberBodyusage with[ExpressiveFor](see Migrating UseMemberBody) - Remove any
ExpandEnumMethods,NullConditionalRewriteSupport, orCompatibilityModesettings - Build again and fix any remaining compilation errors
- Run your test suite to verify query behavior is unchanged
- Optionally adopt new features:
ExpressiveDbSet<T>, switch expressions, pattern matching,ExpressionPolyfill.Create
See Also
- Computed Entity Properties -- the foundational recipe
- Modern Syntax in LINQ Chains -- the biggest new capability
- External Member Mapping -- replaces
UseMemberBody
