Engineering Notes
Home/Languages/C#/Delegates and Events
Languages · C#

Delegates and Events

A delegate is a type whose instances point at a method with a specific signature.

💻 Languages● Advanced⏱ 4 min read🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Delegates: Type-Safe Function References
  • Built-in Generic Delegates
  • Lambdas and Closures
  • Multicast Delegates
  • Events: Delegates With Access Control

1Delegates: Type-Safe Function References

A delegate is a type whose instances point at a method with a specific signature.

csharp
delegate int BinaryOp(int a, int b);

static int Add(int a, int b) => a + b;

BinaryOp op = Add;          // method group conversion
int r = op(2, 3);           // 5 — same as op.Invoke(2, 3)

Delegates are objects: you can store them in fields, pass them as arguments and return them.

2Built-in Generic Delegates

You rarely declare custom delegate types today:

TypeSignatureExample
Action / Action<T1..T16>returns voidAction<string> log = Console.WriteLine;
Func<T1..T16, TResult>returns TResult (last type arg)Func<int, int, int> add = (a, b) => a + b;
Predicate<T>returns boolPredicate<int> isEven = n => n % 2 == 0;

Custom delegates still make sense when the name documents intent (EventHandler<TEventArgs>, RequestDelegate in ASP.NET Core) or when you need ref/out parameters.

3Lambdas and Closures

csharp
Func<int, Func<int, int>> makeAdder = x => y => x + y;
var add5 = makeAdder(5);
add5(10); // 15

int counter = 0;
Action inc = () => counter++;   // captures the VARIABLE, not its value
inc(); inc();
Console.WriteLine(counter);     // 2

4Multicast Delegates

Every delegate can hold an invocation list. += appends, -= removes the last matching entry.

csharp
Action notify = () => Console.WriteLine("email");
notify += () => Console.WriteLine("sms");
notify();          // email, sms (in subscription order)

For a non-void multicast delegate, invoking it returns only the last handler's result. And if one handler throws, the remaining handlers don't run.

5Events: Delegates With Access Control

csharp
public class OrderService
{
    public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;

    public void Place(Order order)
    {
        // ... save ...
        OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(order.Id));
    }
}

public record OrderPlacedEventArgs(int OrderId);   // EventArgs subclass is optional in modern .NET

var svc = new OrderService();
svc.OrderPlaced += (sender, e) => Console.WriteLine($"placed {e.OrderId}");

The event keyword restricts what outside code can do:

Operationpublic delegate fieldpublic event
+= / -= subscribe✅✅
= handler (replace everyone)✅❌ compile error
svc.OrderPlaced(...) (raise)✅❌ only the declaring class
= null (wipe subscribers)✅❌

6The Standard Event Pattern

  • Signature void Handler(object? sender, TEventArgs e) — use EventHandler / EventHandler<T>.
  • Raise through a protected virtual void OnXxx(TEventArgs e) method so subclasses can hook in.
  • Copy-then-invoke (OrderPlaced?.Invoke(...)) is thread-safe against a concurrent unsubscribe because delegates are immutable — ?. reads the field once.
Under the HoodWhat a Delegate and an Event Compile To▾

A delegate type compiles to a sealed class deriving from System.MulticastDelegate, with a runtime-implemented Invoke method. Each instance stores:

  • _target — the object to call the method on (null for static methods; for a capturing lambda, an instance of a compiler-generated closure class that holds the captured variables as fields)
  • _methodPtr — the code address of the method
  • _invocationList — an array of delegates when there's more than one subscriber

+= calls Delegate.Combine, which allocates a new delegate with a new array — delegates never mutate in place. That's why a copy you already read stays stable while other threads subscribe or unsubscribe.

A field-like event compiles to a private delegate field plus add_X/remove_X accessor methods that perform Combine/Remove in a lock-free Interlocked.CompareExchange loop. Outside the class, x.OrderPlaced += h becomes x.add_OrderPlaced(h) — there is no accessor for raise or assign, which is how the compiler enforces the table above.

An event exposes only add and remove accessors; the private backing field holds an immutable multicast delegate whose invocation list points at each handler's target object and method
An event exposes only add and remove accessors; the private backing field holds an immutable multicast delegate whose invocation list points at each handler's target object and method

⚠️ Common Pitfalls

  1. Memory leaks via events. The publisher's invocation list holds a strong reference to every subscriber's _target. A long-lived publisher (static event, singleton) keeps short-lived subscribers alive forever unless they -=.
  2. Unsubscribing a lambda. x.E -= () => Foo(); creates a new delegate that never matches. Store the handler in a variable to remove it.
  3. Raising without a null check — an event with no subscribers is null. Use ?.Invoke.
  4. Closures over loop variables in old for loops capture one shared variable. (foreach gets a fresh variable per iteration since C# 5; for does not.)
  5. Relying on return values or exception flow of multicast delegates — only the last return value survives and one throwing handler stops the rest. Iterate GetInvocationList() when every handler must run.

📋 Quick Reference

NeedUse
callback returning nothingAction<T>
callback returning a valueFunc<T, TResult>
conditionPredicate<T> / Func<T,bool>
publish notificationspublic event EventHandler<TArgs>? Xxx;
raise safelyXxx?.Invoke(this, args);
run every handler even if one throwsforeach (var d in Xxx.GetInvocationList())

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What is a delegate?core▾

A type-safe function pointer: Func<T, TResult>, Action<T>, Predicate<T> or custom delegate types. Delegates can point to methods or lambdas and can be multicast (several methods).

Follow-ups interviewers ask
  • Lambdas and closures? Lambdas capture variables, allocating closures.
Q2What are events and how do they differ from delegates?core▾

An event is a delegate member with restricted access: outside code can only subscribe (+=) or unsubscribe (-=), while only the declaring class can raise it. The standard pattern uses EventHandler<TEventArgs>.

Follow-ups interviewers ask
  • Why restrict? Prevents outsiders from invoking or clearing subscribers.
Q3How can events cause memory leaks?core▾

The publisher holds references to subscribers; if a long-lived publisher keeps short-lived subscribers registered, they're never collected. Unsubscribe in Dispose or use weak event patterns.

Follow-ups interviewers ask
  • Common in? UI apps and static events.
Q4How do you raise events safely?senior▾

Use Handler?.Invoke(this, args) to avoid a race between null check and invocation, and consider exceptions in one subscriber stopping others in multicast invocation.

Follow-ups interviewers ask
  • Async event handlers? async void handlers can't be awaited; consider async callbacks or channels.
Q5When would you use events vs other patterns?core▾

Events suit in-process notifications (UI, domain events within a process). For decoupled processing, use MediatR notifications, Channels, or message brokers across services.

Follow-ups interviewers ask
  • Observer pattern? Events implement it; IObservable/Rx for streams.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.