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 anint, and I'll give you aboolFunc<int, int>→ give me anint, and I'll give you anintAction<int>→ give me anint, 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.