The umbrella term for everything you've learned this module — functions that take other functions as input, return them as output, or both.
Look back across this entire module: Retrier.RunWithRetry took an Action as a parameter. OrderValidator.AddRule took a Func<Order, bool>. List<T>.Find took a Predicate<T>. CreateCounter returned a Func<int>. Every one of these is doing the exact same kind of thing: treating a function as a piece of data — accepting it, passing it along, or handing one back.
That general pattern has a name: a higher-order function. It's not a new C# feature — you've been writing and using them since lesson 098. This lesson names the pattern explicitly and shows you how to combine everything you know to build genuinely flexible, reusable code.
A higher-order function is any function that does at least one of these two things: takes another function as a parameter, or returns a function as its result. A function that only ever accepts and returns "plain data" — numbers, strings, objects — is a first-order function; the moment a function itself becomes an input or an output, you've stepped up a level.
This concept rests on treating functions as first-class values — values that can be stored in variables, passed as arguments, and returned from other functions, exactly like an int or a string can. C# achieves this entirely through the delegate machinery you already know: Action, Func, Predicate, lambdas, and local functions converted to delegates are all just ways of turning "a function" into "a value" you can move around your program.
Retrier.RunWithRetry(Action operation, ...)OrderValidator.AddRule(Func<Order, bool> rule)List<T>.Find(Predicate<T> match)Func<int> CreateCounter()Func<int,int> CreateMultiplier(int factor)Imagine writing a separate retry method for every operation your app performs — one for saving to a database, one for calling an API, one for writing a file — each with near-identical retry-loop code, differing only in the one line that actually does the work. Every new operation means copy-pasting and re-testing the same orchestration logic.
A higher-order function lets you write the orchestration logic (the "how" — retry looping, validation iteration, sorting, filtering) exactly once, and accept the varying part (the "what" — the specific operation, rule, or comparison) as a parameter. This is inversion of control applied at the function level: instead of a method calling out to hardcoded logic, the caller hands in the logic the method should run.
Every reusable, general-purpose utility you've written this module — the retry helper, the validator, the counter factory — became reusable specifically because it stopped hardcoding "what to do" and started accepting it as a function parameter or returning it as a function result. That's the entire value proposition of higher-order functions in one sentence.
RunWithRetry, Validate, List<T>.Find, List<T>.SortAction operation, Func<Order, bool> rule, Predicate<T> match, Comparison<T> comparerAction; computes a result → Func; yes/no check → Func<T, bool>.using System;
class Program
{
// Takes a function in — a higher-order function
static int ApplyTwice(Func<int, int> f, int x) => f(f(x));
// Returns a function out — also a higher-order function
static Func<int, int> CreateMultiplier(int factor) => x => x * factor;
static void Main()
{
Console.WriteLine(ApplyTwice(x => x + 3, 10)); // (10+3)+3 = 16
Func<int, int> triple = CreateMultiplier(3);
Console.WriteLine(triple(7)); // 21
Console.WriteLine(ApplyTwice(triple, 2)); // triple(triple(2)) = triple(6) = 18
}
}
Code → Meaning → Result: ApplyTwice doesn't know or care what f actually does — it just knows it's a Func<int, int> and calls it twice. CreateMultiplier doesn't compute a value at all; it builds and hands back a new function, closing over factor (a direct application of closures from lesson 102). The last line even feeds one higher-order function's output into another — triple, itself created by a higher-order function, is passed into ApplyTwice, another higher-order function.
A text-processing pipeline: a list of independent Func<string, string> transformation steps, applied in sequence, with new steps addable without touching the pipeline logic itself.
using System;
using System.Collections.Generic;
public class TextPipeline
{
private readonly List<Func<string, string>> _steps = new();
public TextPipeline AddStep(Func<string, string> step)
{
_steps.Add(step);
return this; // enables fluent chaining
}
public string Run(string input)
{
string current = input;
foreach (var step in _steps)
current = step(current); // each step's output feeds the next step's input
return current;
}
}
class Program
{
static void Main()
{
var pipeline = new TextPipeline()
.AddStep(s => s.Trim())
.AddStep(s => s.ToLowerInvariant())
.AddStep(s => s.Replace(" ", " "));
string result = pipeline.Run(" Hello WORLD ");
Console.WriteLine($"'{result}'"); // 'hello world'
}
}
TextPipeline knows absolutely nothing about trimming, casing, or whitespace — it only knows how to run a list of Func<string, string> values in order. Adding a step that strips punctuation or normalizes line endings later requires zero changes to TextPipeline itself — exactly the same "closed for modification, open for extension" benefit you saw with OrderValidator back in lesson 099, now generalized. This same shape — one orchestrator, many interchangeable functions — is precisely the mental model LINQ is built on, which is the very next module.
Picture a factory assembly line: the conveyor belt (the higher-order function) never changes — it always moves the product from station to station. But each station along the belt (the passed-in function) can be swapped for a different specialized task without redesigning the belt itself. Want to add a new inspection step? Bolt on a new station. The belt doesn't need to know what any individual station does — it just knows how to move the product from one to the next, in order.
There's no new runtime mechanism in this lesson — "higher-order function" is a name for a pattern, not a distinct C# feature. Every example above compiles down to exactly what you already know: Func/Action delegate instances, wrapping either a compiler-generated lambda method or a named method, invoked through the same Invoke mechanism from lesson 096. When a function returns another function that closes over local state (like CreateMultiplier capturing factor), the compiler-generated display class from lesson 102 is what keeps that state alive for as long as the returned delegate is reachable. Recognizing "this is a higher-order function" is entirely a design-level skill — it tells you nothing new about how the code executes, but a great deal about how to structure code you haven't written yet.
The term describes a precise, technical property — operating on functions — not a vague sense of sophistication. A single-line utility like ApplyTwice is just as much a higher-order function as an elaborate pipeline system; complexity isn't the criterion.
Higher-order functions are one building block of functional programming, not the entire paradigm. C# remains a fundamentally object-oriented, imperative language that happens to support this functional-style feature well — you're not "switching paradigms" by using Func parameters, just using one more tool from a broader toolbox that C# gives you access to.
Turning every method parameter into a Func "just in case," even when a piece of logic genuinely has one fixed implementation and always will. This adds a layer of indirection that makes the code harder to read for no real flexibility gained.
Reach for a higher-order function when you can point to at least two genuinely different behaviors the same orchestration logic needs to support — not preemptively for logic that has never needed to change.
Calling a factory function like CreateMultiplier repeatedly inside a hot loop, unaware that each call allocates a new closure holding its own captured factor — this is easy to miss once the pattern from lesson 102 is out of sight.
Create the function once, outside the loop, and reuse the resulting delegate value across iterations whenever the captured state doesn't need to change per iteration.
Func parameter is often a lighter-weight stand-in for a single-method interface.Where takes a Func<T, bool>, Select takes a Func<T, TResult>, and chaining them together is the "pipeline" pattern from this lesson, generalized across an entire standard library.
Action, Func, Predicate, lambdas, local functions — all just the vocabulary this pattern is written in.
Let's confirm you can recognize the pattern across different code shapes — and know when reaching for it actually pays off.
1. Which of the following makes a function a "higher-order function"?
Correct: B
Why B is correct: This is the precise, technical definition — operating on functions as inputs and/or outputs is the entire criterion, regardless of how simple or complex the function otherwise is.
Why A is incorrect: Line count has nothing to do with it — ApplyTwice, a one-line method, still qualifies.
Why C is incorrect: async relates to asynchronous programming, an entirely separate topic from higher-order functions.
Why D is incorrect: Class structure is unrelated — the property in question belongs to the individual function's signature, not its containing type.
Reinforcement: Check a function's parameter list and return type for other functions — that's the whole test.
2. In static Func<int, int> CreateMultiplier(int factor) => x => x * factor;, why is CreateMultiplier a higher-order function?
Correct: B
Why B is correct: CreateMultiplier's return type is Func<int, int> — a function. Returning a function as a result is exactly the "returns a function" half of the definition.
Why A is incorrect: Taking an int parameter is ordinary — that alone doesn't involve functions at all.
Why C is incorrect: Using a lambda in the implementation is just syntax; what makes it higher-order is the function's signature (its return type), not how the body happens to be written.
Why D is incorrect: Capturing factor is a closure, a separate (related) concept from lesson 102 — it explains how the returned function remembers factor, not why the outer method itself is higher-order.
Reinforcement: Look at the signature: a function type appearing as a parameter or a return type is the signal.
3. Why did TextPipeline from the real-world example never need to change when a new processing step was added?
Correct: A
Why A is correct: Run only ever calls each stored Func<string, string> in sequence — the specific transformation logic lives entirely in the caller-supplied lambdas, so adding a new one requires zero changes to TextPipeline's own code.
Why B is incorrect: TextPipeline has no built-in knowledge of any specific transformation — every behavior comes from the outside, via AddStep.
Why C is incorrect: Adding a step is a runtime operation (calling AddStep), not a compile-time one — no recompilation happens.
Why D is incorrect: The example's AddStep performs no validation at all — it simply stores whatever Func<string, string> it's given.
Reinforcement: Extensibility without modification is the practical payoff of designing with higher-order functions.
You now see the pattern that ties Action, Func, Predicate, lambdas, closures, and local functions together. Next up: a different application of functions-as-values — the event keyword, and why plain multicast delegates aren't quite enough for safe publish/subscribe.
dotnetmadeeasy.com — Learn C# and .NET, the right way.