Why delegates and events come up in every C# interview
Delegates are the foundation of C#'s callback model. Events are the pub-sub mechanism built on top of them. Understanding both — and the pitfalls around memory leaks, closures, and multicast invocation — separates candidates who use these features from candidates who truly understand them.
What a delegate is
A delegate is a type-safe function pointer. Unlike C function pointers, a delegate is an object — it can be stored, passed as a parameter, returned from a method, and combined with other delegates.
// Declare a delegate type — defines the method signature:
delegate string Transform(string input);
// Assign any method that matches the signature:
Transform upper = s => s.ToUpper();
Transform lower = s => s.ToLower();
Console.WriteLine(upper("hello")); // HELLO
Console.WriteLine(lower("WORLD")); // world
// Pass as a parameter:
string ApplyTwice(Transform t, string s) => t(t(s));
Console.WriteLine(ApplyTwice(upper, "test")); // TEST
In practice, you almost never declare custom delegate types because the built-in generics cover every case.
Action, Func, and Predicate
// Action<T1..T16> — void return:
Action log = () => Console.WriteLine("event");
Action<string> print = msg => Console.WriteLine(msg);
Action<string, int> repeat = (msg, n) => Enumerable.Range(0, n).ToList().ForEach(_ => Console.WriteLine(msg));
// Func<T1..T16, TResult> — non-void return:
Func<int, int> square = n => n * n;
Func<string, int> len = s => s.Length;
Func<int, int, double> divide = (a, b) => (double)a / b;
Console.WriteLine(square(5)); // 25
Console.WriteLine(divide(7, 2)); // 3.5
// Predicate<T> — Func<T, bool> with a different name:
Predicate<string> nonEmpty = s => s.Length > 0;
var names = new List<string> { "", "Alice", "", "Bob" };
var valid = names.FindAll(nonEmpty); // ["Alice", "Bob"]
Rule: Always prefer Action/Func over custom delegate types. Custom delegates
are only useful when you need XML-doc support for a specific callback or when a legacy
API requires a named type.
Multicast delegates
All C# delegates are multicast — they can hold references to multiple methods:
Action<string> log = msg => Console.WriteLine($"[LOG] {msg}");
Action<string> audit = msg => Console.WriteLine($"[AUDIT] {msg}");
Action<string> metrics = msg => Console.WriteLine($"[METRICS] {msg}");
// Combine:
Action<string> all = log + audit + metrics;
all("User logged in");
// [LOG] User logged in
// [AUDIT] User logged in
// [METRICS] User logged in
// += adds a handler:
Action<string> chain = log;
chain += audit;
chain("Order placed");
// -= removes a specific handler:
chain -= audit;
chain("Payment processed"); // only log runs
When a multicast delegate has a return type, only the last invocation's return value is captured — earlier ones are discarded. If any handler throws, subsequent handlers in the invocation list do not run.
Func<int> counter = () => 1;
counter += () => 2;
counter += () => 3;
Console.WriteLine(counter()); // 3 — only last return value
Events — encapsulated multicast delegates
An event is a delegate with restricted access. Outside the declaring class, you can only
+= or -=; you cannot invoke or overwrite the delegate directly:
public class Button
{
// Public delegate field — anyone can invoke or assign:
public Action<string>? Clicked;
// Event — only Button can invoke; subscribers can only += or -=:
public event Action<string>? ClickedEvent;
public void Click(string label)
{
Clicked?.Invoke(label);
ClickedEvent?.Invoke(label);
}
}
var btn = new Button();
btn.Clicked = msg => Console.WriteLine(msg); // overwrites existing handlers!
btn.ClickedEvent += msg => Console.WriteLine(msg); // adds to invocation list
// btn.ClickedEvent("test"); // compile error outside Button — good!
This encapsulation prevents one subscriber from accidentally replacing another's handler.
The standard EventHandler pattern
.NET's convention for events pairs EventHandler<TEventArgs> with a custom EventArgs
subclass and a protected virtual OnXxx raise method:
public class StockEventArgs : EventArgs
{
public string Symbol { get; }
public decimal Price { get; }
public StockEventArgs(string symbol, decimal price)
=> (Symbol, Price) = (symbol, price);
}
public class StockFeed
{
public event EventHandler<StockEventArgs>? PriceChanged;
protected virtual void OnPriceChanged(StockEventArgs e)
=> PriceChanged?.Invoke(this, e); // thread-safe null check + invoke
public void UpdatePrice(string symbol, decimal price)
{
// ... internal logic ...
OnPriceChanged(new StockEventArgs(symbol, price));
}
}
// Subscriber:
var feed = new StockFeed();
feed.PriceChanged += (sender, e) =>
Console.WriteLine($"{e.Symbol}: {e.Price:C}");
feed.UpdatePrice("MSFT", 420.00m); // MSFT: £420.00
The protected virtual raise method lets subclasses override the raise behavior without
duplicating subscription logic.
Closures and the loop-capture bug
Lambdas capture the variable, not the value at the time of capture:
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
actions.Add(() => Console.WriteLine(i)); // captures the variable 'i'
foreach (var a in actions) a(); // prints: 3, 3, 3 — loop finished, i == 3!
// Fix: copy to a new local per iteration:
var fixed = new List<Action>();
for (int i = 0; i < 3; i++)
{
int copy = i; // new variable each iteration
fixed.Add(() => Console.WriteLine(copy));
}
foreach (var a in fixed) a(); // 0, 1, 2 — correct!
The foreach version avoids this in C# 5+: each iteration implicitly creates a new
range variable. The for loop does not.
The event memory leak
The most common source of memory leaks in .NET applications:
public class EventSource
{
public event EventHandler? DataChanged;
}
public class Subscriber
{
public Subscriber(EventSource source)
{
source.DataChanged += OnDataChanged; // source holds reference to 'this'
}
private void OnDataChanged(object? sender, EventArgs e) { }
}
var source = new EventSource(); // long-lived singleton
{
var sub = new Subscriber(source);
} // 'sub' out of scope — but source.DataChanged still references it!
GC.Collect(); // sub is NOT collected — source keeps it alive!
Fix: always unsubscribe in Dispose:
public class Subscriber : IDisposable
{
private readonly EventSource _source;
public Subscriber(EventSource source)
{
_source = source;
_source.DataChanged += OnDataChanged;
}
private void OnDataChanged(object? sender, EventArgs e) { }
public void Dispose()
{
_source.DataChanged -= OnDataChanged; // unsubscribe — reference released
}
}
Always implement IDisposable and unsubscribe from events when the subscriber has a
shorter intended lifetime than the publisher.
Covariance and contravariance
Delegates support covariance on return types (more derived) and contravariance on parameter types (more general):
class Animal { }
class Dog : Animal { }
// Covariance — Func<Dog> assignable to Func<Animal>:
Func<Dog> getDog = () => new Dog();
Func<Animal> getAnimal = getDog; // Dog IS-A Animal
// Contravariance — Action<Animal> assignable to Action<Dog>:
Action<Animal> feedAnimal = a => Console.WriteLine($"Feeding {a.GetType().Name}");
Action<Dog> feedDog = feedAnimal; // any Animal handler can handle a Dog
feedDog(new Dog());
Func is out-covariant on TResult; in-contravariant on each T parameter.
Action is in-contravariant. This is why Func<Dog> is a valid Func<Animal> —
it satisfies the Liskov Substitution Principle at the delegate level.
IObservable — when events aren't enough
For time-based, composable, or filtered event streams, the Rx.NET IObservable<T> model
is more powerful:
// Reactive extensions — composable event streams:
IObservable<decimal> prices = Observable
.Interval(TimeSpan.FromMilliseconds(100))
.Select(_ => (decimal)(Random.Shared.NextDouble() * 100));
IDisposable sub = prices
.Where(p => p > 80) // filter: only high prices
.Buffer(TimeSpan.FromSeconds(1)) // batch: 1-second windows
.Subscribe(batch =>
Console.WriteLine($"High prices this second: {batch.Count}"));
await Task.Delay(5000);
sub.Dispose(); // unsubscribe — cleanup
Use events for simple notification within a bounded component. Use IObservable<T> for
real-time streams, throttling, debouncing, time-windowed aggregation, or merging multiple
event sources.
Recap
Delegates are type-safe function pointers; Action, Func, and Predicate cover most
needs without custom declarations. All delegates are multicast — += and -= manage the
invocation list. Events restrict delegate access: only the declaring class can invoke or
assign; subscribers can only add or remove handlers. Use EventHandler<TEventArgs> with
a protected virtual OnXxx raise method as the standard event pattern. Beware the
loop-capture bug: copy loop variables before closing over them in lambdas. Always
unsubscribe from events in Dispose when the subscriber is shorter-lived than the
publisher — this is the most common event-related memory leak. For complex, composable
event streams, prefer IObservable<T> from Rx.NET over raw events.