Skip to content

.NET Core · C# Core

C# Delegates, Events, and the Observer Pattern

7 min read Updated 2026-06-23 Share:

Practice Delegates & Events interview questions

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.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel