A ready-made delegate type for "do something with these arguments, return nothing" — so you almost never write your own void delegate again.
In the last two lessons, you wrote public delegate void Notify(string message); by hand. Now imagine you need a similar delegate that takes two ints instead. And another that takes no parameters at all. And another that takes a string and a bool. Are you really going to declare a new delegate type every single time?
The people who designed the .NET Base Class Library asked the same question — and the answer was no. They built a small family of generic delegate types that cover almost every shape you'll ever need, so you don't have to declare your own. The first and simplest of that family is Action.
Action is a built-in delegate type that represents "a method that takes some arguments and returns nothing" — exactly the shape you'd otherwise write a custom void-returning delegate for. It comes in several generic flavors so it can match methods with different numbers of parameters.
Action is defined in the System namespace as a family of generic delegates, from zero parameters up to sixteen:
public delegate void Action();
public delegate void Action<T>(T obj);
public delegate void Action<T1, T2>(T1 arg1, T2 arg2);
public delegate void Action<T1, T2, T3>(T1 arg1, T2 arg2, T3 arg3);
// ...continues up to Action<T1, ..., T16>
Every Action variant has one thing in common: it always returns void. The number after "Action" in your head (Action, Action<T>, Action<T1,T2>...) simply tells you how many parameters it takes, and of what types.
public delegate void Notify(string s);Action<string> notify = ...;Before generic delegates existed in .NET, every distinct method signature you wanted to pass around needed its own hand-declared delegate type. Two codebases solving the same kind of problem (say, "run this action, then that action") would each invent their own, incompatible delegate void SomeCallback(...) types — impossible to share between libraries.
Generics (which you covered in the previous module) let the BCL define Action<T> once, and have it work for a method taking a string, a method taking an int, a method taking a Customer — anything. Because Action lives in the BCL, every .NET library, every piece of your own code, and every third-party package speaks the same "language" for void-returning callbacks.
Action doesn't do anything a custom delegate couldn't do. Its value is standardization — one shared vocabulary for "a callback with no return value," so you (and everyone else) stop reinventing that vocabulary project after project.
delegate void ... entirely — Action already exists in System.Action greet = PrintGreeting; // no parameters
Action<string> log = LogMessage; // one parameter
Action<string, int> report = LogWithLevel; // two parameters
greet();
log("Started processing.");
report("WARN", 3);
Action is multicast too, so +=/-= work the same way.using System;
class Program
{
static void PrintGreeting() => Console.WriteLine("Hello!");
static void LogMessage(string message) => Console.WriteLine($"[LOG] {message}");
static void LogWithLevel(string message, int level) =>
Console.WriteLine($"[LEVEL {level}] {message}");
static void Main()
{
Action greet = PrintGreeting;
Action log = LogMessage;
Action report = LogWithLevel;
greet(); // Hello!
log("Processing started."); // [LOG] Processing started.
report("Disk almost full", 2); // [LEVEL 2] Disk almost full
// Multicast still works
Action combined = LogMessage;
combined += msg => Console.WriteLine($"[AUDIT] {msg}");
combined("Order saved."); // both handlers run
}
}
Code → Meaning → Result: No custom delegate types were declared anywhere in this file. Action, Action<string>, and Action<string, int> — all supplied by the BCL — cover every shape used here, and they combine with += exactly like the custom delegates from the previous lessons.
A common pattern: a retry helper that runs an operation and retries it on failure, reporting each attempt through a caller-supplied logging action.
using System;
public static class Retrier
{
public static void RunWithRetry(Action operation, int maxAttempts, Action onAttemptFailed)
{
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
operation();
return; // success — stop retrying
}
catch (Exception ex)
{
onAttemptFailed($"Attempt {attempt} failed: {ex.Message}");
if (attempt == maxAttempts)
throw; // out of retries — let the caller handle it
}
}
}
}
class Program
{
static void Main()
{
int callCount = 0;
Retrier.RunWithRetry(
operation: () =>
{
callCount++;
if (callCount < 3)
throw new InvalidOperationException("Temporary network error");
Console.WriteLine("Operation succeeded.");
},
maxAttempts: 5,
onAttemptFailed: message => Console.WriteLine($"Retry log: {message}")
);
}
}
Retrier.RunWithRetry knows nothing about networking, databases, or files — it only knows "run this Action, and call this other Action<string> when something goes wrong." That's what makes it reusable across every kind of operation your application performs.
Before Action, every "void callback" was like a custom-shaped plug — you needed a matching custom-shaped outlet (delegate type) for every device (method signature). Action and its generic siblings are like a universal outlet: as long as your device fits the number and type of prongs (parameters), it plugs right in — no custom adapter required.
Action and Action<T> are ordinary delegate types, generated once by Microsoft and shipped in System.Private.CoreLib — there's no special-case compiler magic. They still derive from MulticastDelegate, still store a target-plus-method-pointer pair per invocation-list entry, and still get combined by Delegate.Combine under +=, exactly as covered in the previous lesson. The only thing "generic" about them is that the JIT compiler generates specialized native code per closed generic type (e.g., Action<int> vs Action<string>) the first time each is used — the same generic specialization mechanism from the Generics module, applied here to a delegate type instead of a class.
It's tempting to read Action<string> as "returns a string" because of the angle brackets. It doesn't — every type parameter on Action is a parameter type. Action is always void. If you need a return value, you need Func (next lesson).
Action covers the overwhelming majority of cases, but a custom delegate type still communicates intent better in some APIs (a named type like EventHandler or a domain-specific delegate reads more clearly than an anonymous Action<object, EventArgs>). You'll see this trade-off again with events.
Action when you actually need a result Passing an Action<int> and trying to "return" a value by writing it to a captured outer variable, instead of just using Func<int, TResult>.
If the caller needs a value back, use Func — covered next. Reach for Action only when the operation's whole point is a side effect (printing, saving, logging), not a computed result.
Action<string, int, bool, decimal, DateTime> is technically legal, but the call site becomes a guessing game about what each positional argument means.
Beyond two or three parameters, consider a small record or class to group related values, and keep the Action to one or two parameters.
EventHandler still exists, rather than everything using bare Action).Action = "do this, give nothing back."<T1, T2, ...>) describe the inputs, never a return value.Action, Action<T>, Action<T1,T2>, ... are built-in generic delegate types in the System namespace, always returning void.MulticastDelegates underneath — everything about invocation lists, +=/-=, and sequential invocation from lesson 097 applies unchanged.Action for side-effect operations; when you need a computed result back, you want Func instead.You've seen how Action replaces most custom void delegates. Let's check your understanding.
1. What does every variant of Action have in common?
Correct: B
Why B is correct: Every Action variant, regardless of how many type parameters it has, always returns void. The type parameters only ever describe input parameters.
Why A is incorrect: Action (no parameters) through Action<T1,...,T16> support varying numbers of parameters.
Why C is incorrect: Action works with static methods, instance methods, and lambdas equally.
Why D is incorrect: Action is a MulticastDelegate just like custom delegates — it fully supports +=/-=.
Reinforcement: The number of type parameters on Action tells you the parameter list, never a return type.
2. Which built-in delegate type correctly matches void ApplyDiscount(Order order, decimal percent)?
Correct: B
Why B is correct: The method takes an Order then a decimal, in that order, and returns void — exactly what Action<Order, decimal> describes.
Why A is incorrect: That's missing the second parameter entirely.
Why C is incorrect: Func types always have a return value; this method is void.
Why D is incorrect: The parameter order is reversed — delegate type parameter order must match the method's parameter order exactly.
Reinforcement: Type parameter order on Action/Func must match the target method's parameter order precisely.
3. Why does Action reduce the need for custom delegate declarations?
Correct: B
Why B is correct: Generics let Action<T> be declared once and reused for any type T, so the BCL doesn't need a separate named delegate type for every possible signature.
Why A is incorrect: Action has the same invocation mechanism and performance characteristics as any other delegate — there's no special speed advantage.
Why C is incorrect: A method must still match the exact required signature; Action doesn't perform any conversion of mismatched signatures.
Why D is incorrect: The delegate keyword is still very much used — Action itself is defined using it, and custom delegates remain valid and sometimes preferable.
Reinforcement: Generics, applied to delegate types, is exactly what lets Action stand in for an unlimited number of custom void-delegate declarations.
You now know when and how to use Action. Next up: its counterpart for methods that do return a value — Func.
dotnetmadeeasy.com — Learn C# and .NET, the right way.