Sitemap

Delegates & Events in C#: What .NET Interviews Actually Ask

6 min readSep 30, 2026

--

Delegates quietly power something you’ve already used constantly — every LINQ method like Where and Select is built on them. This covers what a delegate actually is, the Func/Action/Predicate shortcuts, multicast delegates, and why event exists as a separate, restricted thing.

Press enter or click to view image in full size

What a Delegate Actually Is

A delegate is a type that represents a method. Instead of holding a number or a string, it holds a reference to a method you can call later.

public delegate int MathOperation(int a, int b);

public int Add(int a, int b) => a + b;
MathOperation op = Add;
int result = op(3, 4); // calls Add(3, 4) → 7

op isn't the number 7, and it isn't the method itself either. It's a reference to the method — something you can call, store in a variable, or pass into another method.

Why This Matters

It lets you treat behavior as data — pass a method into another method the same way you’d pass an int or a string.

public void Calculate(int a, int b, MathOperation operation)
{
int result = operation(a, b);
Console.WriteLine(result);
}
Calculate(3, 4, Add);
Calculate(3, 4, Subtract); // same Calculate method, different behavior plugged in

Calculate doesn't need to know in advance whether it'll add or subtract. The caller decides, by handing over a different method.

Func, Action, Predicate — the Built-In Shortcuts

These are the same tools used throughout LINQ, just under their formal names.

Func<int, int, int> add = (a, b) => a + b;      // takes params, RETURNS a value
Action<string> log = message => Console.WriteLine(message); // takes params, returns NOTHING
Predicate<int> isEven = n => n % 2 == 0; // takes ONE param, returns bool
  • Func<T1, T2, ..., TResult> — the last type parameter is always the return type.
  • Action<T1, T2, ...> — always returns void.
  • Predicate<T> — always takes exactly one parameter and returns bool.

This Is What Powers LINQ

numbers.Where(n => n > 1);

Simplified, Where looks roughly like this underneath:

public IEnumerable<int> Where(IEnumerable<int> source, Func<int, bool> condition)
{
var result = new List<int>();
foreach (var item in source)
{
if (condition(item)) // calling the delegate — YOUR logic, once per item
{
result.Add(item);
}
}
return result;
}

Where itself has no idea what condition you actually want. It doesn't contain > 1 anywhere in its own code. It just knows it will be handed some method that takes an int and returns true or false, and it calls that method once per item, keeping the ones that return true.

Change n => n > 1 to n => n > 100, and Where's own code doesn't change at all — only the delegate passed in changed, so the filtering behavior changed entirely from the outside.

Multicast Delegates

One delegate can hold multiple methods at once.

Action greet = () => Console.WriteLine("Hello");
greet += () => Console.WriteLine("Hi");
greet += () => Console.WriteLine("Hey");

greet(); // Hello, Hi, Hey - all three run, in order

+= adds a method. -= removes one.

Action greet = () => Console.WriteLine("A");
greet += () => Console.WriteLine("B");
greet += () => Console.WriteLine("C");
greet -= () => Console.WriteLine("B");

greet(); // A, C - B was added and removed before ever firing

If a multicast delegate has a return type, calling it only gives back the last method’s return value — the others still run, but their return values are silently discarded. This is one reason Action is far more common than Func for multicast scenarios.

Events — a Restricted Delegate

public class Button
{
public event Action Clicked;
public void SimulateClick()
{
Clicked?.Invoke();
}
}
var button = new Button();
button.Clicked += () => Console.WriteLine("Button was clicked!");
button.SimulateClick();

Action describes the shape of the method being referenced — here, no parameters, no return value. event adds restrictions on top of that shape, controlling what outside code is allowed to do with it.

Get Anchalmehandiratta’s stories in your inbox

Join Medium for free to get updates from this writer.

Without event, a plain delegate field:

public Action Clicked; // no event keyword

button.Clicked += () => Console.WriteLine("Handler 1"); // fine
button.Clicked = () => Console.WriteLine("Only this now"); // wipes out Handler 1 entirely
button.Clicked(); // outside code can invoke it directly, whenever it wants

With event Action:

button.Clicked += () => Console.WriteLine("Handler 1"); // fine
button.Clicked = () => Console.WriteLine("Only this now"); // ❌ compile error
button.Clicked(); // ❌ compile error — only Button itself can invoke it

event is a protective wrapper around a delegate. Outside code can subscribe and unsubscribe with +=/-=, but it can never overwrite the whole subscriber list or fire the event directly. Only the declaring class can do that.

Why ?.Invoke Instead of .Invoke

Clicked?.Invoke(); // safe
Clicked.Invoke(); // risky

An event with zero subscribers is just a null delegate reference, not an empty list. Clicked.Invoke() compiles fine, but if nobody has subscribed yet, it throws a NullReferenceException at runtime. Clicked?.Invoke() short-circuits safely if Clicked is null, doing nothing instead of crashing. This is the standard, defensive way to raise any event.

The Standard Event Pattern

Real .NET code follows a specific shape rather than a custom one.

public class OrderService
{
public event EventHandler<OrderEventArgs> OrderPlaced;
public void PlaceOrder(string orderId)
{
OrderPlaced?.Invoke(this, new OrderEventArgs(orderId));
}
}
public class OrderEventArgs : EventArgs
{
public string OrderId { get; }
public OrderEventArgs(string orderId) => OrderId = orderId;
}

EventHandler<TEventArgs> is a built-in delegate type with a fixed shape: (object sender, TEventArgs e) => void. It always takes a sender and an args object, and always returns nothing.

The sender parameter matters when one handler is subscribed to multiple objects raising the same kind of event:

service1.OrderPlaced += HandleOrder;
service2.OrderPlaced += HandleOrder;
void HandleOrder(object sender, OrderEventArgs e)
{
if (sender == service1) Console.WriteLine("This came from service1");
}

OrderEventArgs is a small class whose entire job is carrying data along with the event. It inherits from EventArgs, an empty base class used purely as a convention marker.

Why Wrap Data in a Class Instead of Passing It Directly

public event Action<string> OrderPlaced; // locked to exactly one string, forever

If more data needs to be added later — say, the order’s total — the delegate’s signature itself has to change:

public event Action<string, decimal> OrderPlaced;

Every existing subscriber breaks, because their method was written to match the old, single-parameter shape.

With EventHandler<OrderEventArgs>, the delegate's signature never changes. New data just becomes a new property on OrderEventArgs:

public class OrderEventArgs : EventArgs
{
public string OrderId { get; }
public decimal Total { get; } // added later, nothing else changes
}

Every existing subscriber still compiles and still works, since (sender, args) => ... never had to change. This is the real, practical reason the convention exists — not just style, but avoiding breaking changes as an event grows.

Delegates vs Events, Precisely

An event is a delegate. event doesn't create a new kind of thing — it takes an existing delegate type and restricts what external code can do with it.

Plain DelegateEventOutside code can overwrite with =YesNo — compile errorOutside code can invoke directlyYesNo — compile errorOutside code can += / -=YesYesTypical usePassing logic into a method, like Where(n => n > 1)Announcing something happened, safely, to many subscribers

Wrapping Up

Delegates aren’t a separate, exotic feature — they’re the mechanism quietly running underneath every LINQ call from earlier in this series. Func, Action, and Predicate are just named shortcuts for common delegate shapes. Events take that same mechanism and lock it down: subscribe and unsubscribe are allowed, overwrite and direct invocation aren't, because a class raising an event should be the only thing that gets to say it actually happened. The EventHandler<T> and EventArgs convention exists for one concrete reason — so an event's data can grow over time without breaking every subscriber that already depends on it.

--

--

Anchalmehandiratta
Anchalmehandiratta

Written by Anchalmehandiratta

Backend engineer. I write about .NET and databases — mostly the parts I learned by breaking them first.