C#  

Mastering Delegates in C#

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.