Learn Minimal APIs from start to finish. Perfect for beginners.

Delegates and events in C# explained

David Grace David Grace

We are going to look at how delegates and events work in C#, how to assign and invoke them, the built in Action, Func and Predicate types, and how to add strongly typed data to an event with a custom EventArgs class.

What is a delegate

A delegate is a type that represents a reference to a method with a matching signature. It has no implementation itself, it just describes what a method it points to must look like. It can be used with an instance method or a static method.

// OrderProcessedHandler.cs
public delegate void OrderProcessedHandler(string orderId);

Assigning and invoking a delegate

OrderService.cs holds a field of type OrderProcessedHandler called OnOrderProcessed. It's invoked inside ProcessOrder.

// OrderService.cs
public class OrderService
{
	public OrderProcessedHandler? OnOrderProcessed;

	public void ProcessOrder(string orderId)
	{
		Console.WriteLine($"Processing order {orderId}");

		OnOrderProcessed?.Invoke(orderId); // OnOrderProcessed is invoked.
	}
}

OnOrderProcessed.Invoke runs whatever method is currently assigned. The ? stops a null reference exception if nothing has been assigned.

We can assign a method to OnOrderProcessed like this:

// Program.cs
var orderService = new OrderService();
orderService.OnOrderProcessed += 
	orderId => Console.WriteLine($"Order {orderId} processed");
 
orderService.ProcessOrder("ORD-1001");

When OrderService.ProcessOrder is called, it will output Processing order {orderId} to the console. Afterwards, it will invoke the method that is assigned to OnOrderProcessed which writes Order {orderId} processed to the console.

Multicast delegates

A delegate can hold more than one method at a time:

void OrderProcessed(string orderId) =>
	Console.WriteLine($"Order {orderId} processed");
void SendConfirmationEmail(string orderId) =>
	Console.WriteLine($"Sending confirmation email for {orderId}");
void UpdateStock(string orderId) =>
	Console.WriteLine($"Updating stock for {orderId}");
 
var orderService = new OrderService();
orderService.OnOrderProcessed += OrderProcessed;
orderService.OnOrderProcessed += SendConfirmationEmail;
orderService.OnOrderProcessed += UpdateStock;
 
orderService.ProcessOrder("ORD-1001");

+= adds another method to the invocation list. Every method in the list runs when the delegate is invoked, in the order it was added.

So when OrderService.ProcessOrder is called, it will invoke OrderProcessed, SendConfirmationEmail and UpdateStock as OnOrderProcessed is invoked inside OrderService.ProcessOrder.

Unsubscribing from a delegate

// Program.cs
orderService.OnOrderProcessed -= OrderProcessed;
orderService.OnOrderProcessed -= SendConfirmationEmail;
orderService.OnOrderProcessed -= UpdateStock;
 
orderService.ProcessOrder("ORD-1002");

-= removes a method from the invocation list. To remove a method, you need a reference to the exact same delegate instance you added with +=, which is why the methods above are declared separately rather than as lambda expressions.

As a result, when orderService.ProcessOrder("ORD-1002") is called, it will not invoke OrderProcessed, SendConfirmationEmail and UpdateStock as these methods have been removed.

You cannot unsubscribe a method that was added as a lambda expression, since each lambda expression creates a new delegate instance.

Forgetting to unsubscribe is a common cause of memory leaks. The publisher holds a reference to the subscriber through the delegate, which can keep it alive longer than expected.

Action, Func and Predicate

Action, Func and Predicate are built in generic delegate types.

Action

// OrderService.cs
public class OrderService
{
	...

	public void SendConfirmationEmail(string orderId, Action<string> sendOrder)
	{
		Console.WriteLine($"Sending confirmation email for {orderId}");

		sendOrder?.Invoke(orderId);
	}
}
var orderService = new OrderService();
orderService.SendConfirmationEmail("ORD-1003",
	(orderId) => Console.WriteLine(
		$"Sent confirmation email for {orderId}"));

Action has no return value. The types inside the generic type represent the parameter types that need to be included when invoking it. The above has Action<string> as the type, so it must be invoked with a string parameter.

Func

// OrderService.cs
public class OrderService
{
	...

	public int ReduceStock(string orderId, Func<string, int> reduceStock)
	{
		Console.WriteLine($"Reducing stock for {orderId}");

		return reduceStock.Invoke(orderId);
	}
}
var orderService = new OrderService();
...
var newStock = orderService.ReduceStock("ORD-1003", (orderId) =>
{
	var originalStock = 16;
	Console.WriteLine($"Original stock for {orderId} = {originalStock}");
	return --originalStock;
});
Console.WriteLine($"New stock for ORD-1003 = {newStock}");

Func always returns a value. The last generic type parameter represents the return type. The above has Func<string, int> as the type, so it must be invoked with a string parameter, and returns an integer.

Predicate

// OrderService.cs
public class OrderService
{
	...
 
	public bool IsTaxable(string orderId, Predicate<string> isTaxable)
	{
		Console.WriteLine($"Is taxable? {orderId}");
 
		return isTaxable.Invoke(orderId);
	}
}
var orderService = new OrderService();
...
var isTaxable = orderService.IsTaxable("ORD-1003", (orderId) =>
{
	return true;
});
Console.WriteLine($"Is taxable for ORD-1003 = {isTaxable}");

Predicate always returns a boolean. The above has Predicate<string> as the type, so it must be invoked with a string parameter, and returns a boolean.

Using Func with async methods

An async method needs to return a Task, so the parameter needs to be a delegate that returns one. This can be done using Func<string, Task>:

// OrderService.cs
public class OrderService
{
	...

	public async Task DelayProcessing(string orderId, Func<string, Task> delayProcessing)
	{
		Console.WriteLine($"Delaying processing for {orderId}");

		await delayProcessing(orderId);
	}
}
var orderService = new OrderService();
...
await orderService.DelayProcessing("ORD-1003", async (orderId) =>
{
	Console.WriteLine($"Delaying ORD-1003");
 
	await Task.Delay(TimeSpan.FromSeconds(5));
 
	Console.WriteLine($"Delayed ORD-1003");
});
Console.WriteLine($"Finished delaying ORD-1003");

We call DelayProcessing from OrderService. This writes to the console before invoking and awaiting the method assigned to the delayProcessing Func parameter.

The order ID is added for the first parameter. For the second, we add an async method that writes to the console, delays the task by five seconds, then outputs another message to the console.

What is an event

You can invoke OnOrderProcessed directly from outside the class it's assigned to.

// Program.cs
orderService.OnOrderProcessed?.Invoke("ORD-1002");

That compiled because OnOrderProcessed is a public field. Adding the event keyword changes that:

// OrderService.cs
public class OrderService
{
	public event OrderProcessedHandler? OnOrderProcessed;

	...
}

Running the same line as before now fails to compile:

The event 'OrderService.OnOrderProcessed' can only appear on the left hand side of += or -= (except when used from within the type 'OrderService')

event turns a delegate field into a delegate with restricted access. Code outside the class can still subscribe with += or unsubscribe with -=, but it can no longer invoke it directly.

This protects the internal state of the class. Only OrderService can invoke OnOrderProcessed.

Subscribing and unsubscribing work exactly as they did before. Only Invoke changes.

EventHandler and custom EventArgs

So far, OnOrderProcessed has passed a single string. A custom EventArgs class lets an event carry strongly typed data instead.

// OrderDispatchedEventArgs.cs
public class OrderDispatchedEventArgs : EventArgs
{
	public string OrderId { get; }

	public DateTime Dispatched { get; }

	public OrderDispatchedEventArgs(string orderId, DateTime dispatched)
	{
		OrderId = orderId;
		Dispatched = dispatched;
	}
}
// OrderService.cs
public class OrderService
{
	public event EventHandler? OrderDispatched;

	...

	public void DispatchOrder(string orderId)
	{
		OrderDispatched?.Invoke(this,
			new OrderDispatchedEventArgs(orderId, DateTime.UtcNow));
	}
}
// Program.cs
var orderService = new OrderService();
...
orderService.OrderDispatched += (sender, eventArgs) =>
{
	Console.WriteLine($"Dispatched at {eventArgs.Dispatched} for {eventArgs.OrderId}");
};
orderService.DispatchOrder("ORD-1003");

EventHandler<T> always takes two parameters when invoked. The sender object and the event data. This is why we add this as the first parameter value shown in OrderService.DispatchOrder. This will send the OrderService instance as the sender.

We then create a new instance of our custom EventArgs class as the second parameter which contains our event data.

These parameters can then be used when assigning the method.

Watch the video

Watch this video where we go through delegates and events in C#, including Action, Func, Predicate and custom EventArgs.