← All posts

Programming 6 min read

Understanding C# Delegates: From Basic Concepts to Real-World Composition

How learning delegates step-by-step helped me demystify ASP.NET Core middleware, LINQ, and functional behavior in C#

I recently wanted to learn more about ASP.NET Core and understand what happens under the hood. While digging into the ASP.NET Core request pipeline, I discovered that delegates play an important role in how middleware is composed and executed.

That made me want to step back and really understand C# delegates instead of simply using them without knowing what was happening underneath.

So I decided to learn delegates by building a few small exercises.

What is a Delegate?

In simple terms, a delegate is a type-safe reference to a method.

For example, I can create a delegate that points to a method:

C#

public static void Print(string message)
{
    Console.WriteLine(message);
}

var print = MyDelegate.Print;
print("Hello"); // Hello

Instead of calling MyDelegate.Print() directly, I can store the method reference in a variable and pass it around.

C# also provides three built-in delegate types that cover many common use cases:

Func<...>

Func represents a delegate that takes zero or more parameters and returns a value.

The last type argument specifies the return type.

C#

// Takes two ints and returns an int
Func<int, int, int> sum = (x, y) => x + y;

var result = sum(5, 10); // 15

Predicate

Predicate<T> represents a delegate that takes one parameter of type T and returns a bool.

It is commonly used when evaluating a condition.

C#

Predicate<int> isEven = x => x % 2 == 0;

Console.WriteLine(isEven(10)); // True

Action<...>

Action represents a delegate that takes zero or more parameters but returns nothing (void).

C#

Action<string> print = message => Console.WriteLine(message);

print("Hello");

Why Use Delegates Instead of Regular Methods?

This was probably the most important part of my learning.

A regular method contains a specific implementation. A delegate allows us to pass behavior around as a value.

1. Passing behavior as a parameter

Delegates allow us to pass executable behavior into another method.

For example, I created this method:


static void TransformNumber(
    int[] numbers,
    Predicate<int> filter,
    Func<int, int> transform,
    Action<int> output)
{
    foreach (int num in numbers)
    {
        if (filter(num))
        {
            output(transform(num));
        }
    }
}

I can then decide what the method should do by passing different delegates:


TransformNumber(
    numbers,
    isEven,
    square,
    printNumber
);

The method doesn't need to know what filter, transform, or output actually do.

It simply knows their contracts:

  • Predicate<int> → give me an int, and I'll give you a bool

  • Func<int, int> → give me an int, and I'll give you an int

  • Action<int> → give me an int, and I'll perform an action

This was the point where delegates started to really click for me.

2. Decoupling the "What" from the "How"

I created another exercise using employees.

Instead of writing a separate method for contractors, non-contractors, or employees above a certain salary, I created a reusable method:


static void ProcessEmployees(
    List<Employee> employees,
    Predicate<Employee> filter,
    Action<Employee> output)
{
    foreach (Employee employee in employees)
    {
        if (filter(employee))
        {
            output(employee);
        }
    }
}

Now I can change the behavior without changing ProcessEmployees.

For example:

var contractors = employee =>
    employee.IsContractor;

var nonContractors = employee =>
    !employee.IsContractor;

var salaryGreaterThan50K = employee =>
    employee.Salary >= 50000;

The same processing method can now handle completely different business rules.

This is a powerful idea:

The method controls the process. The delegate controls the behavior.

The Exercises That Helped Me Understand Delegates

Rather than just reading about Func, Action, and Predicate, I found it much easier to understand them by progressively building exercises.

Exercise 1 — Action

Create an Action<string> that prints a message in uppercase.


Action<string> printUpperCase =
    message => Console.WriteLine(message.ToUpper());

printUpperCase("john");
// JOHN

This helped me understand that Action is essentially:

"Give me something, and I'll do something with it without returning a value."


Exercise 2 — Func

Create a Func<int, int> that squares a number.



Func<int, int> square = x => x * x;

Console.WriteLine(square(5));
// 25

Then create:

Func<int, int, int> sum =
    (x, y) => x + y;

This helped reinforce that Func is about input → output.


Exercise 3 — Predicate

Create a predicate that determines whether a number is even:

Predicate<int> isEven =
    x => x % 2 == 0;

This made the purpose of Predicate<T> very clear:

"Give me something, and I'll tell you whether it satisfies a condition."


Exercise 4 — Combine Predicate, Func, and Action

I then combined all three:

TransformNumber(
    numbers,
    isEven,
    square,
    printNumber
);

The flow became:

numbers
   ↓
Predicate → filter
   ↓
Func → transform
   ↓
Action → output

For example:

1 → skip
2 → square → 4 → print
3 → skip
4 → square → 16 → print
6 → square → 36 → print

This was where I started seeing delegates as more than just another C# feature.

They allow you to compose behavior.


Exercise 5 — Passing Multiple Behaviors

The next exercise was to create a reusable processing method:

static void ProcessNumbers(
    int[] numbers,
    Predicate<int> filter,
    Func<int, int> transform,
    Action<int> output)
{
    foreach (int number in numbers)
    {
        if (filter(number))
        {
            output(transform(number));
        }
    }
}

Now the method itself doesn't know:

  • what numbers should be accepted

  • how they should be transformed

  • how they should be displayed

Those decisions are supplied by the caller.

For example:

ProcessNumbers(
    numbers,
    x => x % 2 == 0,
    x => x * x,
    x => Console.WriteLine(x)
);

The benefit became much clearer:

I can reuse the same algorithm while changing its behavior.


Exercise 6 — Applying the Same Idea to Employees

I then applied the same concept to a more realistic scenario.

static void ProcessEmployees(
    List<Employee> employees,
    Predicate<Employee> filter,
    Action<Employee> output)
{
    foreach (Employee employee in employees)
    {
        if (filter(employee))
        {
            output(employee);
        }
    }
}

I could use different predicates:

var contractor =
    employee => employee.IsContractor;

var nonContractor =
    employee => !employee.IsContractor;

var salaryGreaterThan50K =
    employee => employee.Salary >= 50000;

And different output behaviors:

var formatEmployee =
    employee => Console.WriteLine(
        $"{employee.Name} - ${employee.Salary:F2}"
    );

var formatName =
    employee => Console.WriteLine(employee.Name);

Then I could combine them:

ProcessEmployees(
    employees,
    contractor,
    formatEmployee
);

or:

ProcessEmployees(
    employees,
    salaryGreaterThan50K,
    formatName
);

Same processing method. Different behavior.

What I Took Away

Before doing these exercises, I understood that delegates were "references to methods," but that definition alone didn't really explain why I should care.

After working through these examples, I see delegates differently.

The important idea isn't simply:

"A delegate points to a method."

It's:

"A delegate allows me to pass behavior into another piece of code."

And once you understand that, several C# features start making more sense.