Delegates in C# are often introduced as “function pointers” but in practice they form the backbone of some of the language’s most expressive and powerful features. Events, LINQ, asynchronous programming, and even many design patterns rely on them. By taking the time to understand delegates beyond the basics, you unlock an entire toolkit for writing flexible, extensible, and testable applications.
What Exactly Is a Delegate?
At its simplest, a delegate is a type that defines a contract for methods: a particular set of parameters and a return type. Any method that matches this contract can be assigned to the delegate.
delegate int MathOperation(int a, int b);
Here, MathOperation
can represent any method that accepts two integers and returns an integer. Assigning a method is straightforward:
int Add(int x, int y) => x + y;
MathOperation op = Add;
Console.WriteLine(op(3, 4)); // 7
Why they are useful
Delegates aren’t just syntactic sugar. They enable:
Decoupling logic between components
Flexibility in passing behaviour as arguments
Extensibility for callbacks, plug-ins, and strategies
Testability by allowing behaviour to be injected and mocked
These qualities are why delegates appear everywhere in idiomatic C# code.
Multicast Delegates
Delegates can be chained together so multiple methods are invoked in sequence:
Action notify = () => Console.WriteLine("First");
notify += () => Console.WriteLine("Second");
notify();
Only the return value from the final method in the invocation list is preserved, but side-effects from all are executed.
Built-In Generics: Func
, Action
, and Predicate
Rather than defining custom delegates every time, C# provides built-in generic types:
Action<string> log = msg => Console.WriteLine($"Log: {msg}");
log("Hello");
Func<int, int, int> add = (a, b) => a + b;
int result = add(2, 3); // 5
Predicate<string> isEmpty = s => string.IsNullOrEmpty(s);
bool check = isEmpty("");
These generic types reduce boilerplate while remaining expressive.
Strategy Pattern with Delegates
Instead of hard-wiring behaviour inside a class, delegates make strategies pluggable:
public class PaymentProcessor
{
private readonly Action<Payment> _strategy;
public PaymentProcessor(Action<Payment> strategy) => _strategy = strategy;
public void Process(Payment payment) => _strategy(payment);
}
This approach makes the processor adaptable, easy to test, and open to extension without subclassing.
Higher-Order Functions and Currying
Delegates allow functions to return functions:
Func<int, Func<int, int>> add = x => y => x + y;
var addFive = add(5);
Console.WriteLine(addFive(10)); // 15
While not as common in day-to-day C#, higher-order delegates open the door to functional techniques useful in complex pipelines.
Events: Delegates with Rules
Events wrap delegates with additional constraints, ensuring safe publication and subscription:
public class Alarm
{
public event Action? OnRing;
public void Ring() => OnRing?.Invoke();
}
var alarm = new Alarm();
alarm.OnRing += () => Console.WriteLine("Wake up!");
alarm.Ring();
This event model is the backbone of event-driven .NET applications.
Delegates and Asynchronous Programming
Although older asynchronous patterns used BeginInvoke
/EndInvoke
, today delegates combine naturally with Task
and async/await
:
Func<int, int, Task<int>> addAsync = async (a, b) =>
{
await Task.Delay(100);
return a + b;
};
int result = await addAsync(3, 4);
You can also use delegates to build async pipelines for processing HTTP requests, messages, or background jobs.
LINQ: Delegates Everywhere
Whenever you use Select
, Where
, or Aggregate
, you’re passing delegates:
var names = new[] { "alice", "bob", "carol" };
var upper = names.Select(name => name.ToUpper());
These tiny functions compose into powerful query pipelines.
Practical Example: Retry Logic
Delegates make retry behaviour reusable:
public static T Retry<T>(Func<T> operation, int maxAttempts = 3)
{
int attempts = 0;
while (true)
{
try { return operation(); }
catch { if (++attempts >= maxAttempts) throw; }
}
}
int result = Retry(() => UnreliableOperation());
Testing with Delegates
By injecting delegates, you make testing easier:
public class EmailSender
{
private readonly Func<string, string, bool> _send;
public EmailSender(Func<string, string, bool> send) => _send = send;
public bool Send(string subject, string body) => _send(subject, body);
}
In a test:
var sender = new EmailSender((s, b) => s.Contains("Test"));
Assert.True(sender.Send("Test subject", "Hello"));
Building Delegate Pipelines
Delegates compose naturally:
Func<string, string> step1 = s => s.ToUpper();
Func<string, string> step2 = s => $"Hello, {s}";
Func<string, string> pipeline = s => step2(step1(s));
Console.WriteLine(pipeline("patrick")); // "Hello, PATRICK"
This pattern underpins middleware systems and data transformation layers.
Custom Delegate Types vs. Generic Delegates
Use custom delegate types when you want semantic clarity, public API readability, or event-like semantics. For internal logic, Func<>
, Action<>
, and Predicate<>
are usually sufficient and concise.
Delegates are not just a syntactic quirk of C#. They enable separation of concerns, composability, and extensibility without bloated inheritance hierarchies. Whether you’re building retry logic, pipelines, or event-driven systems, delegates are one of the most effective tools for clean, maintainable code.
Most developers already use them indirectly in LINQ and events, but mastering them explicitly will elevate your ability to design flexible systems that scale with your project’s needs.