Articles › C#
Delegates, Events and Lambdas in C#: From Basics to Real Patterns
Understand delegates, Func and Action, lambdas, closures, multicast delegates, events and the EventHandler pattern in C# — and when to prefer interfaces or IObservable.
Delegates are how C# treats behavior as data: you can store a method in a variable, pass it to another method and call it later. LINQ, ASP.NET Core minimal APIs, event handlers in WPF, retry policies and middleware are all built on delegates. This article explains delegates, lambdas and events from the ground up and shows the patterns you'll meet in real code.
What is a delegate?
A delegate is a type-safe reference to a method with a specific signature.
public delegate decimal DiscountRule(decimal price);
decimal TenPercent(decimal price) => price * 0.9m;
decimal FlatFive(decimal price) => Math.Max(0, price - 5);
DiscountRule rule = TenPercent;
Console.WriteLine(rule(100)); // 90
rule = FlatFive;
Console.WriteLine(rule(100)); // 95
The variable rule holds which method to run. The calling code doesn't care which discount is applied — it only knows the signature.
Built-in delegates: Func, Action and Predicate
You rarely declare custom delegate types anymore. .NET provides generic ones:
| Type | Signature | Example |
|---|---|---|
Action |
no return value | Action<string> log = msg => Console.WriteLine(msg); |
Func<…, TResult> |
returns a value (last type argument) | Func<int, int, int> add = (a, b) => a + b; |
Predicate<T> |
returns bool |
Predicate<int> isEven = n => n % 2 == 0; |
Func<decimal, decimal> rule = price => price * 0.9m;
decimal ApplyRules(decimal price, IEnumerable<Func<decimal, decimal>> rules) =>
rules.Aggregate(price, (current, apply) => apply(current));
var final = ApplyRules(1_000_000, [p => p * 0.9m, p => p - 50_000]);
Custom delegate types still make sense when a name adds meaning (DiscountRule) or when you need ref/out parameters.
Lambda expressions
A lambda is an inline anonymous function:
Func<int, int> square = x => x * x; // expression lambda
Func<int, int, int> max = (a, b) => a > b ? a : b;
Action<string> greet = name =>
{ // statement lambda
var message = $"Hello, {name}!";
Console.WriteLine(message);
};
var adults = people.Where(p => p.Age >= 18); // lambdas everywhere in LINQ
Since C# 10, lambdas can have a natural type, explicit return types and attributes:
var parse = (string s) => int.Parse(s); // inferred as Func<string, int>
var choose = object (bool b) => b ? 1 : "one"; // explicit return type
Closures: lambdas capture variables
Lambdas can use variables from the surrounding scope. The compiler captures them — the lambda sees the variable itself, not a copy of its value.
int counter = 0;
Action increment = () => counter++;
increment();
increment();
Console.WriteLine(counter); // 2
Captured variables live as long as the lambda does. Be careful with long-lived lambdas (event handlers, static caches) that capture large objects — they keep them in memory.
Use static lambdas when you don't intend to capture anything; the compiler will stop accidental captures:
Func<int, int> doubler = static x => x * 2;
Methods that take behavior
Accepting a delegate makes methods flexible without inheritance:
public static async Task<T> RetryAsync<T>(
Func<Task<T>> operation, int attempts = 3, TimeSpan? delay = null)
{
for (var i = 1; ; i++)
{
try
{
return await operation();
}
catch (HttpRequestException) when (i < attempts)
{
await Task.Delay(delay ?? TimeSpan.FromMilliseconds(200 * i));
}
}
}
var rates = await RetryAsync(() => http.GetFromJsonAsync<Rates>("/api/rates"));
ASP.NET Core minimal APIs are the same idea at framework level — each endpoint is a delegate:
app.MapGet("/articles/{slug}", async (string slug, IArticleService service) =>
await service.GetAsync(slug) is { } article ? Results.Ok(article) : Results.NotFound());
Multicast delegates
Delegates can hold several methods. Invoking the delegate calls them in order:
Action<string> notify = msg => Console.WriteLine($"Console: {msg}");
notify += msg => File.AppendAllText("log.txt", msg + Environment.NewLine);
notify("Order #42 shipped"); // both run
notify -= ...; // remove a handler (needs the same instance)
For Func multicast delegates only the last return value is kept — multicast is meant for notifications, not computations.
Events: safe multicast delegates
An event is a delegate field with restricted access: outside code can only subscribe (+=) and unsubscribe (-=); only the owning class can raise it.
public class OrderPlacedEventArgs(int orderId, decimal total) : EventArgs
{
public int OrderId { get; } = orderId;
public decimal Total { get; } = total;
}
public class OrderService
{
public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;
public void Place(Order order)
{
// ... save
OnOrderPlaced(new OrderPlacedEventArgs(order.Id, order.Total));
}
protected virtual void OnOrderPlaced(OrderPlacedEventArgs e) =>
OrderPlaced?.Invoke(this, e); // ?. avoids NullReferenceException when no subscribers
}
var service = new OrderService();
service.OrderPlaced += (sender, e) => Console.WriteLine($"Send receipt for {e.OrderId}");
The .NET convention: EventHandler<TEventArgs> with (object? sender, TEventArgs e), a protected virtual On… method to raise it, and event args deriving from EventArgs.
Avoiding memory leaks with events
A subscriber is referenced by the publisher's event. If a long-lived publisher (a static service) holds handlers from short-lived subscribers (windows, view models), those subscribers can never be garbage-collected. Always unsubscribe when the subscriber is done:
public void Dispose() => _service.OrderPlaced -= HandleOrderPlaced;
Events vs alternatives
| Need | Good fit |
|---|---|
| UI notifications in WPF/WinForms | events, INotifyPropertyChanged |
| In-process domain notifications | domain events + a dispatcher (e.g. MediatR notifications) |
| Streams of values over time | IObservable<T> / Reactive Extensions, IAsyncEnumerable<T>, Channel<T> |
| Cross-service communication | message brokers (RabbitMQ, Kafka) |
| Pluggable strategy with several methods | an interface |
Events are perfect for UI and simple in-process notifications. For async work, note that event handlers are synchronous by nature — an async void handler can't be awaited and its exceptions are hard to handle.
Expression trees: lambdas as data
When a lambda is assigned to Expression<Func<…>> instead of Func<…>, the compiler produces a data structure describing the code rather than executable code. That's how EF Core turns your LINQ into SQL:
Expression<Func<Product, bool>> cheap = p => p.Price < 100; // inspectable tree
Func<Product, bool> cheapFunc = cheap.Compile(); // executable delegate
Summary
Delegates let you pass behavior around as values; Func, Action and Predicate cover almost every need; lambdas make them concise and can capture surrounding variables through closures. Events wrap multicast delegates in a safe publish/subscribe API — just remember to unsubscribe. With these tools you can build flexible APIs, retry helpers, pipelines and notification systems without heavy inheritance hierarchies.
FAQ
What's the difference between a delegate and an interface?
A delegate represents a single method signature; an interface represents a contract with possibly many members. Use delegates for single operations and callbacks, interfaces for richer abstractions.
Are lambdas slower than normal methods?
Invocation through a delegate has a tiny overhead, and closures allocate. It only matters in very hot paths.
Why is the event raised with ?.Invoke?
If nobody subscribed, the event field is null. The null-conditional operator avoids a NullReferenceException and is thread-safe for the check.