Mastering Delegates in C#


Delegates in C# are much more than just function pointers, they are foundational to many of the language’s most powerful features, including events, asynchronous programming, and LINQ. Understanding how to use delegates properly opens the door to writing flexible, decoupled, and testable code.
What Are Delegates?
A delegate is a type that represents references to methods with a particular parameter list and return type. Think of it as a contract that any matching method can satisfy.
delegate int MathOperation(int a, int b);
This defines a delegate that can point to any method that takes two int
parameters and returns an int
.
Example usage:
Add(int x, int y) => x + y;
MathOperation op = Add;
Console.WriteLine(op(3, 4)); // 7
Why Use Delegates?
Decoupling logic
Flexibility in passing methods as parameters
Enabling extensibility (e.g., callbacks, strategies)
Core to events and LINQ
Better unit testing with mockable logic. Multicast Delegates
Delegates can point to multiple methods using the +=
and -=
operators.
notify = () => Console.WriteLine("First");
notify += () => Console.WriteLine("Second");
notify();
Return values from multicast delegates are only from the last invocation.
Built In Generic Delegates: Func, Action, Predicate
Action
No return value:
Action<string> log = message => Console.WriteLine($"Log: {message}");
log("Test");
Func
Returns a value:
Func<int, int, int> add = (a, b) => a + b;
int result = add(2, 3); // 5
Predicate
Returns bool
:
Predicate<string> isEmpty = s => string.IsNullOrEmpty(s);
bool check = isEmpty("");
These are often more concise than custom delegate types.
Delegates as Strategy Pattern
Instead of tightly coupling logic:
public class PaymentProcessor
{
public void Process(Payment payment) { /* logic */ }
}
Use delegates for pluggability:
public class PaymentProcessor
{
private readonly Action<Payment> _strategy;
public PaymentProcessor(Action<Payment> strategy)
{
_strategy = strategy;
}
public void Process(Payment payment)
{
_strategy(payment);
}
}
This makes the behaviour easily swappable, mockable, and testable.
Higher Order Functions and Currying
A higher order function takes a delegate or returns one.
Func<int, Func<int, int>> add = x => y => x + y;
var addFive = add(5);
Console.WriteLine(addFive(10)); // 15
Currying like this is rarely used in C# day to day but can unlock powerful abstractions in complex business rules or pipelines.
Delegates and Events
Events are essentially multicast delegates with restrictions.
public class Alarm
{
public event Action? OnRing;
public void Ring()
{
OnRing?.Invoke();
}
}
Events let subscribers register methods:
var alarm = new Alarm();
alarm.OnRing += () => Console.WriteLine("Wake up!");
alarm.Ring();
Always check for null
or use the null conditional operator (?.
) when invoking events.
Asynchronous Delegates
Delegates can be called asynchronously with BeginInvoke
/ EndInvoke
, but in modern C#, use async
and Task
instead.
Func<int, int, Task<int>> addAsync = async (a, b) =>
{
await Task.Delay(100);
return a + b;
};
var result = await addAsync(3, 4);
Or use delegates to orchestrate async pipelines:
Func<HttpRequestMessage, Task<HttpResponseMessage>> pipeline = async request =>
{
// logging, auth, etc.
return await httpClient.SendAsync(request);
};
Delegates in LINQ
LINQ heavily uses delegates — both Func<T, bool>
and Func<T, TResult>
.
var names = new[] { "alice", "bob", "carol" };
var upper = names.Select(name => name.ToUpper()); // Func<string, string>
This allows for expressive and composable data transformation pipelines.
Real World Example: Retry Logic
You can write reusable retry logic with delegates:
public static T Retry<T>(Func<T> operation, int maxAttempts = 3)
{
int attempts = 0;
while (true)
{
try
{
return operation();
}
catch
{
if (++attempts >= maxAttempts)
throw;
}
}
}
Usage:
int result = Retry(() => UnreliableOperation());
Unit Testing with Delegates
Using delegates instead of tightly coupled code makes testing easier.
Example:
public class EmailSender
{
private readonly Func<string, string, bool> _sendFunc;
public EmailSender(Func<string, string, bool> sendFunc)
{
_sendFunc = sendFunc;
}
public bool Send(string subject, string body)
{
return _sendFunc(subject, body);
}
}
In a unit test:
var sender = new EmailSender((s, b) => s.Contains("Test"));
Assert.True(sender.Send("Test subject", "Hello"));
Combining Delegates Dynamically
You can build pipelines by composing delegates:
Func<string, string> step1 = s => s.ToUpper();
Func<string, string> step2 = s => $"Hello, {s}";
Func<string, string> pipeline = s => step2(step1(s));
var result = pipeline("patrick"); // "Hello, PATRICK"
This works well in middleware style architectures.
Custom Delegate Types vs Func/Action
When to define your own delegate type:
You want semantic clarity in public APIs.
You want to use attributes (e.g.,
[Obsolete]
) on delegate parameters.You need to support multicast scenarios with event like semantics.
Otherwise, Func<>
and Action<>
are sufficient for internal use and concise code.
Delegates are the backbone of flexible and composable C# applications. Mastering them enables clean separation of concerns, extensibility without inheritance & easy testability through injection. You also get functional patterns like pipelines and transformations that promote clean decoupled code.
While many developers use delegates indirectly via LINQ or events without thinking about it, going deeper into custom delegate usage, higher order functions, and strategies will improve your design skills in large systems.
Subscribe to my newsletter
Read articles from Patrick Kearns directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
