C # & . N E T
Delegates, Lambdas, Callbacks, Events & Expression Trees
Treating code as data: from first principles to production
A single mental model that unlocks LINQ, EF Core, RavenDB, Hot Chocolate, MediatR, AutoMapper and FluentValidation.
Includes closure internals, event-leak forensics, expression-tree construction, a production playbook, a code-review checklist, 17 interview questions and 12 exercises.
.NET 8 / .NET 10 C# 12 Senior / Architect level
Contents
Delegates, Lambdas, Callbacks, Events & Expression Trees
A working engineer's guide to treating code as data in C# / .NET
Part 0 — The one idea behind all five
Part 1 — Delegates: the foundation
1.1 The problem, before we solve it 1.2 Your first delegate
1.3 What the compiler actually generates 1.4 Multicast: one delegate, many methods 1.5 Why -= sometimes silently does nothing 1.6 You rarely declare delegates any more: Func, Action, Predicate 1.7 Variance: why Func<object, string> fits where Func<string, object> is wanted 1.8 Delegate vs interface — the honest comparison
Part 2 — Lambdas & closures
2.1 Lambdas are just syntax for "a method, written here" 2.2 Closures: the part that actually confuses people
2.3 The loop-variable trap 2.4 The cost of a lambda, and how to avoid paying it 2.5 Closures extend object lifetime — a real leak source
Part 3 — Callbacks: the usage pattern
3.1 There is no callback keyword 3.2 Strategy callbacks: the everyday case 3.3 Async callbacks: the history that explains async/await 3.4 IProgress<T> — the right way to report progress
3.5 Cancellation callbacks 3.6 Callback pitfalls checklist
Part 4 — Events: delegates with a bouncer
4.1 The problem events solve 4.2 What the compiler generates 4.3 The canonical .NET event pattern 4.4 The ?.Invoke idiom and the race it fixes 4.5 The #1 production bug: event handler memory leaks 4.6 Exceptions and multicast, revisited
4.7 Async event handlers — the async void trap 4.8 When NOT to use C# events
Part 5 — Expression trees: code as data
5.1 What the tree actually looks like 5.2 Back to executable code: .Compile() 5.3 The payoff: IQueryable<T> vs IEnumerable<T> 5.4 Building expressions by hand
5.5 Combining predicates: a real PredicateBuilder 5.6 ExpressionVisitor: rewriting trees
5.7 Expressions as a fast alternative to reflection 5.8 The limits of C#-compiled expression trees 5.9 One more distinction: expression trees vs nameof vs reflection
Part 6 — How the five fit together
6.1 One request, all five 6.2 The decision table 6.3 The five-second version, for interviews
Part 7 — Production playbook
7.1 Specification pattern — reusable, translatable business rules 7.2 Dynamic filtering for list APIs and GraphQL
7.3 Pipeline behaviours: the delegate chain you already use 7.4 Multi-tenancy: expressions applied to every query
7.5 Domain events: in-process vs durable 7.6 Progress and cancellation for long-running Functions 7.7 Performance rules of thumb
7.8 Code review checklist
Part 8 — Gotchas cheat sheet
Part 9 — Interview questions and strong answers
Part 10 — Exercises
Closing thought
Delegates, Lambdas, Callbacks, Events & Expression Trees
A working engineer's guide to treating code as data in C# / .NET
Part 0 — The one idea behind all five
Every one of these five features exists to solve one problem:
"I want to hand a piece of behaviour to someone else, so they decide when — or whether — to run it."
That's it. Everything else is packaging.
Normally, data flows through your program and code sits still. A string , an int , an Order — those move around. Methods don't. You can't put a method in a list, pass it as an argument, or store it in a field.
Unless you wrap it. And the five things you're confused about are just five different wrappers, each with a different job:
| Concept | What it actually is | One-line job |
|---|---|---|
| Delegate | A type — the shape of a method | "Any method that takes an Order and returns bool fits here" |
| Lambda | A value — an unnamed method written inline | "Here's the actual method, defined right where I need it" |
| Callback | A usage pattern — not a language feature | "Hold onto this and call it later / when something finishes" |
| Event | A guarded delegate field | "You may subscribe and unsubscribe. Only I may raise it." |
| Expression tree | Code as data — a parsed, inspectable object graph | "Don't run this. Read it, and translate it into something else (SQL, RQL, a GraphQL filter)" |
The master analogy: the restaurant
Hold this in your head for the entire document.
- A delegate type is a job posting: "Wanted: someone who takes raw ingredients and returns a plated dish." It describes the shape of the work. It is not a person.
- A method or lambda is the actual chef. A specific person who can do that job.
- A delegate instance is the chef's phone number, already dialled and pointed at one specific chef. Call it, and that chef cooks.
- A callback is you giving the kitchen your table number: "Do the work, and buzz me when it's ready."
- An event is the restaurant's newsletter. Diners subscribe and unsubscribe freely, but only the restaurant can send an issue. No customer can send a newsletter on the restaurant's behalf.
- An expression tree is the written recipe instead of the cooked dish. You can't eat a recipe — but you can read it, check it for peanuts, translate it into French, or fax it to a kitchen in another country that cooks in a completely different style. This is how
Where(o => o.Total> 100)becomes SQL.
That last one is the distinction that unlocks LINQ, EF Core, RavenDB and Hot Chocolate. Keep it close.
The dependency ladder
They build on each other in a strict order. Learn them in this order and the confusion disappears:
Expression tree ──.Compile()──► Delegate instance
│
Lambda / method group ──────────────────┤
│
▼
Delegate type
│
┌───────────────┴───────────────┐
▼ ▼
used as a "callback" wrapped as an "event"
(a usage pattern) (add/remove encapsulation)
A lambda is not a delegate. A lambda is syntax that the compiler turns into either a delegate or an expression tree, depending on what the target type is. Same source text, two completely different outcomes. That single fact explains 80% of the confusion around expression trees.
Part 1 — Delegates: the foundation
1.1 The problem, before we solve it
Say you're processing shipment records. You need to filter them.
public List<Shipment> GetHeavyShipments(List<Shipment> shipments)
{
var result = new List<Shipment>();
foreach (var s in shipments)
if (s.WeightKg > 1000) result.Add(s);
return result;
}
public List<Shipment> GetOverdueShipments(List<Shipment> shipments)
{
var result = new List<Shipment>();
foreach (var s in shipments)
if (s.DueDate < DateTime.UtcNow) result.Add(s);
return result;
}
Nine methods later, you notice the loop is identical every time. The only thing that changes is one line — the condition. You want to parameterise that line. But a condition is code, and you can't pass code as a parameter.
Unless you give that code a type.
1.2 Your first delegate
// A delegate declaration. This is a TYPE declaration, like declaring a class. // Read it as: "ShipmentFilter is the name for any method that takes a // Shipment and returns a bool." public delegate bool ShipmentFilter(Shipment shipment);
Now the loop is written once:
public List<Shipment> Filter(List<Shipment> shipments, ShipmentFilter predicate)
{
var result = new List<Shipment>();
foreach (var s in shipments)
if (predicate(s)) // <-- invoking the delegate
result.Add(s);
return result;
}
And you supply behaviour at the call site:
static bool IsHeavy(Shipment s) => s.WeightKg > 1000; static bool IsOverdue(Shipment s) => s.DueDate < DateTime.UtcNow; var heavy = Filter(shipments, IsHeavy); // method group — no () var overdue = Filter(shipments, IsOverdue); var both = Filter(shipments, s => s.WeightKg > 1000 && s.DueDate < DateTime.UtcNow);
Note IsHeavy with no parentheses. IsHeavy() means call it now. IsHeavy means the method itself. That's called a method group conversion — the compiler sees you need a ShipmentFilter , checks that IsHeavy has a matching signature, and builds a delegate instance pointing at it.
Mental model: IsHeavy() = "cook the dish now." IsHeavy = "here's the chef's number."
1.3 What the compiler actually generates
A delegate declaration is not magic syntax. The compiler emits a real sealed class:
// Roughly what `public delegate bool ShipmentFilter(Shipment s);` becomes:
public sealed class ShipmentFilter : System.MulticastDelegate
{
public ShipmentFilter(object target, IntPtr method) { ... }
public bool Invoke(Shipment s) { ... } // the runtime implements this
// (BeginInvoke/EndInvoke existed on .NET Framework; they throw on .NET Core+)
}
Two fields matter, inherited from Delegate :
Target— the object instance the method should be called on (nullfor a static method).
Method— aMethodInfo/function pointer for the method itself.
So a delegate instance is literally an object reference + a method pointer, boxed together. That pairing is the entire trick, and it's why delegates can carry state that plain C function pointers cannot.
ShipmentFilter f = someService.IsValid; // instance method Console.WriteLine(f.Target); // the someService instance Console.WriteLine(f.Method.Name); // "IsValid" ShipmentFilter g = IsHeavy; // static method Console.WriteLine(g.Target is null); // True
That Target reference is the seed of every event-related memory leak you will ever hit. A live delegate keeps its target object alive. Park that thought; we return to it in Part 4.
1.4 Multicast: one delegate, many methods
Delegate derives from MulticastDelegate , which means every delegate instance holds an invocation list — potentially more than one method.
Action<string> log = msg => Console.WriteLine($"[console] {msg}");
log += msg => File.AppendAllText("app.log", msg); // now two
log += msg => _telemetry.Track(msg); // now three
log("Shipment 4471 dispatched"); // all three run, in subscription order
+= compiles to Delegate.Combine , -= to Delegate.Remove .
Three rules you must know for interviews and for production:
Rule 1 — Delegates are immutable. += doesn't modify the existing delegate; it creates a new delegate object with a longer list and reassigns your variable. This matters for thread safety, and it explains Rule 3.
Rule 2 — With a non-void return type, you only get the LAST result.
Func<int> f = () => 1; f += () => 2; f += () => 3; Console.WriteLine(f()); // 3. The 1 and the 2 ran, then were discarded.
If you need every result, walk the list yourself:
foreach (Func<int> single in f.GetInvocationList().Cast<Func<int>>())
Console.WriteLine(single()); // 1, 2, 3
Rule 3 — An exception in one subscriber aborts the rest.
Action notify = () => Console.WriteLine("A");
notify += () => throw new InvalidOperationException("B blew up");
notify += () => Console.WriteLine("C"); // NEVER RUNS
notify(); // prints A, then throws. C is silently skipped.
This is a genuine production landmine. If subscribers are independent and must all run, iterate and isolate:
var errors = new List<Exception>();
foreach (Action handler in notify.GetInvocationList())
{
try { handler(); }
catch (Exception ex) { errors.Add(ex); }
}
if (errors.Count > 0) throw new AggregateException(errors);
1.5 Why -= sometimes silently does nothing
service.OnCompleted += () => Console.WriteLine("done");
service.OnCompleted -= () => Console.WriteLine("done"); // NO-OP. Still subscribed.
Delegate equality compares Target and Method . Two identically-typed lambdas compile to two different generated methods, so they're never equal. Same problem in JavaScript's removeEventListener , same fix:
Action handler = () => Console.WriteLine("done"); // keep the reference
service.OnCompleted += handler;
// ...later...
service.OnCompleted -= handler; // works
Rule for production: if you will ever need to unsubscribe, never subscribe with an inline lambda. Store it in a field, or use a named method.
1.6 You rarely declare delegates any more: Func , Action , Predicate
Since .NET 3.5 the BCL ships generic delegate types covering almost every signature, so custom delegate declarations are now the exception.
Action // void, no params Action<T1> // void, 1 param ... up to 16 Func<TResult> // returns TResult, no params Func<T1, TResult> // returns TResult, 1 param ... up to 16 Predicate<T> // == Func<T, bool> (legacy, pre-generics-era API) Comparison<T> // == Func<T, T, int> (used by List<T>.Sort) Converter<TIn, TOut> // == Func<TIn, TOut> (legacy) EventHandler<TArgs> // void (object? sender, TArgs e)
The last type parameter of Func is always the return type. Func<Shipment, decimal, bool> takes a Shipment and a decimal , returns bool .
So our filter becomes idiomatic modern C#:
public List<Shipment> Filter(List<Shipment> shipments, Func<Shipment, bool> predicate)
...which is exactly the signature Enumerable.Where uses. You've been using delegates on every LINQ call for years.
When should you still declare a custom delegate?
- You need
ref,out,in, orparamsparameters —Func/Actioncan't express those.
- You need
ref struct/Span<T>parameters.
- The name carries real domain meaning and improves readability:
RetryPolicy,TenantResolver,PriceAdjustmentreads better than
Func<decimal, TimeSpan, decimal> .
- You want default parameter values or XML docs on the parameters.
// Legitimate custom delegate — Func can't express `out` public delegate bool TryParse<T>(string input, out T value); // Legitimate custom delegate — Span parameter public delegate int Hasher(ReadOnlySpan<byte> data);
1.7 Variance: why Func<object, string> fits where Func<string, object> is wanted
Func and Action are declared with variance annotations:
public delegate TResult Func<in T, out TResult>(T arg);
in(contravariant, parameters): a delegate accepting a more general type can stand in for one accepting a more specific type. Anything that can handle anyobjectcan certainly handle astring.
out(covariant, return): a delegate returning a more specific type can stand in for one returning a more general type. Something that returns astringsatisfies a caller who just wants anobject.
Func<object, string> describe = o => o?.ToString() ?? "null"; Func<string, object> f = describe; // legal — contravariant in, covariant out
Practical payoff: you can write one Action<Exception> handler and pass it wherever an Action<InvalidOperationException> is required.
1.8 Delegate vs interface — the honest comparison
A delegate is essentially a single-method interface plus a bound instance, allocated as one object.
// Interface flavour
public interface IShipmentFilter { bool Matches(Shipment s); }
// Delegate flavour
public Func<Shipment, bool> Filter { get; set; }
| Use a delegate when | Use an interface when |
|---|---|
| There is exactly one operation | You need 2+ related operations that share state |
| The implementation is short and local | The implementation is a real, testable, injectable class |
| You want inline lambdas at the call site | You want DI, named registration, decorators |
| You need multicast (many listeners) | You want the implementation to be discoverable by name |
Strategy pattern with one method? Use a delegate. Strategy pattern with lifecycle, configuration and 4 methods? Use an interface. Most codebases over-use interfaces where a Func<> would be clearer.
Part 2 — Lambdas & closures
2.1 Lambdas are just syntax for "a method, written here"
The evolution, so the older syntax you'll meet in legacy code makes sense:
// C# 1 (2002) — you had to write a whole named method somewhere else
bool IsHeavy(Shipment s) { return s.WeightKg > 1000; }
Filter(list, IsHeavy);
// C# 2 (2005) — anonymous methods. Inline at last, but verbose.
Filter(list, delegate (Shipment s) { return s.WeightKg > 1000; });
// C# 3 (2007) — lambda expressions. Types inferred, `return` implied.
Filter(list, (Shipment s) => { return s.WeightKg > 1000; });
Filter(list, s => s.WeightKg > 1000); // idiomatic
// C# 9 (2020) — static lambdas: compiler ERROR if you accidentally capture
Filter(list, static s => s.WeightKg > 1000);
// C# 10 (2021) — natural type; lambdas can be assigned to `var`
var isHeavy = (Shipment s) => s.WeightKg > 1000; // inferred as Func<Shipment, bool>
// C# 12 (2023) — default parameter values in lambdas
var increment = (int x, int by = 1) => x + by;
The => is pronounced "goes to". Left side: parameters. Right side: body.
Expression-bodied (one expression, value returned implicitly):
s => s.WeightKg > 1000
Statement-bodied (a block, explicit return ):
s => {
var limit = _config.MaxWeight;
_logger.LogDebug("Checking {Id}", s.Id);
return s.WeightKg > limit;
}
Remember that distinction — only expression-bodied lambdas can become expression trees (Part 5).
2.2 Closures: the part that actually confuses people
A lambda can use variables from the enclosing scope. That's a closure — the lambda "closes over" those variables.
public Func<Shipment, bool> BuildFilter(decimal weightLimit) // local parameter
{
return s => s.WeightKg > weightLimit; // captured
}
var filter = BuildFilter(1000);
// BuildFilter has RETURNED. Its stack frame is gone.
// Yet `weightLimit` is still alive inside `filter`. How?
The compiler rewrites your method. Captured variables get promoted from the stack to a heap object — a compiler-generated "display class":
// What the compiler actually generates (names simplified):
private sealed class <>c__DisplayClass0_0
{
public decimal weightLimit; // the captured variable, now a FIELD
internal bool <BuildFilter>b__0(Shipment s) => s.WeightKg > weightLimit;
}
public Func<Shipment, bool> BuildFilter(decimal weightLimit)
{
var closure = new <>c__DisplayClass0_0(); // heap allocation
closure.weightLimit = weightLimit; // copy the value in
return new Func<Shipment, bool>(closure.<BuildFilter>b__0); // second allocation
}
Two consequences that explain almost every closure bug:
- The lambda captures the variable, not the value. They share one field. Change the variable after creating the lambda, and the lambda
sees the change.
- Capturing costs allocations — the display class, plus the delegate. In a hot path called a million times, that's two million objects.
Point 1 in action:
int threshold = 100; Func<int, bool> over = x => x > threshold; Console.WriteLine(over(150)); // True threshold = 200; // mutate AFTER building the lambda Console.WriteLine(over(150)); // False! Same variable, new value.
Analogy: capturing a variable is not photocopying a number onto a sticky note. It's writing down the locker number. Whatever is in the locker when you open it is what you get.
2.3 The loop-variable trap
This is the single most-asked C# closure interview question.
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
actions.Add(() => Console.Write(i));
foreach (var a in actions) a(); // prints 3 3 3 — not 0 1 2
There is one i for the whole loop. All three lambdas captured the same locker. By the time you invoke them, the loop has finished and i is 3.
Fix — create a fresh variable per iteration, so each closure gets its own locker:
for (int i = 0; i < 3; i++)
{
int copy = i; // new variable each iteration
actions.Add(() => Console.Write(copy));
}
// prints 0 1 2
foreach is different — and it changed. Before C# 5 (2012), foreach had the same bug. C# 5 redefined the iteration variable as fresh per iteration, so this is safe today:
foreach (var shipment in shipments)
tasks.Add(() => Process(shipment)); // safe in C# 5+, broken in C# 4 and earlier
If you maintain very old code, know that for still has the trap and foreach no longer does.
2.4 The cost of a lambda, and how to avoid paying it
Not all lambdas allocate. The compiler is smarter than most people assume.
Case A — captures nothing → cached, zero allocation per call.
list.Where(s => s.WeightKg > 1000);
The compiler emits a static field, creates the delegate once, and reuses it forever.
Case B — captures a local → allocates a display class + delegate on every call.
public IEnumerable<Shipment> Heavy(List<Shipment> list, decimal limit)
=> list.Where(s => s.WeightKg > limit); // 2 allocations per call
Case C — captures this → allocates a delegate per call (no display class needed).
public IEnumerable<Shipment> Heavy(List<Shipment> list)
=> list.Where(s => s.WeightKg > _limit); // _limit is a field ⇒ captures `this`
Case D — C# 11+ caches method group conversions to static methods.
strings.Select(int.Parse); // used to allocate each call; now cached
Weapon 1: static lambdas
static on a lambda means "compiler error if I accidentally capture anything." Use it as a guard-rail in hot paths and in library code.
list.Where(static s => s.WeightKg > 1000); // fine list.Where(static s => s.WeightKg > _limit); // CS8820 — cannot capture `this`
Weapon 2: state-passing overloads
The BCL added TState overloads precisely to kill closure allocations. Learn to spot them:
// Allocates a closure on every call: var value = cache.GetOrAdd(key, _ => BuildFor(tenantId)); // Zero closure — tenantId is passed as explicit state: var value = cache.GetOrAdd(key, static (k, tid) => BuildFor(tid), tenantId);
Same pattern appears on ConcurrentDictionary.GetOrAdd , Task.Run doesn't have it but ThreadPool.UnsafeQueueUserWorkItem<TState> does, CancellationToken.Register(Action<object?>, object?) , Lazy<T> , and ArrayPool callbacks.
Weapon 3: cached delegates for logging
ILogger.LogInformation("...{X}", x) boxes value types and allocates. LoggerMessage.Define builds the delegate once:
private static readonly Action<ILogger, string, int, Exception?> JobCompleted =
LoggerMessage.Define<string, int>(
LogLevel.Information,
new EventId(1001, nameof(JobCompleted)),
"Import job {JobId} completed with {RowCount} rows");
// call site — no allocation, no boxing
JobCompleted(_logger, jobId, rowCount, null);
In .NET 6+ the [LoggerMessage] source generator does this for you. Either way, the underlying mechanism is a cached delegate.
Honest guidance: don't micro-optimise closures in your controllers or handlers. Two allocations per HTTP request is noise. Do care in loops running >100k iterations, in serialization hot paths, and in library code others will call in tight loops. Measure with BenchmarkDotNet before and after; guessing here is how people waste weeks.
2.5 Closures extend object lifetime — a real leak source
public void Setup()
{
var hugeBuffer = new byte[100_000_000]; // 100 MB
var summary = Summarise(hugeBuffer);
_timer.Elapsed += (s, e) => Log(hugeBuffer.Length); // captures the ENTIRE array
}
The display class holds a reference to hugeBuffer . The delegate holds the display class. _timer holds the delegate. So 100 MB stays alive for the lifetime of the timer, even though you only needed .Length .
Fix: capture the smallest thing you need.
int length = hugeBuffer.Length; // capture an int, not the array _timer.Elapsed += (s, e) => Log(length);
Subtler version of the same bug: all lambdas in the same scope share one display class. If method M has two lambdas, and only one captures the huge buffer, both keep it alive because they share the generated class. If that bites you, move the innocent lambda into a separate method.
Part 3 — Callbacks: the usage pattern
3.1 There is no callback keyword
A callback isn't a C# feature. It's what you call a delegate when the pattern is:
"I'm giving you this method. You decide when to invoke it."
Sometimes called the Hollywood Principle: don't call us, we'll call you. Inversion of control at the method level.
Three flavours you'll meet:
- Strategy callback — "run this instead of my default behaviour" (
Where,OrderBy,Sort).
- Completion callback — "run this when the long job finishes" (async work, HTTP, file I/O).
- Notification callback — "run this every time X happens" (progress, retries, cancellation) — this is the one that grows into events.
3.2 Strategy callbacks: the everyday case
public sealed class RetryExecutor
{
public async Task<T> ExecuteAsync<T>(
Func<CancellationToken, Task<T>> operation, // the work — a callback
Func<Exception, bool> shouldRetry, // the policy — a callback
Action<int, TimeSpan>? onRetry = null, // notification — a callback
int maxAttempts = 3,
CancellationToken ct = default)
{
for (var attempt = 1; ; attempt++)
{
try
{
return await operation(ct);
}
catch (Exception ex) when (attempt < maxAttempts && shouldRetry(ex))
{
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt));
onRetry?.Invoke(attempt, delay);
await Task.Delay(delay, ct);
}
}
}
}
Usage:
var manifest = await _retry.ExecuteAsync(
operation: ct => _documentIntelligence.AnalyseAsync(blobUri, ct),
shouldRetry: ex => ex is RequestFailedException { Status: 429 or >= 500 },
onRetry: (attempt, delay) => _logger.LogWarning(
"Analyse failed, attempt {Attempt}, retrying in {Delay}", attempt, delay),
ct: cancellationToken);
Three callbacks, three different jobs, one reusable class. Note onRetry?.Invoke(...) — the ?. is essential; an unassigned delegate is null , and invoking null throws NullReferenceException .
3.3 Async callbacks: the history that explains async/await
This matters because await is a callback with better ergonomics, and understanding that removes the mystery.
Stage 1 — APM (Asynchronous Programming Model), .NET 1.0. Explicit callbacks:
// Old style — you will still see this in ancient code
stream.BeginRead(buffer, 0, buffer.Length, asyncResult =>
{
int bytesRead = stream.EndRead(asyncResult); // callback fires on a pool thread
ProcessChunk(buffer, bytesRead);
}, state: null);
Nest three of these and you get the pyramid of doom. Error handling is miserable: a try/catch around BeginRead catches nothing, because the callback runs later on a different thread.
Stage 2 — EAP (Event-based Asynchronous Pattern), .NET 2.0. Callbacks dressed as events:
var client = new WebClient();
client.DownloadStringCompleted += (s, e) => { /* handle e.Result / e.Error */ };
client.DownloadStringAsync(uri);
Better for UI, but still no composition, no await , and easy to leak subscriptions.
Stage 3 — TAP (Task-based Asynchronous Pattern), .NET 4.0. The callback becomes a value:
_httpClient.GetStringAsync(uri)
.ContinueWith(t => ProcessChunk(t.Result)); // still an explicit callback
Stage 4 — async / await , C# 5. The compiler writes the callback for you:
var json = await _httpClient.GetStringAsync(uri, ct); ProcessChunk(json);
This compiles into a state machine that registers a continuation — a callback — with the awaited task. Everything after the await becomes the callback body. That's the whole trick.
Rule for production: if you're writing ContinueWith , BeginXxx , or hand-rolling completion callbacks in new code, stop. Use await . Callbacks are still correct for strategy and notification, but completion callbacks are a solved problem.
3.4 IProgress<T> — the right way to report progress
You have a bulk import in an Azure Function. You want progress updates. Don't invent your own Action<int> ; the BCL has a designed abstraction.
public async Task ImportAsync(
Stream csv,
IProgress<ImportProgress>? progress,
CancellationToken ct)
{
var rows = 0;
await foreach (var row in ReadRowsAsync(csv, ct))
{
await _repository.StoreAsync(row, ct);
if (++rows % 500 == 0)
progress?.Report(new ImportProgress(rows, row.SourceLine));
}
progress?.Report(new ImportProgress(rows, Completed: true));
}
public readonly record struct ImportProgress(int RowsProcessed, int SourceLine = 0, bool Completed = false);
Caller:
var progress = new Progress<ImportProgress>(p =>
_hubContext.Clients.Group(jobId).SendAsync("progress", p));
await _importer.ImportAsync(stream, progress, ct);
Why IProgress<T> and not a bare Action<T> :
Progress<T>captures theSynchronizationContextat construction time. In a UI app the callback marshals back to the UI thread automatically. In ASP.NET Core there's no sync context, so it posts to the thread pool — which is what you want.
- It's an interface, so it's trivially mockable and can be
nullfor "caller doesn't care".
- It's the standard the whole ecosystem recognises.
Gotcha: Progress<T>.Report is fire-and-forget and asynchronous. Reports can arrive out of order and after your method has returned. Never use it for anything ordering-sensitive — always send absolute values ("450 rows done"), never deltas ("+50 rows").
3.5 Cancellation callbacks
using var registration = cancellationToken.Register(static state =>
{
((IDisposable)state!).Dispose();
}, connection);
CancellationToken.Register returns a CancellationTokenRegistration that you must dispose. If the token is long-lived (an application- lifetime token, say) and you register per-request without disposing, the registration list grows forever — a slow, ugly leak that's very hard to find. The using is not optional.
Notice the static lambda plus the state parameter: zero allocation, and it can't accidentally capture your whole service.
3.6 Callback pitfalls checklist
| Pitfall | What happens | Fix |
|---|---|---|
Invoking a null delegate | NullReferenceException | callback?.Invoke(...) |
async void callback | Exception escapes to the thread pool and kills the process | Return Task ; make the delegate Func<..., Task> |
| Exception thrown inside a callback | Propagates into your library code, from a stranger's method | Document it, or wrap in try/catch and surface as an event/log |
| Callback re-enters your object | Corrupt state, deadlock, stack overflow | Invoke callbacks outside your locks, after your state is consistent |
| Callback invoked while holding a | Classic deadlock — the user's code takes another lock | Never call user code inside a lock |
lock
Long-running callback on a hot Blocks your pipeline Document expectations; consider queueing to a path Channel<T>
The re-entrancy and locking rules are non-negotiable in library code:
public void Add(Shipment s)
{
Shipment[] snapshot;
lock (_gate)
{
_items.Add(s);
snapshot = _items.ToArray();
}
_onChanged?.Invoke(snapshot); // invoke OUTSIDE the lock
}
Part 4 — Events: delegates with a bouncer
4.1 The problem events solve
Suppose you expose a public delegate field so others can subscribe:
public sealed class ShipmentTracker
{
public Action<Shipment>? Delivered; // public delegate FIELD — dangerous
}
Any consumer can now do things you never intended:
tracker.Delivered = null; // wiped out EVERY other subscriber tracker.Delivered = s => Console.WriteLine(); // replaced everyone with themselves tracker.Delivered(someShipment); // RAISED your event on your behalf — a lie
Add one keyword and all three become compile errors:
public event Action<Shipment>? Delivered; // now it's an event
That is the entire difference between a delegate and an event. An event is a delegate field with an access-control layer: from outside the declaring type you may only += and -= . You cannot assign, cannot read, cannot invoke.
Analogy: a delegate field is a shared WhatsApp group where anyone can remove all members or post as the admin. An event is a newsletter: subscribe, unsubscribe, that's it. Only the publisher sends.
4.2 What the compiler generates
A "field-like event" expands to a private delegate field plus two accessors:
// You write:
public event EventHandler<ShipmentDeliveredEventArgs>? Delivered;
// Roughly what Roslyn emits:
private EventHandler<ShipmentDeliveredEventArgs>? _delivered; // private!
public event EventHandler<ShipmentDeliveredEventArgs>? Delivered
{
add
{
// lock-free thread-safe combine
EventHandler<ShipmentDeliveredEventArgs>? current, updated;
do
{
current = _delivered;
updated = (EventHandler<ShipmentDeliveredEventArgs>?)Delegate.Combine(current, value);
}
while (Interlocked.CompareExchange(ref _delivered, updated, current) != current);
}
remove { /* same loop with Delegate.Remove */ }
}
Two takeaways: subscribe/unsubscribe is already thread-safe, and the backing field is private, so only the declaring class can raise it.
You can also write the accessors yourself when you need custom storage (useful when a class has 40 rarely-used events and you don't want 40 fields — WinForms does this with an EventHandlerList ):
private readonly EventHandlerList _events = new();
private static readonly object DeliveredKey = new();
public event EventHandler<ShipmentDeliveredEventArgs> Delivered
{
add => _events.AddHandler(DeliveredKey, value);
remove => _events.RemoveHandler(DeliveredKey, value);
}
4.3 The canonical .NET event pattern
public sealed class ShipmentDeliveredEventArgs : EventArgs
{
public ShipmentDeliveredEventArgs(string shipmentId, DateTimeOffset deliveredAt)
=> (ShipmentId, DeliveredAt) = (shipmentId, deliveredAt);
public string ShipmentId { get; }
public DateTimeOffset DeliveredAt { get; }
}
public class ShipmentTracker
{
public event EventHandler<ShipmentDeliveredEventArgs>? Delivered;
// protected virtual On<Name> so subclasses can extend or suppress
protected virtual void OnDelivered(ShipmentDeliveredEventArgs e)
=> Delivered?.Invoke(this, e);
public void MarkDelivered(string shipmentId)
{
// 1. change state FIRST, so handlers observe a consistent object
_store.SetDelivered(shipmentId);
// 2. then notify
OnDelivered(new ShipmentDeliveredEventArgs(shipmentId, DateTimeOffset.UtcNow));
}
}
Conventions worth following because the whole ecosystem expects them:
- Signature is
(object? sender, TEventArgs e). UseEventHandler<T>— don't declare your own delegate type unless you have a real reason.
- Event name is a verb:
Delivered,Closing,PriceChanged. Past tense = already happened; present participle = about to happen and possibly cancellable.
- Raise via a
protected virtual OnXxxmethod.
- Since .NET 4.5 the
where TEventArgs : EventArgsconstraint was removed, soEventHandler<ShipmentId>is legal. Deriving fromEventArgsis still the convention, and it gives you room to add properties later without breaking callers.
4.4 The ?.Invoke idiom and the race it fixes
// WRONG — classic race
if (Delivered != null)
Delivered(this, e); // another thread may unsubscribe between the check and the call
// → NullReferenceException
// OLD FIX — copy to a local first
var handler = Delivered;
if (handler != null) handler(this, e);
// MODERN — ?. does exactly the copy-then-check, atomically
Delivered?.Invoke(this, e);
Because delegates are immutable, the local copy is safe: an unsubscribe creates a new delegate and reassigns the field, leaving your snapshot untouched. The only side effect is that a handler which unsubscribed microseconds ago might still get called once. That's inherent to the pattern — write handlers defensively.
4.5 The #1 production bug: event handler memory leaks
This causes more real-world .NET memory leaks than anything else.
public class OrderGridViewModel : IDisposable
{
public OrderGridViewModel(IPriceFeed feed)
{
feed.PriceChanged += OnPriceChanged; // feed now holds a reference to `this`
}
private void OnPriceChanged(object? s, PriceEventArgs e) { /* ... */ }
}
IPriceFeed is a long-lived singleton. Its delegate's Target is your view model. The publisher keeps the subscriber alive. Create and discard 10,000 view models and you have 10,000 live objects the GC will never collect — plus 10,000 handlers firing on every price tick, doing work for screens nobody is looking at.
Notice the direction: long-lived publisher + short-lived subscriber = leak. The reverse (short-lived publisher, long-lived subscriber) is fine.
Fixes, in order of preference
1. Unsubscribe deterministically. Make the subscriber IDisposable .
public sealed class OrderGridViewModel : IDisposable
{
private readonly IPriceFeed _feed;
public OrderGridViewModel(IPriceFeed feed)
{
_feed = feed;
_feed.PriceChanged += OnPriceChanged;
}
public void Dispose() => _feed.PriceChanged -= OnPriceChanged;
}
2. Return a disposable subscription — makes the contract impossible to get wrong.
public sealed class PriceFeed
{
private event EventHandler<PriceEventArgs>? PriceChanged;
public IDisposable Subscribe(EventHandler<PriceEventArgs> handler)
{
PriceChanged += handler;
return new Subscription(() => PriceChanged -= handler);
}
private sealed class Subscription(Action unsubscribe) : IDisposable
{
private Action? _unsubscribe = unsubscribe;
public void Dispose() => Interlocked.Exchange(ref _unsubscribe, null)?.Invoke();
}
}
// consumer
using var sub = feed.Subscribe(OnPriceChanged); // scoped, leak-proof
This is the pattern Rx ( IObservable<T> ) standardised, and why Rx exists at all.
- 3. Weak event pattern — the publisher holds a
WeakReferenceto the subscriber. Powerful but fiddly; WPF hasWeakEventManagerbuilt in. Treat it as a last resort for cases where you genuinely cannot control subscriber lifetime.
Rule for production: for every += you write, be able to point at the matching -= . If you can't, you have written a leak. In ASP.NET Core specifically: never subscribe a scoped service to a singleton's event. That's the leak, every time.
4.6 Exceptions and multicast, revisited
Same problem as Part 1.4, now with real consequences: one badly-written subscriber can silently prevent every later subscriber from running. If subscribers are third-party or independent modules, isolate them:
protected virtual void OnDelivered(ShipmentDeliveredEventArgs e)
{
var handlers = Delivered;
if (handlers is null) return;
foreach (EventHandler<ShipmentDeliveredEventArgs> h in handlers.GetInvocationList())
{
try { h(this, e); }
catch (Exception ex)
{
_logger.LogError(ex, "Subscriber {Target}.{Method} threw",
h.Target?.GetType().Name, h.Method.Name);
}
}
}
Do this in library/framework code and in any plugin system. For internal code where you control every subscriber, plain ?.Invoke is fine.
4.7 Async event handlers — the async void trap
Events are void -returning by design. So subscribers who need to await write:
tracker.Delivered += async (s, e) => await _bus.PublishAsync(e); // async void
Three things are now broken:
- The publisher cannot await it.
Invokereturns the instant the handler hits its firstawait. Your "notify then continue" sequence is a lie.
- An exception inside an
async voidhandler is thrown on the captured context or the thread pool — it will crash the process, and no
try/catch around the raise will see it.
- There is no way to know when handlers have finished, so ordering and shutdown are unreliable.
Fix: don't use C# events for async work. Use a list of Func<..., Task> .
public delegate Task AsyncEventHandler<TArgs>(object? sender, TArgs e, CancellationToken ct);
public sealed class ShipmentTracker
{
private readonly List<AsyncEventHandler<ShipmentDeliveredEventArgs>> _handlers = new();
private readonly object _gate = new();
public IDisposable OnDelivered(AsyncEventHandler<ShipmentDeliveredEventArgs> handler)
{
lock (_gate) _handlers.Add(handler);
return new Subscription(() => { lock (_gate) _handlers.Remove(handler); });
}
protected async Task RaiseDeliveredAsync(ShipmentDeliveredEventArgs e, CancellationToken ct)
{
AsyncEventHandler<ShipmentDeliveredEventArgs>[] snapshot;
lock (_gate) snapshot = _handlers.ToArray();
foreach (var handler in snapshot) // sequential: deterministic ordering
await handler(this, e, ct);
// Parallel alternative — only if handlers are genuinely independent:
// await Task.WhenAll(snapshot.Select(h => h(this, e, ct)));
}
}
Now you can await the raise, exceptions propagate properly, and cancellation flows through.
The only legitimate use of async void is a top-level event handler you don't control the signature of (a WinForms/WPF button click), and even there you must wrap the whole body in try/catch .
4.8 When NOT to use C# events
This is the judgement call that separates senior from mid-level, and it's a common interview question.
| Scenario | Use |
|---|---|
| Two objects in the same process, same call stack, UI-ish notification | C# event |
| Decoupling handlers within one request, in-process, with DI | MediatR INotification |
| Async in-process fan-out with back-pressure | Channel<T> + a background reader |
| Must survive process restart / needs retry / crosses service boundaries | Azure Service Bus / queue + Transactional Outbox |
| Complex event streams: throttle, debounce, buffer, combine | Rx ( System.Reactive ) |
| Loosely-coupled components in the same app, no DI available | Event aggregator (or just IObservable<T> ) |
The line that matters:
C# events are in-memory, synchronous, unordered across subscribers, non-durable, and vanish on process restart.
So: a domain event that must reliably trigger an email, a webhook, or a downstream service must not be a C# event. Persist it in the same transaction as your aggregate (outbox), then dispatch to a broker. Use C# events for in-process notification only — cache invalidation, UI updates, in-memory metrics, plugin hooks.
A pragmatic hybrid, and the one I'd default to in a Clean Architecture codebase:
// Domain layer: aggregate records events, raises nothing
public abstract class AggregateRoot
{
private readonly List<IDomainEvent> _events = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _events;
protected void Raise(IDomainEvent e) => _events.Add(e);
public void ClearDomainEvents() => _events.Clear();
}
// Infrastructure: on SaveChanges, write events to the outbox in the SAME transaction,
// then a background dispatcher publishes to Service Bus with retries.
No delegates, no events — just data. Which is exactly the point: durability beats cleverness at a service boundary.
Part 5 — Expression trees: code as data
This is the one that feels like black magic. It isn't. Two lines make the whole thing click:
Func<Shipment, bool> compiled = s => s.WeightKg > 1000; Expression<Func<Shipment, bool>> tree = s => s.WeightKg > 1000;
Identical source text. Completely different results.
- The first is compiled to IL. It's a black box. You can call it. You cannot ask it what it does.
- The second is not compiled to IL at all. The compiler builds an object graph describing the code — nodes for "greater than", "property access on parameter
snamedWeightKg", "constant 1000". You can walk it, rewrite it, and translate it into SQL, RQL, MongoDB filters, or a GraphQL query.
Analogy: Func is a cooked dish. You can eat it, that's all. Expression<Func> is the recipe on paper. You can read it, check for allergens, translate it to another language, or post it to a kitchen in another country that will cook it their own way. This is precisely what EF Core, RavenDB and Hot Chocolate do — they read your recipe and cook it in the database.
5.1 What the tree actually looks like
For s => s.WeightKg > 1000 :
LambdaExpression (Expression<Func<Shipment,bool>>)
├── Parameters: [ ParameterExpression "s", Type = Shipment ]
└── Body: BinaryExpression NodeType = GreaterThan
├── Left: MemberExpression Member = Shipment.WeightKg
│ └── Expression: ParameterExpression "s"
└── Right: ConstantExpression Value = 1000m
Inspect it yourself:
Expression<Func<Shipment, bool>> tree = s => s.WeightKg > 1000m; Console.WriteLine(tree.NodeType); // Lambda Console.WriteLine(tree.Parameters[0].Name); // s var body = (BinaryExpression)tree.Body; Console.WriteLine(body.NodeType); // GreaterThan Console.WriteLine(((MemberExpression)body.Left).Member.Name); // WeightKg Console.WriteLine(((ConstantExpression)body.Right).Value); // 1000 Console.WriteLine(tree); // ToString() prints: s => (s.WeightKg > 1000)
System.Linq.Expressions has ~40 node types ( BinaryExpression , MethodCallExpression , ConditionalExpression , NewExpression , MemberInitExpression , BlockExpression , LoopExpression , and so on). You almost never need all of them.
5.2 Back to executable code: .Compile()
Expression<Func<Shipment, bool>> tree = s => s.WeightKg > 1000; Func<Shipment, bool> fn = tree.Compile(); // generates IL at RUNTIME bool heavy = fn(shipment); // now it's just a normal delegate
Compile() emits a DynamicMethod and JITs it. It is expensive — think microseconds to milliseconds, orders of magnitude more than calling the delegate. Once compiled, the delegate runs at roughly normal delegate speed.
Rule for production: never call .Compile() inside a request handler or a loop. Compile once, cache the delegate in a static readonly field or a ConcurrentDictionary .
private static readonly ConcurrentDictionary<string, Func<Shipment, object?>> GetterCache = new();
public static object? GetValue(Shipment s, string propertyName)
=> GetterCache.GetOrAdd(propertyName, static name =>
{
var param = Expression.Parameter(typeof(Shipment), "s");
var body = Expression.Convert(Expression.PropertyOrField(param, name), typeof(object));
return Expression.Lambda<Func<Shipment, object?>>(body, param).Compile();
})(s);
ExpressionType.Compile() also has an interpreted mode ( Compile(preferInterpretation: true) ), which starts faster but runs slower — relevant on platforms without runtime code generation, such as iOS/AOT.
5.3 The payoff: IQueryable<T> vs IEnumerable<T>
Look at the two Where methods side by side. This is the most important comparison in this document:
// System.Linq.Enumerable — LINQ to Objects
public static IEnumerable<T> Where<T>(this IEnumerable<T> source,
Func<T, bool> predicate);
// System.Linq.Queryable — LINQ to a query provider
public static IQueryable<T> Where<T>(this IQueryable<T> source,
Expression<Func<T, bool>> predicate);
One takes a delegate (a black box: the only thing you can do is run it per item, in memory). The other takes an expression tree (a recipe: the provider reads it and generates a query).
// IQueryable — EF Core reads the tree, emits SQL, database does the filtering
var heavy = await _db.Shipments
.Where(s => s.WeightKg > 1000) // → WHERE [s].[WeightKg] > 1000
.OrderBy(s => s.DueDate) // → ORDER BY [s].[DueDate]
.Take(50) // → OFFSET/FETCH
.ToListAsync(ct);
// IEnumerable — pulls the ENTIRE table into memory, then filters in C#
var heavy = _db.Shipments
.AsEnumerable() // ← the boundary. Everything after is in-process.
.Where(s => s.WeightKg > 1000)
.ToList();
Same syntax, wildly different behaviour and cost. The difference is only whether the parameter type is Func<> or Expression<Func<>> .
The classic production bug
private bool IsEligible(Shipment s) => s.WeightKg > 1000 && !s.IsCancelled; // This will NOT translate: var results = await _db.Shipments.Where(s => IsEligible(s)).ToListAsync(ct);
The tree contains a MethodCallExpression for IsEligible . EF Core has no idea what's inside a compiled C# method — it can't read IL. In EF Core 3.0+ this throws InvalidOperationException: The LINQ expression could not be translated . (In EF Core 2.x it silently fell back to client evaluation, quietly loading your whole table — one of the worst defaults in ORM history, which is exactly why they made it throw.)
Fix — make the reusable rule an expression, not a method:
public static class ShipmentSpecs
{
public static readonly Expression<Func<Shipment, bool>> Eligible =
s => s.WeightKg > 1000 && !s.IsCancelled;
}
var results = await _db.Shipments.Where(ShipmentSpecs.Eligible).ToListAsync(ct);
Now the tree is inlined into the query and translates cleanly. This tiny change is the seed of the Specification pattern (Part 7).
5.4 Building expressions by hand
Composing trees manually is how you build dynamic queries. The API is verbose but mechanical.
using System.Linq.Expressions; // Goal: build s => s.WeightKg > 1000 from scratch var param = Expression.Parameter(typeof(Shipment), "s"); var property = Expression.Property(param, nameof(Shipment.WeightKg)); var constant = Expression.Constant(1000m, typeof(decimal)); var body = Expression.GreaterThan(property, constant); var lambda = Expression.Lambda<Func<Shipment, bool>>(body, param); Console.WriteLine(lambda); // s => (s.WeightKg > 1000)
Two rules that catch everyone out:
- Parameter identity matters.
Expression.Parameter(typeof(Shipment), "s")called twice gives two different parameters, even with the
same name. A tree that mixes them throws InvalidOperationException: variable 's' of type 'Shipment' referenced from scope '', but
it is not defined .
- Types must match exactly. Comparing a
decimalproperty toExpression.Constant(1000)(anint) throws. Use
Expression.Constant(1000m, typeof(decimal)) or wrap with Expression.Convert .
Practical example: dynamic sorting from an API parameter
Every list endpoint eventually needs ?sortBy=customerName&desc=true . You cannot write OrderBy(x => x.<string>) . You build the tree:
public static IQueryable<T> OrderByProperty<T>(
this IQueryable<T> source, string propertyPath, bool descending)
{
var parameter = Expression.Parameter(typeof(T), "x");
Expression body = parameter;
foreach (var member in propertyPath.Split('.')) // supports "Customer.Name"
body = Expression.PropertyOrField(body, member);
var selector = Expression.Lambda(body, parameter);
var methodName = descending ? nameof(Queryable.OrderByDescending)
: nameof(Queryable.OrderBy);
var call = Expression.Call(
typeof(Queryable),
methodName,
new[] { typeof(T), body.Type }, // generic args: TSource, TKey
source.Expression,
Expression.Quote(selector));
return source.Provider.CreateQuery<T>(call);
}
// usage
var page = await _db.Shipments
.OrderByProperty(request.SortBy, request.Descending)
.Skip(request.Skip).Take(request.Take)
.ToListAsync(ct);
SECURITY — read this twice. propertyPath comes from the user. Expression.PropertyOrField will happily bind to any property, including ones you never meant to expose, and will throw an ugly ArgumentException for a typo. Always whitelist:
`csharp private static readonly HashSet Sortable = new(StringComparer.OrdinalIgnoreCase) { "Id", "DueDate", "WeightKg", "Customer.Name" };
if (!Sortable.Contains(propertyPath)) throw new ValidationException($"Cannot sort by '{propertyPath}'."); ` Dynamic expression building is the NoSQL/ORM equivalent of string-concatenating SQL. Same discipline required.
5.5 Combining predicates: a real PredicateBuilder
Search screens need "apply filter A and filter B and filter C, but only the ones the user supplied." The naive attempt fails:
Expression<Func<Shipment, bool>> a = s => s.WeightKg > 1000;
Expression<Func<Shipment, bool>> b = s => !s.IsCancelled;
var broken = Expression.Lambda<Func<Shipment, bool>>(
Expression.AndAlso(a.Body, b.Body), a.Parameters[0]); // throws at query time
Why: a.Body refers to a's parameter, b.Body refers to b's parameter. Two different ParameterExpression objects. The lambda only declares one, so b 's parameter is unbound.
The fix is an ExpressionVisitor that rewrites b 's parameter to be a 's:
public static class PredicateBuilder
{
public static Expression<Func<T, bool>> True<T>() => static _ => true;
public static Expression<Func<T, bool>> False<T>() => static _ => false;
public static Expression<Func<T, bool>> And<T>(
this Expression<Func<T, bool>> left, Expression<Func<T, bool>> right)
=> Combine(left, right, Expression.AndAlso);
public static Expression<Func<T, bool>> Or<T>(
this Expression<Func<T, bool>> left, Expression<Func<T, bool>> right)
=> Combine(left, right, Expression.OrElse);
private static Expression<Func<T, bool>> Combine<T>(
Expression<Func<T, bool>> left,
Expression<Func<T, bool>> right,
Func<Expression, Expression, BinaryExpression> merge)
{
var parameter = Expression.Parameter(typeof(T), "x");
var leftBody = new ParameterReplacer(left.Parameters[0], parameter).Visit(left.Body)!;
var rightBody = new ParameterReplacer(right.Parameters[0], parameter).Visit(right.Body)!;
return Expression.Lambda<Func<T, bool>>(merge(leftBody, rightBody), parameter);
}
private sealed class ParameterReplacer(ParameterExpression from, ParameterExpression to)
: ExpressionVisitor
{
protected override Expression VisitParameter(ParameterExpression node)
=> node == from ? to : base.VisitParameter(node);
}
}
Now dynamic search is clean and fully translatable:
var predicate = PredicateBuilder.True<Shipment>();
if (request.MinWeight is { } min)
predicate = predicate.And(s => s.WeightKg >= min);
if (!string.IsNullOrWhiteSpace(request.Customer))
predicate = predicate.And(s => s.Customer.Name.Contains(request.Customer));
if (request.ExcludeCancelled)
predicate = predicate.And(s => !s.IsCancelled);
var results = await _db.Shipments.Where(predicate).ToListAsync(ct);
// → ONE SQL query with exactly the WHERE clauses the user asked for
Compare that to the alternative most teams write: a giant if/else ladder building a raw SQL string. This is type-safe, refactor-safe, and injection-proof.
5.6 ExpressionVisitor : rewriting trees
ExpressionVisitor is the visitor pattern over the node graph. Override the VisitXxx you care about, return a replacement node, and the base class rebuilds the tree around it. Trees are immutable, so "rewriting" means "producing a new tree."
A useful production example — swapping out a constant so a cached tree can be reused with a new tenant, or normalising all string comparisons to be case-insensitive:
public sealed class CaseInsensitiveStringVisitor : ExpressionVisitor
{
private static readonly MethodInfo StringEquals =
typeof(string).GetMethod(nameof(string.Equals),
new[] { typeof(string), typeof(string), typeof(StringComparison) })!;
protected override Expression VisitBinary(BinaryExpression node)
{
if (node.NodeType == ExpressionType.Equal && node.Left.Type == typeof(string))
{
return Expression.Call(
StringEquals,
Visit(node.Left)!,
Visit(node.Right)!,
Expression.Constant(StringComparison.OrdinalIgnoreCase));
}
return base.VisitBinary(node);
}
}
The same technique underlies EF Core's global query filters, soft-delete interceptors, multi-tenancy filters, and every LINQ provider ever written. A LINQ provider is an ExpressionVisitor that emits query text instead of a new tree.
5.7 Expressions as a fast alternative to reflection
Reflection is flexible and slow. Compiled expressions are flexible and fast — you pay the cost once.
public static class FastAccessor<T>
{
private static readonly ConcurrentDictionary<string, Func<T, object?>> Getters = new();
public static Func<T, object?> Getter(string propertyName)
=> Getters.GetOrAdd(propertyName, static name =>
{
var prop = typeof(T).GetProperty(name)
?? throw new ArgumentException($"No property '{name}' on {typeof(T).Name}");
var instance = Expression.Parameter(typeof(T), "instance");
var body = Expression.Convert(Expression.Property(instance, prop), typeof(object));
return Expression.Lambda<Func<T, object?>>(body, instance).Compile();
});
}
// One-time compile, then near-native speed
var getName = FastAccessor<Shipment>.Getter("CustomerName");
foreach (var s in millionShipments) Use(getName(s));
Rough orders of magnitude (measure your own — these are directional, not gospel):
| Approach | Relative cost per call |
|---|---|
| Direct property access | 1× |
| Delegate / compiled expression | ~1–3× |
Cached PropertyInfo.GetValue | ~50–200× |
PropertyInfo.GetValue looked up each time | ~500×+ |
This is the engine inside AutoMapper, Dapper, JSON serializers, ORMs and validation libraries.
Modern alternatives worth knowing (interviewers like this answer): since .NET 6+, source generators often beat runtime expression compilation — zero startup cost, AOT-friendly, debuggable. System.Text.Json source generation and [LoggerMessage] are examples. And [UnsafeAccessor] (.NET 8) gives fast access to private members without reflection or emit. Reach for expressions when the shape is only known at runtime; reach for source generators when it's known at compile time.
5.8 The limits of C#-compiled expression trees
The compiler only converts expression-bodied lambdas. These are compile errors:
Expression<Func<int, int>> a = x => { return x + 1; }; // CS0834 statement body
Expression<Func<Task>> b = async () => await Foo(); // CS1989 async lambda
Expression<Action> c = () => { var y = 1; y++; }; // CS0834
The Expression API itself can represent blocks, loops, try/catch, assignments — Expression.Block , Expression.Loop , Expression.TryCatch . You just have to build them by hand. That's how DI containers and rules engines generate code at runtime.
// Statement body, built manually
var x = Expression.Parameter(typeof(int), "x");
var temp = Expression.Variable(typeof(int), "temp");
var block = Expression.Block(
new[] { temp },
Expression.Assign(temp, Expression.Add(x, Expression.Constant(1))),
temp); // last expression is the return value
var fn = Expression.Lambda<Func<int, int>>(block, x).Compile();
Console.WriteLine(fn(41)); // 42
Note: a query provider like EF Core will not translate a hand-built block. Manual blocks are for .Compile() scenarios, not for IQueryable .
5.9 One more distinction: expression trees vs nameof vs reflection
You'll see all three used to name a member. Pick deliberately:
// nameof — compile-time constant, zero cost. Use this whenever you just need the NAME.
var name = nameof(Shipment.WeightKg); // "WeightKg"
// Expression — needed when you must capture a PATH or a whole rule, not just a name
public void Rule<TProp>(Expression<Func<Shipment, TProp>> selector) { ... }
validator.RuleFor(s => s.Customer.Address.PostCode); // multi-level path
// Reflection — needed only when the type isn't known at compile time
var prop = type.GetProperty(userSuppliedName);
FluentValidation, EF Core's fluent configuration, and most mapping libraries take Expression<Func<T, TProp>> for exactly this reason: it gives them the path, the type, and refactor-safety, all in one.
Part 6 — How the five fit together
6.1 One request, all five
Trace a single API call through a typical Clean Architecture service and you'll hit every concept:
// 1. EVENT — the host raises ApplicationStarted; DI wiring subscribes
app.Lifetime.ApplicationStarted.Register(() => _logger.LogInformation("Up"));
// 2. DELEGATE — DI registration is a factory delegate
services.AddScoped<ITenantResolver>(sp => new HeaderTenantResolver(sp.GetRequiredService<IHttpContextAccessor>()));
// 3. CALLBACK — MediatR pipeline behaviour receives `next` as a delegate
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
using var _ = _logger.BeginScope("{RequestName}", typeof(TRequest).Name);
return await next(); // ← invoking the rest of the pipeline via a callback
}
// 4. EXPRESSION TREE — the handler's query
var shipments = await _db.Shipments
.Where(s => s.TenantId == _tenant.Id && s.WeightKg > request.MinWeight)
.ToListAsync(ct);
// 5. LAMBDA — in-memory projection after the data comes back
return shipments.Select(s => new ShipmentDto(s.Id, s.WeightKg)).ToList();
Points 3, 4 and 5 are the ones people conflate. Line 3 is a delegate used as a callback. Line 4 is a lambda that became an expression tree because Where on IQueryable takes one. Line 5 is a lambda that became a delegate because Select on IEnumerable takes one.
You already use all five daily. You just didn't have names attached.
6.2 The decision table
| I want to... | Reach for |
|---|---|
| Pass one operation as a parameter | Func<> / Action<> |
Pass an operation with out / ref / Span | Custom delegate |
| Let callers hook into "this happened", in-process | event EventHandler<T> |
Let callers hook in, and await them | List<Func<T, CancellationToken, Task>> + IDisposable subscription |
| Let callers hook in, durably, across services | Outbox + Azure Service Bus |
| Filter/sort/project in the database | Expression<Func<T, bool>> on IQueryable |
| Filter/sort/project in memory | Func<T, bool> on IEnumerable |
| Reuse a business rule across queries | static readonly Expression<Func<T, bool>> (Specification) |
| Build a query from user input at runtime | ExpressionVisitor + PredicateBuilder + a whitelist |
| Replace slow reflection in a hot path | Compiled expression, cached — or a source generator |
| Report progress from a long job | IProgress<T> |
| React to cancellation | CancellationToken.Register (dispose the registration!) |
6.3 The five-second version, for interviews
- Delegate — a type-safe function pointer that also carries a target object. Multicast, immutable.
- Lambda — inline syntax that becomes either a delegate or an expression tree, depending on the target type.
- Callback — a delegate you hand to someone else so they can invoke it later. A pattern, not a feature.
- Event — a delegate field with
add/removeaccessors, so outsiders can only subscribe and unsubscribe, never assign or raise.
- Expression tree — the lambda's structure as data instead of IL, so a provider can translate it into another language such as SQL.
Part 7 — Production playbook
Everything so far has been mechanism. Here's where I'd actually use each one, and the rules I'd enforce in code review.
7.1 Specification pattern — reusable, translatable business rules
The problem: the rule "an active shipment for a tenant" appears in nine queries. Copy-paste drift follows.
public abstract class Specification<T>
{
public abstract Expression<Func<T, bool>> ToExpression();
public Specification<T> And(Specification<T> other) => new AndSpecification<T>(this, other);
// Lets you also evaluate in memory (unit tests, domain guards) with no duplication
public bool IsSatisfiedBy(T entity) => ToExpression().Compile()(entity);
public static implicit operator Expression<Func<T, bool>>(Specification<T> spec)
=> spec.ToExpression();
}
public sealed class ActiveShipmentSpec : Specification<Shipment>
{
public override Expression<Func<Shipment, bool>> ToExpression()
=> s => !s.IsCancelled && s.DeliveredAt == null;
}
public sealed class HeavierThanSpec(decimal kg) : Specification<Shipment>
{
public override Expression<Func<Shipment, bool>> ToExpression()
=> s => s.WeightKg > kg; // `kg` becomes a captured constant in the tree
}
var spec = new ActiveShipmentSpec().And(new HeavierThanSpec(1000));
var results = await _db.Shipments.Where(spec.ToExpression()).ToListAsync(ct);
Two rules for this pattern:
- Don't cache the result of
IsSatisfiedBy'sCompile()naively — that code path compiles on every call. Cache the compiled delegate in a
Lazy<Func<T,bool>> field on the specification.
And/Ormust use theExpressionVisitorparameter-rebinding from §5.5, or you'll get the unbound-parameter exception at query time
rather than compile time.
7.2 Dynamic filtering for list APIs and GraphQL
If you're on Hot Chocolate, [UseFiltering] / [UseSorting] / [UseProjection] are doing exactly what §5.4–5.5 do by hand: taking the GraphQL filter AST, building an expression tree, and appending it to your IQueryable so the database does the work. Same for RavenDB's LINQ provider translating your tree to RQL.
Which means the failure modes are identical:
- Return
IQueryable<T>from your resolver, notList<T>. Return a list and you've materialised the table before filtering — the middleware then filters in memory, and your query performance quietly collapses.
- Any C# method the provider can't translate breaks the query. Keep resolver expressions to properties, operators, and provider-supported methods.
- Whitelist sortable/filterable fields. Auto-exposing every property leaks your schema and invites expensive unindexed sorts.
- Cap page size server-side. An expression-built query with no
Takeis a denial-of-service waiting to happen.
7.3 Pipeline behaviours: the delegate chain you already use
MediatR's RequestHandlerDelegate<TResponse> and ASP.NET Core's RequestDelegate are the same idea: each middleware receives the rest of the pipeline as a callback and decides whether, when, and how many times to invoke it.
public sealed class RetryBehaviour<TRequest, TResponse>(ILogger<RetryBehaviour<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next, // ← callback: "the rest of the pipeline"
CancellationToken ct)
{
for (var attempt = 1; ; attempt++)
{
try { return await next(); }
catch (ConcurrencyException) when (attempt < 3)
{
logger.LogWarning("Concurrency conflict, retry {Attempt}", attempt);
await Task.Delay(50 * attempt, ct);
}
}
}
}
(MediatR's exact delegate signature has changed across major versions — v12 takes no arguments, later versions pass the CancellationToken . Check the version you're on.)
Because next is a delegate, a behaviour can: call it once (logging), call it repeatedly (retry), never call it (cache hit, authorisation failure), or wrap it (transaction, tenant scope). That's the whole power of the pattern.
// Short-circuit: never invoke next()
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
if (_cache.TryGetValue(Key(request), out TResponse? cached)) return cached!;
var response = await next();
_cache.Set(Key(request), response, TimeSpan.FromMinutes(5));
return response;
}
7.4 Multi-tenancy: expressions applied to every query
Global query filters are expression trees the ORM injects into every query for an entity:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Shipment>()
.HasQueryFilter(s => s.TenantId == _tenantContext.TenantId && !s.IsDeleted);
}
The gotcha that bites teams in production: the EF Core model is cached per-context-type by default. If your filter captures a value that varies per request, the tree is baked into the cached model and every tenant gets the first tenant's filter. Two correct approaches:
- Reference a field or property on the DbContext instance inside the filter (EF Core parameterises the access rather than the value), and
- Implement
IModelCacheKeyFactoryto include anything that genuinely changes the model shape.
Verify it. Log the generated SQL in a two-tenant integration test and assert the parameter differs. A silent cross-tenant data leak is the worst bug in this document.
Applying a filter generically across all entities implementing an interface — a real use of hand-built expressions:
foreach (var entityType in modelBuilder.Model.GetEntityTypes()
.Where(t => typeof(ITenantScoped).IsAssignableFrom(t.ClrType)))
{
var parameter = Expression.Parameter(entityType.ClrType, "e");
var tenantId = Expression.Property(
Expression.Constant(this), nameof(CurrentTenantId));
var body = Expression.Equal(
Expression.Property(parameter, nameof(ITenantScoped.TenantId)), tenantId);
modelBuilder.Entity(entityType.ClrType)
.HasQueryFilter(Expression.Lambda(body, parameter));
}
7.5 Domain events: in-process vs durable
Restating the most important architectural rule in this document, because it's the one that causes outages:
- C#
event→ in-memory, synchronous, non-durable. Cache invalidation, in-process notifications, plugin hooks. Fine.
- MediatR
INotification→ in-process, still non-durable, but DI-resolved and testable. Fine within a transaction boundary.
- Outbox + Azure Service Bus → the only option when the side effect must survive a crash, must be retried, or crosses a service boundary.
// Inside the same DB transaction as the aggregate change: await _db.OutboxMessages.AddAsync(OutboxMessage.From(domainEvent), ct); await _db.SaveChangesAsync(ct); // A background dispatcher polls the outbox and publishes to Service Bus with retry + DLQ.
If you find yourself adding try/catch + retry + persistence around a C# event, you've reinvented a message broker badly. Switch.
7.6 Progress and cancellation for long-running Functions
For a bulk import running in an Azure Function:
public async Task RunAsync(BlobClient blob, IProgress<ImportProgress>? progress, CancellationToken ct)
{
await foreach (var batch in ReadBatchesAsync(blob, size: 500, ct))
{
ct.ThrowIfCancellationRequested(); // cooperative cancellation
await _repository.BulkStoreAsync(batch, ct);
progress?.Report(new ImportProgress(batch.EndRow));
}
}
Three rules:
- Always accept and honour a
CancellationToken. On Consumption/Flex plans your host will cancel on scale-in and shutdown. Ignoring it
means half-written state.
- Report absolute progress, never deltas —
Progress<T>callbacks can arrive out of order.
- Never
.Wait()or.Resulton the delegate you invoke. Async all the way down, or you'll starve the thread pool.
7.7 Performance rules of thumb
| Rule | Why |
|---|---|
Cache .Compile() results in static readonly or a ConcurrentDictionary | Compilation is orders of magnitude more expensive than invocation |
Prefer static lambdas in hot paths | Compile-time guarantee of zero capture |
Use TState overloads ( GetOrAdd(key, factory, state) ) | Eliminates the closure allocation |
Use LoggerMessage / [LoggerMessage] for hot-path logging | Cached delegate, no boxing, no format parsing |
Prefer source generators over runtime Compile() when shape is known at compile | Zero startup cost, AOT-safe, debuggable |
time
Don't Compile() an expression just to run it once in memory Write a plain lambda instead
Don't micro-optimise closures outside measured hot paths Two allocations per request is noise; readability isn't
7.8 Code review checklist
Print this. It catches most real bugs in this area.
- [ ] Every
+=on an event has a matching-=, or the subscriber outlives the publisher.
- [ ] No scoped/transient service subscribes to a singleton's event.
- [ ] No inline lambda is used where unsubscription will be needed.
- [ ] Events are raised via
handler?.Invoke(...), neverif (h != null) h(...).
- [ ] No
async voidexcept top-level UI handlers, and those wrap everything in try/catch.
- [ ] Callbacks are never invoked while holding a lock.
- [ ] No
.Compile()inside a request path or loop.
- [ ] Dynamic property names from user input are validated against a whitelist.
- [ ] No
AsEnumerable()/ToList()before aWherethat could run in the database.
- [ ] Reusable query rules are
Expression<Func<T,bool>>, notbool Method(T).
- [ ] Multi-tenant query filters verified against generated SQL in a two-tenant test.
- [ ] Anything requiring durability uses the outbox, not a C# event.
Part 8 — Gotchas cheat sheet
| # | Symptom | Cause | Fix |
|---|---|---|---|
| 1 | -= doesn't unsubscribe | Two lambdas are never equal | Store the delegate in a field or use a named method |
| 2 | Loop prints 3 3 3 instead of 0 1 2 | for has one shared variable | Copy to a per-iteration local |
| 3 | NullReferenceException when raising an event | No subscribers; delegate is null | handler?.Invoke(...) |
| 4 | Race: NRE on raise even after a null check | Unsubscribe between check and call | ?.Invoke (snapshots atomically) |
| 5 | Memory grows over hours; objects never collected | Long-lived publisher holds short-lived subscribers | Unsubscribe in Dispose , or return IDisposable subscriptions |
| 6 | Some subscribers never fire | Earlier subscriber threw | Iterate GetInvocationList() with try/catch |
| 7 | Only the last return value is visible | Multicast discards intermediate results | Iterate GetInvocationList() and collect |
| 8 | Process crashes with an unobserved exception | async void handler threw | Use Func<..., Task> handlers; wrap UI handlers in try/catch |
| 9 | The LINQ expression could not be translated | Tree contains a C# method the provider can't read | Convert the rule to Expression<Func<T,bool>> , or move the boundary explicitly |
| 10 | Query loads the whole table | AsEnumerable() / ToList() before | Keep it IQueryable until the last possible moment |
Where
| 11 | variable 'x' ... is not defined | Two different ParameterExpression instances | Rebind parameters with an ExpressionVisitor |
|---|---|---|---|
| 12 | Expression.GreaterThan throws about types | int constant vs decimal property | Expression.Constant(v, typeof(decimal)) or Expression.Convert |
| 13 | First request slow, rest fast | .Compile() on the hot path | Cache the compiled delegate |
| 14 | All tenants see tenant #1's data | Captured value baked into the cached EF model | Reference a DbContext member; add IModelCacheKeyFactory |
| 15 | 100 MB held alive by a timer | Closure captured a large object | Capture only the small value you need |
| 16 | Two lambdas, one leaks | Both share the same generated display class | Move one into a separate method |
| 17 | CS0834: statement body cannot be | Block-bodied lambda assigned to | Use an expression body, or build the tree manually |
converted Expression<>
| 18 | Registrations grow forever | CancellationToken.Register result never disposed | using var reg = token.Register(...) |
|---|---|---|---|
| 19 | Deadlock when raising an event | Handler called inside a lock | Snapshot state, exit the lock, then invoke |
| 20 | Progress bar jumps backwards | Progress<T> reports arrive out of order | Report absolute values, not deltas |
Part 9 — Interview questions and strong answers
Q1. Difference between a delegate and an event? An event is a delegate field plus add / remove accessors. Outside the declaring type you can only subscribe and unsubscribe; you cannot assign, read, or invoke it. Events exist to stop consumers wiping out other subscribers or raising the event on the publisher's behalf. Internally an event is still backed by a multicast delegate.
Q2. Is a delegate mutable? No. += and -= compile to Delegate.Combine / Delegate.Remove , which return a new delegate instance and reassign the variable. Immutability is what makes the ?.Invoke snapshot idiom thread-safe.
Q3. Why doesn't -= with a lambda work? Delegate equality compares Target and Method . Each lambda expression compiles to a distinct generated method, so two textually identical lambdas are different delegates and Remove finds no match.
Q4. What happens if one subscriber throws? The exception propagates immediately out of the raise, and the remaining subscribers in the invocation list never run. If subscribers must be isolated, iterate GetInvocationList() and catch per handler.
Q5. Func<T,bool> vs Expression<Func<T,bool>> ? Func is compiled IL — an opaque delegate you can only invoke. Expression<Func> is an object graph describing the code, which a query provider can walk and translate into SQL/RQL/etc. Enumerable.Where takes the first, Queryable.Where takes the second. That single difference is why EF Core can filter in the database.
Q6. What is a closure, and what does the compiler generate? A lambda that captures enclosing variables. The compiler generates a "display class"; captured locals are promoted from the stack to fields on a heap object, and the lambda becomes a method on that class. Consequences: the lambda sees later mutations to the variable, and capturing costs an allocation.
Q7. Why does a for loop's lambda capture the final value? There's one variable for the entire loop, so all closures share it. foreach was changed in C# 5 to declare a fresh iteration variable per iteration, so foreach no longer has this problem — for still does.
Q8. How would you make event handlers awaitable? Don't use a void -returning C# event. Maintain a list of Func<TArgs, CancellationToken, Task> , snapshot it under a lock, and await each handler (or Task.WhenAll if they're independent). async void handlers can't be awaited and their exceptions crash the process.
Q9. Most common cause of memory leaks in .NET? Event subscriptions. A publisher's delegate holds a strong reference to the subscriber via Target . If the publisher is long-lived and the subscriber isn't, the subscriber can never be collected. Fix by unsubscribing deterministically, returning IDisposable subscriptions, or using weak events.
Q10. How does EF Core turn Where(x => x.Age > 18) into SQL? DbSet<T> implements IQueryable<T> , so the lambda is compiled into an expression tree rather than IL. Queryable.Where appends a MethodCallExpression to the query's expression tree. On enumeration, the provider's ExpressionVisitor walks the tree and emits parameterised SQL.
Q11. When does client evaluation happen, and why is it dangerous? When the tree contains something the provider can't translate. EF Core 3.0+ throws for the Where / OrderBy parts of a query rather than silently evaluating on the client, because silent fallback used to load entire tables into memory. Only the final Select projection is still evaluated client-side by design.
Q12. How do you build a query filter from user input safely? Build the expression tree with Expression.Parameter / Property / Constant , validate every property name against a whitelist, use ExpressionVisitor to rebind parameters when combining predicates, and cap page size. Values become parameters in the generated SQL, so injection isn't a risk — but unvalidated property names are.
Q13. Expression trees vs reflection for dynamic property access? Expression trees compile once to a delegate that runs at near-native speed; reflection pays the cost on every call and is 1–2 orders of magnitude slower. The trade-off is a one-time compile cost and more complex code. Cache the compiled delegate. Where the shape is known at compile time, a source generator beats both.
Q14. When would you use a custom delegate instead of Func / Action ? When you need ref / out / in / params or Span<T> parameters, which Func / Action can't express — or when a domain-meaningful name ( RetryPolicy , TenantResolver ) materially improves readability.
Q15. Explain covariance and contravariance for delegates. Func<in T, out TResult> . Parameters are contravariant: a delegate accepting a more general type can substitute for one accepting a more specific type. Return types are covariant: a delegate returning a more derived type can substitute for one returning a base type. So Func<object,string> is assignable to Func<string,object> .
Q16. Would you use a C# event for a domain event? Not across a service boundary. C# events are in-memory, synchronous and non-durable — they vanish on restart, have no retry and no ordering guarantees. Use them for in-process notification. For anything requiring durability, record the event on the aggregate, persist it to an outbox in the same transaction, and dispatch to a broker.
Q17. How is await related to callbacks? await compiles into a state machine that registers a continuation with the awaited task — the code after the await becomes a callback. It's the same mechanism as ContinueWith or the old BeginXxx / EndXxx pattern, with exception propagation, context capture and composition handled by the compiler.
Part 10 — Exercises
Do these in a scratch console project. They take about an hour and will lock the concepts in.
- 1. Multicast semantics. Build a
Func<int>with three subscribers returning 1, 2, 3. Print the result of invoking it. Then useGetInvocationList()to print all three. Add a subscriber that throws in the middle and observe which ones run.
- 2. Closure capture. Reproduce the
for-loop3 3 3bug. Fix it with a per-iteration local. Then repeat withforeachand confirm the difference.
- 3. Delegate identity. Subscribe with an inline lambda, try to unsubscribe with an identical lambda, and prove it's still subscribed. Then fix it by storing the delegate.
- 4. Event encapsulation. Write a class with a public delegate field and a class with an event. From outside, try to (a) set it to null, (b) invoke it. Note which lines don't compile.
- 5. Leak proof. Create a singleton publisher and 100,000 subscribers that subscribe in their constructor. Force
GC.Collect(), then checkGC.GetTotalMemory(true). AddIDisposableunsubscription and measure again.
- 6. Tree inspection. Write
Expression<Func<Shipment,bool>> e = s => s.WeightKg > 1000 && !s.IsCancelled;and write a recursive method that prints every node'sNodeTypeandType, indented by depth.
- 7. Build a tree by hand. Construct
s => s.WeightKg > 1000using only theExpression.*factory methods. Compile it and verify it gives the same answer as the C# lambda.
- 8. Dynamic sort. Implement
OrderByProperty<T>(string path, bool desc)from §5.4 and use it against an in-memoryIQueryablevia.AsQueryable(). Add the whitelist.
- 9. Predicate builder. Implement
And/Orwith theParameterReplacervisitor. Prove the naiveExpression.AndAlso(a.Body, b.Body)version throws, and yours doesn't.
- 10. Reflection vs compiled expression. Read one property a million times via
PropertyInfo.GetValueand via a cached compiled getter. Time both withStopwatch. Then do it properly with BenchmarkDotNet and compare the allocation columns.
- 11. Async events. Convert a
voidevent into an awaitableList<Func<TArgs, CancellationToken, Task>>withIDisposablesubscriptions. Throw from one handler and confirm the exception reaches the raiser (unlikeasync void).
- 12. The client-evaluation trap. Against a real EF Core provider (SQLite in-memory is enough), write
.Where(x => MyHelper(x))and observe the translation failure. ConvertMyHelperto astatic readonly Expression<Func<T,bool>>and watch it translate.
Closing thought
The five concepts in this document are one concept wearing five hats.
A delegate type names a shape. A lambda fills that shape. A callback is what we call it when someone else holds it. An event is what we call it when we want to control who may raise it. An expression tree is what happens when we choose to read the code instead of running it.
The moment that last idea lands — that s => s.WeightKg > 1000 can be either a compiled function or a data structure describing a comparison, decided entirely by the type you assign it to — LINQ, EF Core, RavenDB, Hot Chocolate, AutoMapper, FluentValidation and half the .NET ecosystem stop being magic and start being obvious.