Data Layer at Scale — series:
EF Core 10 AOT: what's broken (this article)
Dapper.AOT broken on .NET 10 (link TBD)
Extern alias benchmarking (link TBD)
Cold start vs density (link TBD)
Allocation numbers for k8s (link TBD)
The benchmark lied (link TBD)
The data layer as a line item (link TBD)
Measure, then migrate (link TBD)
The Short Answer
EF Core 10's NativeAOT support is officially marked experimental, not for production. Having validated this hands-on — by restructuring a real data layer until every documented constraint was satisfied — I can confirm: the label is accurate, and the failure mode is harder than you'd expect.
This article documents the three bugs that block production use, where workarounds exist, and what to use instead today.
The Intended Path
Microsoft's documented approach to EF Core AOT is precompiled queries via the dotnet ef CLI:
dotnet ef dbcontext optimize --precompile-queries --nativeaotThis generates a static compiled model and precompiled query interceptors that replace runtime LINQ translation. The goal: no expression trees, no Roslyn at runtime, no AOT violations.
In principle, clean. In practice, three bugs stop you.
Bug 1: Any Indirection Is "Dynamic LINQ" (efcore#32727)
The precompiler classifies a LINQ query as "Dynamic LINQ" — and rejects it — if the DbSet is accessed through any abstraction: an extension method, a generic wrapper, or an abstract base class.
This fails at precompile time:
// Extension method wrapping the set
public static IQueryable<Order> ActiveOrders(this AppDbContext db)
=> db.Orders.Where(o => !o.IsDeleted);
// Using it
var results = await _db.ActiveOrders().ToListAsync();This also fails:
// Generic repository base
public abstract class RepositoryBase<T> where T : class
{
protected abstract DbSet<T> GetSet();
public IQueryable<T> All() => GetSet();
}The only accepted form:
// Direct property access, no indirection
var results = await _db.Orders
.Where(o => !o.IsDeleted)
.ToListAsync();Every query must reference the DbSet property directly on the concrete DbContext type. This eliminates virtually all real-world repository patterns — and any framework that routes queries through a generic seam.
Bug 2: DbContext as Field or Parameter (efcore#35887)
Even with direct DbSet access, the precompiler misclassifies the context when it's reached through a field or a captured constructor parameter.
Fails:
public class OrderService(AppDbContext db)
{
public async Task<Order?> GetByIdAsync(int id)
=> await db.Orders.FindAsync(id); // captured primary-ctor parameter
}Workaround — copy to a local variable first:
public async Task<Order?> GetByIdAsync(int id)
{
var local = _db; // local copy
return await local.Orders.FindAsync(id);
}Awkward but functional. It satisfies the precompiler's symbol analysis. Add it to every method that queries the database.
Bug 3: Parameterized Queries Throw UnreachableException (Blocking, No Workaround)
This is the blocker. After restructuring an entire data layer to satisfy bugs 1 and 2 — direct DbSet access, local-variable copies, even the exact doc-prescribed shape (a static method taking the context as its first parameter, one expression rooted in db.<DbSet>) — the precompiler throws:
System.Diagnostics.UnreachableException: IdentifierName of type ParameterSymbolThis exception fires on every query with a parameter: an id, a CancellationToken, a max-count limit. Parameterless queries precompile cleanly. Any query that touches a method parameter does not.
There is no configuration to disable this. There is no workaround. (Related trail: dotnet/efcore#35494.)
Example of a query that triggers it:
public static Task<Order?> GetById(AppDbContext db, int id, CancellationToken ct)
=> db.Orders
.Where(o => o.Id == id) // 'id' is a ParameterSymbol — crashes the precompiler
.FirstOrDefaultAsync(ct);The same code under JIT — no precompile step — works perfectly.
Bonus: MSBuild Precompile Breaks with Source Generators
If you use the EF Tasks package (Microsoft.EntityFrameworkCore.Tasks) to run precompilation inside MSBuild, it fails when source generators are present in the same solution: the MSBuild Workspace does not run them, so generated types are missing and you get CS0234 on build.
Workaround: skip the MSBuild integration. Build first, then run the CLI directly:
dotnet build
dotnet ef dbcontext optimize --precompile-queries --nativeaot --output-dir CompiledModelsThe MSBuild path is a convenience that doesn't survive mixed-generator solutions — which today means most solutions of any size.
Quick Reference
| # | Bug | Tracking | Has Workaround |
|---|---|---|---|
| 1 | Indirection (extension methods, generics, abstract base) classified as Dynamic LINQ | efcore#32727 | Yes — query DbSet directly |
| 2 | DbContext via field / captured parameter misclassified | efcore#35887 | Yes — copy to a local variable |
| 3 | UnreachableException on any parameterized query | efcore#35494 trail | No |
| 4 | MSBuild precompile breaks with source generators in the solution | — | Yes — run the CLI after build |
Practical Result
A fully restructured project — direct queries, local-copy pattern throughout, MSBuild workaround applied — runs correctly under JIT: a clean CRUD suite passes 13/13 over HTTP. The code compiles. Everything looks right.
AOT publish fails. Bug 3 fires on the first parameterized query the precompiler encounters. The project cannot produce an AOT binary.
This is not pessimistic speculation — it is a reproducible wall, hit after doing everything the documentation asks.
What to Use Instead
Based on my experience for NativeAOT services today: raw ADO.NET. No expression trees, no precompilation step, no AOT unsafety. In our measurements the same service published as a 13 MB native binary, held 37 MB RSS under sustained load, and delivered ~2.2× the requests-per-core of the EF variant. (Dapper is not the middle path here — its AOT generator is separately broken on .NET 10; see the companion article.)
For rich LINQ and productivity: EF Core under JIT. It remains one of the most capable ORMs anywhere. JIT-compiled net10 targets work without limitation — full query composition, change tracking, migrations.
The architectural hedge: put the data access behind a seam (interfaces the business logic consumes), so the EF-vs-ADO decision is per-service and reversible. When EF's precompiler eventually leaves experimental, services behind the seam gain AOT with zero business-logic changes. That's how we structured our framework — the bet stays open either way.
The engineering-leadership read: don't gate an AOT roadmap on EF Core. The "experimental" label is load-bearing and the hard bug has no workaround. Plan AOT around a provider that works today, keep EF where its productivity pays, and make the boundary between them a deliberate architectural seam rather than a rewrite.
Validated against .NET 10 (net10.0), Microsoft.EntityFrameworkCore.Sqlite/Design/Tasks 10.0.9, on x64. Bug numbers reference the dotnet/efcore repository.
Part of the Data Layer at Scale series — research notes from building Turboservices, a .NET framework for rebuilding legacy systems onto AI-native delivery rails.
About the author — Vitalii Honcharuk is a hands-on Distinguished Engineer, Architect and CTO with 15+ years of experience building frameworks and high-reliability backend systems for enterprises and startups. His current work, Turboservices, turns engineering discipline into compile-time guarantees: unsafe states refuse to build, quality gates are code, and every architectural decision ships with measured evidence.

Join the conversation! Your thoughts help the community grow.