diff --git a/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/ClosureCaptureBenchmark.cs b/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/ClosureCaptureBenchmark.cs
new file mode 100644
index 0000000..c962ff7
--- /dev/null
+++ b/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/ClosureCaptureBenchmark.cs
@@ -0,0 +1,198 @@
+using System.Linq.Expressions;
+using BenchmarkDotNet.Attributes;
+using EntityFrameworkCore.Projectables.Benchmarks.Helpers;
+using EntityFrameworkCore.Projectables.Services;
+using Microsoft.EntityFrameworkCore;
+
+namespace EntityFrameworkCore.Projectables.Benchmarks
+{
+ ///
+ /// Benchmarks two closure-capture scenarios that exercise
+ /// ProjectableExpressionReplacer.VisitMember:
+ ///
+ ///
+ /// -
+ /// Value closure
+ ///
+ /// A local int is captured per iteration (delta = i % 10).
+ /// In Full mode, VisitMember evaluates the closure field on every
+ /// query execution to check whether it is an inlinable IQueryable.
+ /// Previously this used Expression.Lambda(...).Compile().Invoke()
+ /// (one JIT compilation per field per iteration); now it uses
+ /// FieldInfo.GetValue() which is significantly cheaper.
+ ///
+ ///
+ /// -
+ /// Sub-query closure
+ ///
+ /// An IQueryable sub-query is captured in a closure and inlined into
+ /// the outer query at expansion time. Full mode pays this cost on every
+ /// iteration; Limited mode pays it only on the first EF Core cache miss.
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// Three benchmark groups are provided, from least to most accurate:
+ ///
+ /// - *_ValueClosure / *_SubQueryClosure — end-to-end via
+ /// ToQueryString() (includes EF Core cache lookup + SQL formatting)
+ /// - Isolated_Replace_* — calls ProjectableExpressionReplacer.Replace()
+ /// directly with a pre-built expression tree; zero EF Core pipeline overhead
+ ///
+ ///
+ ///
+ /// Run with:
+ /// dotnet run -c Release -- --filter "*ClosureCapture*"
+ ///
+ [MemoryDiagnoser]
+ public class ClosureCaptureBenchmark
+ {
+ // ── DbContexts — created once in GlobalSetup, NOT per benchmark invocation ──
+ // The original benchmark constructed a new TestDbContext inside each benchmark
+ // method, adding ~N ms of DI/EF startup noise to every invocation.
+ private TestDbContext _ctxBaseline = null!;
+ private TestDbContext _ctxFull = null!;
+ private TestDbContext _ctxLimited = null!;
+
+ // ── Sub-queries for Scenario B ────────────────────────────────────────
+ // Stored as fields so DbContext lifetime is controlled by GlobalSetup/Cleanup.
+ // Each benchmark method re-captures them into a *local variable* so the C#
+ // compiler generates a <>c__DisplayClass closure — the exact type that
+ // VisitMember checks for (NestedPrivate + CompilerGenerated).
+ // Capturing a field directly would embed 'this' instead and skip the code path.
+ private IQueryable _subBaseline = null!;
+ private IQueryable _subFull = null!;
+ private IQueryable _subLimited = null!;
+
+ // ── Replacer + pre-built trees for isolated benchmarks ────────────────
+ // The isolated benchmarks call Replace() directly, bypassing:
+ // • EF Core compiled-query cache lookup
+ // • SQL generation and string formatting
+ // • ParameterExtractingExpressionVisitor
+ // This isolates the pure expression-tree-rewrite cost.
+ private ProjectableExpressionReplacer _replacer = null!;
+ private Expression _valueClosureExpr = null!;
+ private Expression _subQueryExpr = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _ctxBaseline = new TestDbContext(false);
+ _ctxFull = new TestDbContext(true, useFullCompatibiltyMode: true);
+ _ctxLimited = new TestDbContext(true, useFullCompatibiltyMode: false);
+
+ _subBaseline = _ctxBaseline.Entities.Where(x => x.Id > 5);
+ _subFull = _ctxFull.Entities.Where(x => x.IdPlus1 > 5);
+ _subLimited = _ctxLimited.Entities.Where(x => x.IdPlus1 > 5);
+
+ // Build expression trees used by the isolated benchmarks.
+ // Using local captures so the compiler generates the <>c__DisplayClass
+ // closures that VisitMember expects.
+ var delta = 5;
+ _valueClosureExpr = _ctxFull.Entities.Select(e => e.IdPlusDelta(delta)).Expression;
+
+ var sub = _ctxFull.Entities.Where(x => x.IdPlus1 > 5);
+ _subQueryExpr = _ctxFull.Entities.Where(e => sub.Any(s => s.Id == e.Id)).Expression;
+
+ _replacer = new ProjectableExpressionReplacer(new ProjectionExpressionResolver(), trackByDefault: false);
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ _ctxBaseline.Dispose();
+ _ctxFull.Dispose();
+ _ctxLimited.Dispose();
+ }
+
+ // ── Scenario A: value closure (int captured per call) ─────────────────
+ // A fixed delta=5 is used so the EF Core compiled-query cache is warm after
+ // BDN's own warmup phase. All results are then per-single-query overhead,
+ // directly comparable with the Isolated_Replace_* µs numbers below.
+
+ /// Baseline: no projectables, int closure captured per call.
+ [Benchmark(Baseline = true)]
+ public string Baseline_ValueClosure()
+ {
+ var delta = 5;
+ return _ctxBaseline.Entities.Select(e => e.Id + delta).ToQueryString();
+ }
+
+ ///
+ /// Full mode: projectable method call with a closure-captured int argument.
+ /// VisitMember evaluates the 'delta' field via reflection on every call.
+ ///
+ [Benchmark]
+ public string Full_ValueClosure()
+ {
+ var delta = 5;
+ return _ctxFull.Entities.Select(e => e.IdPlusDelta(delta)).ToQueryString();
+ }
+
+ ///
+ /// Limited mode: expansion runs only on EF Core cache miss (first call per
+ /// query shape); per-call closure evaluation overhead is near zero.
+ ///
+ [Benchmark]
+ public string Limited_ValueClosure()
+ {
+ var delta = 5;
+ return _ctxLimited.Entities.Select(e => e.IdPlusDelta(delta)).ToQueryString();
+ }
+
+ // ── Scenario B: IQueryable sub-query closure ──────────────────────────
+
+ /// Baseline: no projectables, sub-IQueryable captured in a closure.
+ [Benchmark]
+ public string Baseline_SubQueryClosure()
+ {
+ var sub = _subBaseline;
+ return _ctxBaseline.Entities.Where(e => sub.Any(s => s.Id == e.Id)).ToQueryString();
+ }
+
+ ///
+ /// Full mode: sub-IQueryable captured in a closure; the sub-query itself uses
+ /// a projectable property so both the inlining path and the projectable
+ /// expansion path are exercised on every call.
+ ///
+ [Benchmark]
+ public string Full_SubQueryClosure()
+ {
+ var sub = _subFull;
+ return _ctxFull.Entities.Where(e => sub.Any(s => s.Id == e.Id)).ToQueryString();
+ }
+
+ ///
+ /// Limited mode: expansion occurs only on the first EF Core cache miss;
+ /// subsequent calls hit the compiled-query cache directly.
+ ///
+ [Benchmark]
+ public string Limited_SubQueryClosure()
+ {
+ var sub = _subLimited;
+ return _ctxLimited.Entities.Where(e => sub.Any(s => s.Id == e.Id)).ToQueryString();
+ }
+
+ // ── Isolated: pure ProjectableExpressionReplacer.Replace() cost ───────
+ // No EF Core pipeline. No SQL generation. No string formatting.
+ // Now on the same µs scale as the ToQueryString benchmarks above, so you
+ // can directly subtract to get: EF Core pipeline cost = Full_* − Isolated_Replace_*
+
+ ///
+ /// Pure replacer cost for a value-closure query (no EF Core involved).
+ ///
+ [Benchmark]
+ public Expression Isolated_Replace_ValueClosure()
+ => _replacer.Replace(_valueClosureExpr);
+
+ ///
+ /// Pure replacer cost for a sub-query-closure query, including the recursive
+ /// visit of the inlined sub-query expression (no EF Core involved).
+ ///
+ [Benchmark]
+ public Expression Isolated_Replace_SubQueryClosure()
+ => _replacer.Replace(_subQueryExpr);
+ }
+}
+