Skip to content

Delegates & Events Interview Questions & Answers

15 questions Updated 2026-06-23 Share:

C# delegates and events interview questions — Action, Func, multicast delegates, the EventHandler pattern, closures, and memory leaks.

Read the in-depth guideC# Delegates, Events, and the Observer Pattern(opens in new tab)
15 of 15

A delegate is a type-safe function pointer — an object that holds a reference to a method (or multiple methods) with a matching signature. Delegates are first-class objects in C#: they can be stored in fields, passed as parameters, and returned from methods.

// Declare a delegate type — defines the signature methods must match:
delegate int MathOperation(int a, int b);

// Methods that match the signature:
int Add(int a, int b) => a + b;
int Multiply(int a, int b) => a * b;

// Create delegate instances:
MathOperation op = Add;
Console.WriteLine(op(3, 4));    // 7

op = Multiply;
Console.WriteLine(op(3, 4));    // 12

// Pass as a parameter:
int Apply(MathOperation operation, int x, int y) => operation(x, y);
Console.WriteLine(Apply(Add, 5, 6));  // 11

// Assign a lambda:
MathOperation square = (a, _) => a * a;
Console.WriteLine(square(5, 0)); // 25

Rule of thumb: Use the built-in generic delegates (Func, Action, Predicate) instead of declaring custom delegate types — they cover the vast majority of cases with less boilerplate.

These are the three built-in generic delegate families that replace most custom delegate declarations.

// Action<T1, T2, ...> — method that returns void (0–16 params):
Action<string> print = msg => Console.WriteLine(msg);
Action<int, int> printSum = (a, b) => Console.WriteLine(a + b);
print("Hello");    // Hello
printSum(3, 4);    // 7

// Func<T1, ..., TResult> — method that returns TResult (0–16 input params):
Func<int, int, int> add  = (a, b) => a + b;
Func<string, int>   len  = s => s.Length;
Func<bool>          flag = () => DateTime.Now.Hour > 12;

Console.WriteLine(add(3, 4));   // 7
Console.WriteLine(len("hello")); // 5
Console.WriteLine(flag());       // true or false

// Predicate<T> — equivalent to Func<T, bool>:
Predicate<int> isEven = n => n % 2 == 0;
Console.WriteLine(isEven(4)); // True
Console.WriteLine(isEven(7)); // False

// List.FindAll accepts Predicate<T>:
var evens = new List<int> { 1, 2, 3, 4, 5 }.FindAll(isEven); // [2, 4]

Rule of thumb: Use Action for callbacks that don't return a value, Func for callbacks that produce a result. Use Predicate only when APIs require it (e.g., List<T>.FindAll) — otherwise Func<T, bool> is equivalent.

A multicast delegate is a delegate that holds references to more than one method. All C# delegates are inherently multicast. Methods are combined with += and removed with -=. When invoked, each method in the invocation list is called in order.

Action<string> logger = msg => Console.WriteLine($"[LOG] {msg}");
Action<string> auditor = msg => Console.WriteLine($"[AUDIT] {msg}");

// Combine — multicast:
Action<string> combined = logger + auditor;
combined("User signed in");
// [LOG] User signed in
// [AUDIT] User signed in

// += adds to the invocation list:
combined += msg => Console.WriteLine($"[METRICS] {msg}");
combined("Payment processed");
// All three handlers run

// -= removes a specific delegate:
combined -= logger;
combined("Order placed"); // LOG handler no longer called

// Return value: only the LAST delegate's return value is captured
Func<int> d = () => 1;
d += () => 2;
d += () => 3;
Console.WriteLine(d()); // 3 — only last return value

Rule of thumb: Multicast delegates are the mechanism behind events. Be aware that if any handler throws an exception, subsequent handlers in the list will not be called — iterate the invocation list manually if independent error handling per handler is needed.

An event is a field or property of a delegate type with restricted access: only the declaring class can invoke it or assign (=) it. Subscribers can only add (+=) or remove (-=) handlers. This encapsulation is the key difference.

public class Button
{
    // Delegate field — anyone can invoke or overwrite it:
    public Action<string>? ClickedDelegate;

    // Event — subscribers can only += or -=; only Button can invoke:
    public event EventHandler<string>? Clicked;

    public void SimulateClick(string label)
    {
        ClickedDelegate?.Invoke(label); // anyone can also call this
        Clicked?.Invoke(this, label);   // only Button can invoke the event
    }
}

var btn = new Button();

// Event — subscribers:
btn.Clicked += (sender, label) => Console.WriteLine($"Clicked: {label}");

// btn.Clicked("test");           // compile error — can't invoke from outside
// btn.Clicked = null;            // compile error — can't assign from outside

// Delegate field — anyone can do anything:
btn.ClickedDelegate = msg => Console.WriteLine(msg); // overwrites existing handlers!
btn.ClickedDelegate("hi");       // anyone can invoke

Rule of thumb: Always expose callbacks as event in public APIs. Use delegate fields only internally or in private helpers. Events prevent subscribers from accidentally overwriting each other's handlers.

The .NET event convention is to use EventHandler<TEventArgs> as the delegate type, where TEventArgs inherits from EventArgs. The signature always has (object sender, TEventArgs e) so subscribers know who raised the event.

// Custom EventArgs:
public class OrderEventArgs : EventArgs
{
    public int OrderId { get; }
    public decimal Amount { get; }
    public OrderEventArgs(int id, decimal amount) => (OrderId, Amount) = (id, amount);
}

public class OrderService
{
    // Standard event declaration:
    public event EventHandler<OrderEventArgs>? OrderPlaced;

    // Protected virtual raise method — allows subclasses to override:
    protected virtual void OnOrderPlaced(OrderEventArgs e)
        => OrderPlaced?.Invoke(this, e);

    public void PlaceOrder(int id, decimal amount)
    {
        // ... business logic ...
        OnOrderPlaced(new OrderEventArgs(id, amount));
    }
}

// Subscriber:
var svc = new OrderService();
svc.OrderPlaced += (sender, e) =>
    Console.WriteLine($"Order {e.OrderId} placed for £{e.Amount}");

svc.PlaceOrder(42, 99.99m); // Order 42 placed for £99.99

Why this pattern:

  • sender lets subscribers identify the source without coupling to a specific type.
  • TEventArgs is extensible — add new data without breaking existing subscribers.
  • ?.Invoke(this, e) is thread-safe null check + invocation.

Rule of thumb: Use EventHandler<TEventArgs> for all public events. Use EventArgs.Empty if you have no data to send. Put the raise logic in a protected virtual OnXxx method to enable subclassing.

A lambda expression is an anonymous function defined inline with the => ("goes to") syntax. The compiler converts it to a delegate instance (or an expression tree when assigned to Expression<TDelegate>).

// Expression lambda — single expression, implicit return:
Func<int, int> square = x => x * x;
Console.WriteLine(square(5)); // 25

// Statement lambda — block body, explicit return:
Func<int, int, int> clamp = (value, max) =>
{
    if (value > max) return max;
    return value;
};
Console.WriteLine(clamp(15, 10)); // 10

// Discards for unused parameters:
Action<string, int> print = (msg, _) => Console.WriteLine(msg);

// Captures local variables (closure):
int multiplier = 3;
Func<int, int> tripler = n => n * multiplier;
multiplier = 5;
Console.WriteLine(tripler(4)); // 20 — captures the VARIABLE, not the value!

// Expression tree (for LINQ providers like EF Core):
Expression<Func<int, bool>> expr = x => x > 10;
// EF Core can translate this expression tree to SQL WHERE x > 10

Rule of thumb: Prefer lambda expressions over named private methods for short, single-use callbacks. Be aware of closure capture — the lambda captures the variable reference, not the value at the time of definition.

A closure is a lambda (or anonymous method) that captures variables from its enclosing scope. The lambda holds a reference to the variable, not a copy of its value at the time the lambda is created.

// Classic loop-capture bug:
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
    actions.Add(() => Console.WriteLine(i)); // captures 'i' by reference
}
foreach (var a in actions) a(); // prints 3, 3, 3 — loop is done, i == 3

// Fix: capture a copy inside the loop:
var actions2 = new List<Action>();
for (int i = 0; i < 3; i++)
{
    int copy = i;             // new variable per iteration
    actions2.Add(() => Console.WriteLine(copy));
}
foreach (var a in actions2) a(); // prints 0, 1, 2 — correct!

// foreach loop does NOT have this issue in C# 5+:
var items = new[] { "a", "b", "c" };
var prints = items.Select(item => (Action)(() => Console.Write(item))).ToList();
foreach (var p in prints) p(); // a b c — each iteration creates a fresh variable

The compiler generates a display class (a heap-allocated object) to hold captured variables so they outlive the scope they were declared in.

Rule of thumb: When capturing a loop variable in a lambda, always copy it to a new local variable first. With foreach, this is done for you since C# 5.

Covariance means a delegate returning Derived can be used where Base is expected (return type is more specific — safe because Derived IS-A Base). Contravariance means a delegate accepting Base can be used where a Derived parameter is expected (parameter type is more general — safe because the handler accepts anything including Derived).

class Animal { }
class Dog : Animal { }

// Covariance on return type (out):
// Func<Dog> is assignable to Func<Animal> because Dog : Animal
Func<Dog>    getDog    = () => new Dog();
Func<Animal> getAnimal = getDog; // covariance — Func<Dog> → Func<Animal>
Animal a = getAnimal();

// Contravariance on parameter type (in):
// Action<Animal> is assignable to Action<Dog> because any Animal handler handles Dogs too
Action<Animal> feedAnimal = (animal) => Console.WriteLine("feeding animal");
Action<Dog>    feedDog    = feedAnimal; // contravariance — Action<Animal> → Action<Dog>
feedDog(new Dog()); // safe — the handler accepts any Animal

// Built-in generic delegates are already covariant/contravariant:
// Func<in T, out TResult>  — T is contravariant (in), TResult is covariant (out)
// Action<in T>             — T is contravariant

Rule of thumb: Covariance = return types can be more specific (out). Contravariance = parameter types can be more general (in). These match the Liskov Substitution Principle applied to delegate assignability.

When an object subscribes to an event on a longer-lived publisher, the publisher holds a reference to the subscriber via the delegate invocation list. This prevents the subscriber from being garbage collected even if no other references exist — a classic event memory leak.

public class Publisher
{
    public event EventHandler? DataChanged;
    // Publisher holds references to ALL subscribers
}

public class Subscriber
{
    public Subscriber(Publisher pub)
    {
        pub.DataChanged += OnDataChanged; // pub holds reference to 'this'
    }
    private void OnDataChanged(object? sender, EventArgs e) { }
}

var pub = new Publisher();
{
    var sub = new Subscriber(pub);
    // 'sub' goes out of scope — but pub.DataChanged still references it!
}
// sub is NOT garbage collected — pub keeps it alive
GC.Collect(); // sub survives — memory leak!

Solutions:

  1. Unsubscribe explicitly (-=) in Dispose() or when done.
  2. Weak event pattern — use WeakReference<T> or the WPF WeakEventManager.
public class Subscriber : IDisposable
{
    private readonly Publisher _pub;
    public Subscriber(Publisher pub)
    {
        _pub = pub;
        _pub.DataChanged += OnDataChanged;
    }
    private void OnDataChanged(object? sender, EventArgs e) { }
    public void Dispose() => _pub.DataChanged -= OnDataChanged; // unsubscribe!
}

Rule of thumb: Always unsubscribe from events in Dispose() when the subscriber has a shorter lifetime than the publisher. This is one of the most common sources of memory leaks in long-running .NET applications.

C# events are a language-native implementation of the observer pattern — a publisher notifies multiple subscribers of state changes without coupling to them. IObservable<T> / IObserver<T> (Reactive Extensions / Rx.NET) is the richer, composable alternative for streaming data.

// Events — the built-in observer:
public class StockTicker
{
    public event EventHandler<decimal>? PriceChanged;
    public void SetPrice(decimal p) => PriceChanged?.Invoke(this, p);
}

var ticker = new StockTicker();
ticker.PriceChanged += (_, price) => Console.WriteLine($"Price: {price:C}");
ticker.SetPrice(99.50m); // Price: £99.50

// IObservable<T> — composable push-based streams (Rx.NET):
IObservable<decimal> prices = Observable
    .Interval(TimeSpan.FromSeconds(1))
    .Select(_ => (decimal)Random.Shared.NextDouble() * 100);

IDisposable sub = prices
    .Where(p => p > 80)          // filter
    .Select(p => Math.Round(p, 2)) // project
    .Subscribe(
        onNext:      p => Console.WriteLine($"High price: {p}"),
        onError:     ex => Console.WriteLine($"Error: {ex.Message}"),
        onCompleted: () => Console.WriteLine("Stream ended"));

// Unsubscribe:
sub.Dispose();

Rule of thumb: Use C# events for simple notification patterns within a component or between tightly related objects. Use IObservable<T> / Rx.NET when you need composable, filterable, time-based, or asynchronous event streams.

Because delegates are objects, you can combine them using higher-order functions — methods that take or return delegates. This enables functional-style pipelines.

// Compose two Func<T,T> transforms into one:
static Func<T, T> Compose<T>(Func<T, T> first, Func<T, T> second)
    => value => second(first(value));

Func<string, string> trim   = s => s.Trim();
Func<string, string> upper  = s => s.ToUpper();
Func<string, string> exclaim = s => s + "!";

var pipeline = Compose(Compose(trim, upper), exclaim);
Console.WriteLine(pipeline("  hello  ")); // "HELLO!"

// Delegate chaining — multicast style for void pipelines:
Action<string> log    = msg => Console.WriteLine($"[LOG] {msg}");
Action<string> audit  = msg => Console.WriteLine($"[AUDIT] {msg}");
Action<string> notify = log + audit; // multicast
notify("User deleted"); // both run in order

// LINQ uses Func<T,T> pipeline extensively:
var result = Enumerable.Range(1, 10)
    .Where(n => n % 2 == 0)   // Func<int,bool>
    .Select(n => n * n)        // Func<int,int>
    .Aggregate((a, b) => a + b); // Func<int,int,int>
Console.WriteLine(result); // 4+16+36+64+100 = 220

Rule of thumb: Build delegate pipelines when the same transformation sequence applies to multiple inputs or must be configured at runtime. For simple one-off transformations, inline lambdas are clearer than a composed pipeline.

Anonymous methods (introduced in C# 2) are inline delegate implementations written with the delegate keyword. Lambda expressions (C# 3) are the modern replacement — shorter and more powerful.

// Anonymous method (C# 2 syntax):
Func<int, int> squareOld = delegate(int x) { return x * x; };
Action<string> printOld  = delegate(string msg) { Console.WriteLine(msg); };

// Lambda expression (C# 3 — preferred):
Func<int, int> squareNew = x => x * x;
Action<string> printNew  = msg => Console.WriteLine(msg);

// Anonymous method unique feature: parameter list can be omitted
// when you don't need the parameters (lambdas cannot do this):
button.Click += delegate { Console.WriteLine("Clicked!"); };
// vs lambda (must declare params):
button.Click += (sender, e) => Console.WriteLine("Clicked!");

// Both are closures — both capture outer variables:
int counter = 0;
Action inc1 = delegate { counter++; };
Action inc2 = () => counter++;
inc1(); inc2();
Console.WriteLine(counter); // 2

Rule of thumb: Always prefer lambda expressions over anonymous methods. The only case where the delegate syntax is still useful is when you want to subscribe to an event ignoring all parameters without declaring them.

A delegate holds compiled executable code. An expression tree (Expression<TDelegate>) holds a data structure describing the code as data — a tree of nodes representing operations, parameters, and constants. LINQ providers like Entity Framework Core translate expression trees to SQL rather than executing them as .NET code.

// Delegate — compiled, executable:
Func<int, bool> isEvenDelegate = x => x % 2 == 0;
bool result = isEvenDelegate(4); // runs .NET IL directly

// Expression tree — data structure, inspectable:
Expression<Func<int, bool>> isEvenExpr = x => x % 2 == 0;
// NOT executable directly until compiled:
Func<int, bool> compiled = isEvenExpr.Compile();
bool result2 = compiled(4); // now runs

// EF Core translates the expression tree to SQL:
var users = dbContext.Users
    .Where(u => u.Age > 30)  // Expression<Func<User, bool>> — sent to DB as SQL
    .ToList();

// Building expression trees dynamically:
ParameterExpression param  = Expression.Parameter(typeof(int), "x");
BinaryExpression    greaterThan = Expression.GreaterThan(param, Expression.Constant(10));
Expression<Func<int, bool>> dynamic = Expression.Lambda<Func<int, bool>>(greaterThan, param);
Console.WriteLine(dynamic.Compile()(15)); // True
Console.WriteLine(dynamic.Compile()(5));  // False

// Inspecting the tree:
var body = (BinaryExpression)isEvenExpr.Body;
Console.WriteLine(body.NodeType); // Modulo (then Equal)

Rule of thumb: Use Func<T> delegates for in-process logic. Use Expression<Func<T>> when the logic must be translated to another query language (SQL, OData, LDAP). Never pass a lambda as Expression<> to a non-LINQ API — it will be compiled to a delegate and the expression tree is wasted.

Event invocation has a classic race condition: checking the delegate for null and then invoking it is not atomic. A subscriber can unsubscribe between the null check and the call, causing a NullReferenceException.

public class Sensor
{
    public event EventHandler<double>? ReadingChanged;

    // BAD — race condition: ReadingChanged may become null between check and invoke
    private void RaiseBad(double value)
    {
        if (ReadingChanged != null)          // another thread may -= here
            ReadingChanged(this, value);     // NullReferenceException possible!
    }

    // GOOD — copy the delegate reference first (delegates are immutable):
    private void RaiseGood(double value)
    {
        EventHandler<double>? handler = ReadingChanged; // atomic read
        handler?.Invoke(this, value); // safe: handler is a local snapshot
    }

    // IDIOMATIC — ?.Invoke is equivalent and compiler-recommended:
    private void RaiseIdiomatic(double value)
        => ReadingChanged?.Invoke(this, value); // thread-safe null check + invoke
}

Why ?.Invoke is safe: the ?. operator evaluates the left side once and stores the result. Even if the event is unsubscribed on another thread after the null check, the local copy of the delegate is still valid (delegates are immutable reference types).

// Note: individual handler exceptions are NOT isolated —
// if one handler throws, the rest of the invocation list is skipped.
// For independent handler execution, iterate the list manually:
foreach (Delegate d in (ReadingChanged?.GetInvocationList() ?? []))
{
    try { d.DynamicInvoke(this, value); }
    catch (Exception ex) { logger.LogError(ex, "Handler failed"); }
}

Rule of thumb: Always raise events using ?.Invoke(this, args) to avoid the null-check race condition. Delegates are immutable, so the local copy captured by ?. is stable even if subscribers change concurrently.

Both delegates and single-method interfaces define a contract for a callable. The choice comes down to flexibility, allocation cost, and expressiveness.

// Interface approach — requires a named class to implement it:
public interface ITransformer
{
    string Transform(string input);
}

public class UpperCaseTransformer : ITransformer
{
    public string Transform(string input) => input.ToUpper();
}

// Delegate approach — any matching lambda or method works without a class:
public class Pipeline
{
    private readonly Func<string, string> _transform;
    public Pipeline(Func<string, string> transform) => _transform = transform;
    public string Run(string input) => _transform(input);
}

// Caller: inline lambda — no class required
var pipe = new Pipeline(s => s.ToUpper());
Console.WriteLine(pipe.Run("hello")); // HELLO

// Multicast: delegates support multiple handlers; interfaces do not
Func<string, string> chain = s => s.Trim();
// Delegates are combined with multicast; interfaces cannot stack
// (you would need a composite pattern for interfaces)

// Interface advantages:
// - Can carry state naturally (fields on the implementing class)
// - Supports multiple methods in one contract
// - Works better with DI containers (registered by interface type)
// - Easier to mock in unit tests

// Delegate advantages:
// - No ceremony: any lambda or method group works
// - Multicast: combine multiple handlers with +=
// - Lower allocation for short-lived callbacks (no class required)

Rule of thumb: Use a delegate (Func / Action / custom) for single-operation callbacks that are short-lived or supplied inline. Use an interface when the contract has multiple methods, requires DI registration, or implementations carry significant state. Prefer Func / Action over declaring a custom single-method interface in library code.

More ways to practice

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

or
Join our WhatsApp Channel