Overview
Delegates in C# are type-safe references to methods. A delegate defines a method signature: the return type and parameter list that a compatible method must have. After a delegate instance is created, it can point to a named method, an anonymous method, or a lambda expression, and the code can invoke that method through the delegate.
Delegates matter because they allow behavior to be passed as data. Instead of hard-coding what a method should do, a class or method can accept a delegate parameter and let the caller provide the logic. This is the foundation for callbacks, events, LINQ operators, sorting, filtering, validation rules, notification handlers, and many extensibility points in .NET.
In modern C#, developers often use built-in delegate types such as Action, Func, and Predicate<T> instead of declaring custom delegate types. However, understanding custom delegates is still important because events, framework APIs, asynchronous callbacks, expression trees, and functional-style code all build on the same idea.
Delegates are important in interviews because they test whether a developer understands C# beyond basic object-oriented syntax. Interviewers often use delegates to explore method references, lambdas, events, multicast behavior, variance, closures, asynchronous code, and how .NET enables flexible designs without excessive inheritance.
Core Concepts
What Is a Delegate?
A delegate is a reference type that represents one or more methods with a specific signature.
public delegate int Operation(int x, int y);
This delegate can reference any method that accepts two int parameters and returns an int.
public static int Add(int x, int y) => x + y;
public static int Multiply(int x, int y) => x * y;
Operation operation = Add;
Console.WriteLine(operation(2, 3)); // 5
operation = Multiply;
Console.WriteLine(operation(2, 3)); // 6
The key idea is that operation is not the result of calling Add; it is a reference to the Add method itself.
Delegates are:
- Type-safe: the method signature must be compatible.
- Object-oriented: a delegate is an object derived from
System.DelegateorSystem.MulticastDelegate. - Invokeable: a delegate can be called like a method.
- Composable: delegates can be combined into multicast delegates.
- Common in framework APIs: events, LINQ, callbacks, and asynchronous APIs use delegates heavily.
Delegate Declaration, Instantiation, and Invocation
A custom delegate type is declared with the delegate keyword.
public delegate void Notify(string message);
A delegate instance can be assigned a method group, an anonymous method, or a lambda expression.
public static void SendEmail(string message)
{
Console.WriteLine($"Email: {message}");
}
Notify notify1 = SendEmail;
Notify notify2 = delegate (string message)
{
Console.WriteLine($"Anonymous: {message}");
};
Notify notify3 = message => Console.WriteLine($"Lambda: {message}");
notify1("Order created");
notify2("Order created");
notify3("Order created");
A delegate can be invoked directly:
notify1("Hello");
Or with Invoke:
notify1.Invoke("Hello");
Both forms call the referenced method. Direct invocation is more common, while Invoke can be useful when combined with the null-conditional operator.
notify1?.Invoke("Hello");
Built-In Delegate Types: Action, Func, and Predicate
Modern C# provides built-in generic delegate types that cover most common cases.
Action represents a method that returns void.
Action<string> log = message => Console.WriteLine(message);
log("Application started");
Func represents a method that returns a value. The last generic type argument is the return type.
Func<int, int, int> add = (x, y) => x + y;
int result = add(10, 20);
Predicate<T> represents a method that accepts a value of type T and returns bool.
Predicate<int> isEven = number => number % 2 == 0;
Console.WriteLine(isEven(10)); // True
Common built-in delegates include:
For most application code, prefer Action, Func, and Predicate<T> unless a custom delegate name improves readability or is required by an event/API contract.
Custom Delegates vs Built-In Delegates
A custom delegate is useful when the delegate represents a meaningful domain concept.
public delegate bool DiscountEligibilityRule(Customer customer, Order order);
The same signature could be written with Func<Customer, Order, bool>:
Func<Customer, Order, bool> discountEligibilityRule;
Both approaches work, but they communicate different levels of intent.
Use a custom delegate when:
- The delegate has domain meaning.
- The signature is reused across a public API.
- You want XML documentation on the delegate type.
- You want a clearer name than
Func<T1, T2, TResult>. - You are defining an event pattern or framework-style API.
Use Func, Action, or Predicate<T> when:
- The delegate is simple and local.
- The behavior is passed to a method temporarily.
- The signature is obvious from the context.
- You are writing LINQ-style code.
Delegates as Callback Parameters
A callback is a method supplied by the caller and executed by the callee at a later point or inside a workflow.
public static void ProcessNumbers(IEnumerable<int> numbers, Action<int> process)
{
foreach (int number in numbers)
{
process(number);
}
}
ProcessNumbers([1, 2, 3], number => Console.WriteLine(number));
A more realistic example is validation or filtering.
public static IEnumerable<T> Filter<T>(IEnumerable<T> items, Predicate<T> condition)
{
foreach (T item in items)
{
if (condition(item))
{
yield return item;
}
}
}
var activeUsers = Filter(users, user => user.IsActive);
This pattern avoids hard-coding business rules inside the reusable method. The reusable method controls the workflow, while the delegate supplies the changing behavior.
Delegates and Lambdas
A lambda expression is a concise way to create a delegate instance.
Func<int, int> square = x => x * x;
Lambdas are heavily used with delegates because they allow behavior to be written inline.
var expensiveProducts = products
.Where(product => product.Price > 1000)
.OrderBy(product => product.Name)
.ToList();
In this example, Where and OrderBy accept delegate-based parameters. The lambda expressions describe the filtering and sorting behavior.
A lambda can be:
// Expression lambda
Func<int, bool> isPositive = x => x > 0;
// Statement lambda
Action<string> print = message =>
{
string formatted = message.Trim().ToUpperInvariant();
Console.WriteLine(formatted);
};
Use expression lambdas for simple transformations or predicates. Use statement lambdas when multiple statements are required, but avoid putting complex business logic inline if it harms readability or testability.
Closures and Captured Variables
A lambda can capture variables from the surrounding scope. This is called a closure.
int minimumAge = 18;
Func<User, bool> isAdult = user => user.Age >= minimumAge;
The lambda references minimumAge even though minimumAge is declared outside the lambda.
Closures are powerful, but they can cause bugs when variables change after the delegate is created.
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
int copy = i;
actions.Add(() => Console.WriteLine(copy));
}
foreach (Action action in actions)
{
action();
}
The copy variable avoids accidentally capturing a loop variable in a confusing way. Modern C# handles foreach loop variable capture more safely than older versions, but developers should still be careful when closures are created inside loops.
Closures can also allocate extra objects because the compiler may create a generated class to store captured variables. This usually does not matter for normal business code, but it can matter in hot paths or high-throughput code.
Multicast Delegates
A delegate can reference more than one method. This is called a multicast delegate.
Action<string> notify = SendEmail;
notify += SendSms;
notify += WriteAuditLog;
notify("Order shipped");
When invoked, the methods are called in order.
public static void SendEmail(string message) => Console.WriteLine($"Email: {message}");
public static void SendSms(string message) => Console.WriteLine($"SMS: {message}");
public static void WriteAuditLog(string message) => Console.WriteLine($"Audit: {message}");
Delegates can be combined and removed with +, +=, -, and -=.
notify -= SendSms;
Important multicast behavior:
- Methods are invoked in invocation-list order.
- If one method throws an exception, later methods are not called unless the caller handles invocation manually.
- For non-
voidmulticast delegates, the final return value is the return value of the last invoked method. - Delegates are immutable; combining or removing delegates creates a new delegate instance.
For events and notifications, multicast delegates are useful. For operations where each result matters, manually iterate through GetInvocationList() or use another design.
foreach (Action<string> handler in notify.GetInvocationList())
{
try
{
handler("Order shipped");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
Delegates and Events
Events are built on top of delegates. A delegate defines the handler signature, while the event keyword restricts how outside code can interact with the delegate.
public class OrderService
{
public event EventHandler<OrderCreatedEventArgs>? OrderCreated;
public void CreateOrder(Order order)
{
// Create and save order...
OrderCreated?.Invoke(this, new OrderCreatedEventArgs(order.Id));
}
}
public class OrderCreatedEventArgs : EventArgs
{
public OrderCreatedEventArgs(int orderId)
{
OrderId = orderId;
}
public int OrderId { get; }
}
Subscriber code:
var service = new OrderService();
service.OrderCreated += (sender, args) =>
{
Console.WriteLine($"Order created: {args.OrderId}");
};
An event should be used when an object wants to notify interested subscribers that something happened. A delegate parameter should be used when the caller must provide behavior for the operation to complete.
Example difference:
// Delegate callback: required behavior
products.Sort((x, y) => x.Price.CompareTo(y.Price));
// Event: optional notification
button.Click += OnButtonClicked;
With a public delegate field, outside code could overwrite or invoke the delegate. With an event, outside code can usually only subscribe and unsubscribe.
// Avoid this for public APIs
public Action<string>? MessageReceived;
// Prefer this for notifications
public event Action<string>? MessageReceived;
Delegate Variance: Covariance and Contravariance
Variance allows some delegate assignments to work even when the method signature is not exactly identical but is type-compatible.
Covariance allows a delegate to reference a method that returns a more derived type than the delegate return type.
public class Animal { }
public class Dog : Animal { }
Func<Animal> createAnimal = CreateDog;
static Dog CreateDog() => new Dog();
The delegate expects an Animal, and the method returns a Dog. This is safe because every Dog is an Animal.
Contravariance allows a delegate to reference a method that accepts a less derived parameter type.
Action<Dog> handleDog = HandleAnimal;
static void HandleAnimal(Animal animal)
{
Console.WriteLine(animal.GetType().Name);
}
The delegate expects something that can handle a Dog. A method that can handle any Animal can safely handle a Dog.
For generic delegate declarations:
public delegate TResult Transformer<in TInput, out TResult>(TInput input);
inmeans the type parameter is contravariant and used as an input.outmeans the type parameter is covariant and used as an output.
Func and Action use variance annotations. Func return types are covariant, and Action parameter types are contravariant.
Variance is an advanced interview topic. A strong answer should focus on type safety: covariance is about safely returning more specific types, and contravariance is about safely accepting less specific input types.
Delegates, Interfaces, and Strategy Pattern
Delegates and interfaces can both represent replaceable behavior, but they are useful in different situations.
A delegate is often best when the behavior is a single operation.
public decimal CalculateTotal(Order order, Func<Order, decimal> discountRule)
{
decimal discount = discountRule(order);
return order.Subtotal - discount;
}
An interface is often better when the behavior has multiple related operations or needs a named abstraction.
public interface IDiscountPolicy
{
bool IsEligible(Order order);
decimal CalculateDiscount(Order order);
}
Use delegates when:
- The operation is simple and single-method.
- You want lightweight callback behavior.
- You want to avoid creating many small classes.
- The behavior is local to a method call.
Use interfaces when:
- The abstraction has multiple methods.
- The behavior needs dependency injection, lifetime management, or state.
- The implementation is complex and should be tested independently.
- The design benefits from a named contract.
In interviews, this comparison often appears as “When would you use a delegate instead of an interface?” A practical answer is: use delegates for small function-like behavior and interfaces for larger object-like behavior.
Delegates and LINQ
LINQ relies heavily on delegates. Many LINQ methods accept Func parameters.
var names = users
.Where(user => user.IsActive)
.Select(user => user.Name)
.OrderBy(name => name)
.ToList();
Common LINQ delegate signatures include:
Func<User, bool> predicate = user => user.IsActive;
Func<User, string> selector = user => user.Name;
Func<User, DateTime> keySelector = user => user.CreatedAt;
LINQ demonstrates one of the most common real-world uses of delegates: expressing query behavior without changing the collection-processing algorithm.
For IEnumerable<T>, lambdas usually compile to delegates. For IQueryable<T>, lambdas may be represented as expression trees, such as Expression<Func<T, bool>>, so the provider can translate the expression to SQL or another query language.
Func<User, bool> inMemoryFilter = user => user.IsActive;
Expression<Func<User, bool>> databaseFilter = user => user.IsActive;
This distinction matters in Entity Framework Core interviews. A Func<T, bool> usually means client-side executable code, while Expression<Func<T, bool>> means inspectable expression data that can often be translated by a provider.
Delegates and Expression Trees
A delegate is executable behavior. An expression tree is a data structure that represents code.
Func<int, int> compiled = x => x + 1;
Expression<Func<int, int>> expression = x => x + 1;
The delegate can be invoked directly:
int value = compiled(10);
The expression tree can be inspected, transformed, or translated:
Console.WriteLine(expression.Body); // (x + 1)
Expression trees are used by query providers, rule engines, dynamic filters, and libraries that need to analyze code rather than immediately execute it.
Use Func<T, TResult> when you want to run code. Use Expression<Func<T, TResult>> when another component needs to inspect or translate the code.
Async Delegates
Delegates can reference asynchronous methods.
Func<Task> saveAsync = async () =>
{
await Task.Delay(100);
Console.WriteLine("Saved");
};
await saveAsync();
For asynchronous operations, prefer delegates that return Task or Task<T>.
Func<int, Task<User>> getUserAsync = async id =>
{
await Task.Delay(100);
return new User(id, "Minh");
};
Avoid async void except for event handlers.
// Avoid for normal callback APIs
Action save = async () =>
{
await Task.Delay(100);
throw new InvalidOperationException("This exception is hard to observe.");
};
The lambda above is accepted because Action returns void, but the asynchronous exception cannot be awaited by the caller. Prefer:
Func<Task> save = async () =>
{
await Task.Delay(100);
throw new InvalidOperationException("The caller can observe this by awaiting.");
};
await save();
For event handlers, async void is allowed because event signatures typically return void, but the handler should still catch and handle exceptions when needed.
button.Click += async (sender, args) =>
{
try
{
await SaveAsync();
}
catch (Exception ex)
{
Log(ex);
}
};
Delegate Nullability and Safe Invocation
A delegate variable can be null if no method has been assigned.
Action<string>? log = null;
log?.Invoke("Message");
For events, nullability is common because there may be no subscribers.
public event EventHandler? Completed;
protected virtual void OnCompleted()
{
Completed?.Invoke(this, EventArgs.Empty);
}
In older code, developers copied the delegate to a local variable before invoking it to avoid a race where subscribers unsubscribe between the null check and invocation.
EventHandler? handler = Completed;
handler?.Invoke(this, EventArgs.Empty);
The null-conditional operator also evaluates the receiver once, making it a clean modern pattern for most event invocation code.
Delegate Immutability
Delegate instances are immutable. When a delegate is combined or removed, C# creates a new delegate instance with a different invocation list.
Action handler = First;
handler += Second; // Creates a new combined delegate instance
handler -= First; // Creates another delegate instance
This immutability helps make delegate invocation safer and predictable, but it also means repeatedly combining delegates in performance-critical loops can create allocations.
For normal event subscription and callback code, this cost is usually negligible. For high-performance code, avoid repeatedly allocating new lambdas or delegate instances in hot paths.
Exception Behavior in Multicast Delegates
When a multicast delegate is invoked normally, each method is called in order. If one method throws an exception, the invocation stops and the exception is propagated to the caller.
Action notify = Handler1;
notify += Handler2;
notify += Handler3;
notify(); // If Handler2 throws, Handler3 is not called.
If every subscriber must be attempted, invoke each handler manually.
foreach (Action handler in notify.GetInvocationList())
{
try
{
handler();
}
catch (Exception ex)
{
Console.WriteLine($"Handler failed: {ex.Message}");
}
}
This is important for plugin systems, notification systems, and domain event dispatching where one handler failure should not necessarily stop all other handlers.
Return Values in Multicast Delegates
A multicast delegate can have a return type, but normal invocation returns only the value from the last method in the invocation list.
Func<int> getNumber = () => 1;
getNumber += () => 2;
getNumber += () => 3;
Console.WriteLine(getNumber()); // 3
This behavior is often surprising. For this reason, multicast delegates are usually clearer with void return types, especially for events.
If multiple return values are required, manually enumerate the invocation list.
foreach (Func<int> provider in getNumber.GetInvocationList())
{
int result = provider();
Console.WriteLine(result);
}
Performance Considerations
Delegates are efficient enough for most application code, but they are still indirect calls and can involve allocations depending on how they are used.
Common allocation sources include:
- Capturing lambdas, because captured variables may require a compiler-generated closure object.
- Creating new delegate instances repeatedly in loops.
- Combining and removing multicast delegates frequently.
- Using delegates where a direct method call would be simpler in a hot path.
Example of a non-capturing lambda:
Func<int, int> square = static x => x * x;
The static lambda modifier prevents capturing variables from the surrounding scope.
int factor = 10;
// This would not compile because a static lambda cannot capture factor.
// Func<int, int> multiply = static x => x * factor;
Use static lambdas when you want to make accidental captures impossible.
For normal business applications, readability and correct design are usually more important than micro-optimizing delegate overhead. For performance-critical paths, measure before optimizing.
Common Mistakes
A common mistake is using a custom delegate when Func, Action, or Predicate<T> would be clearer and simpler.
// Usually unnecessary
public delegate bool Check(int value);
// Usually simpler
Predicate<int> check = value => value > 0;
Another mistake is using Action with an asynchronous lambda.
// Problematic: async void behavior
Action work = async () => await SaveAsync();
Prefer:
Func<Task> work = async () => await SaveAsync();
await work();
Another common mistake is exposing a public delegate field instead of an event.
// Bad public API
public Action<string>? DataChanged;
Prefer:
public event Action<string>? DataChanged;
Another mistake is assuming multicast delegates collect every return value. Normal invocation only returns the last handler result.
Another mistake is forgetting that closures capture variables, not just values. If a variable changes before the delegate runs, the delegate may observe the changed value.
Best Practices
Use built-in delegates for simple local behavior.
Func<decimal, decimal> applyTax = amount => amount * 1.1m;
Use custom delegates when the name adds domain meaning or improves a public API.
public delegate bool AuthorizationRule(User user, Resource resource);
Use events for notifications and delegates for required callbacks.
public event EventHandler? Completed;
public void Retry(Func<Task> operation)
{
// operation is required for Retry to do useful work
}
Use EventHandler or EventHandler<TEventArgs> for conventional .NET events.
public event EventHandler<OrderCreatedEventArgs>? OrderCreated;
Use Func<Task> or Func<T, Task<TResult>> for asynchronous callbacks.
public Task ExecuteAsync(Func<CancellationToken, Task> operation, CancellationToken cancellationToken)
{
return operation(cancellationToken);
}
Avoid unnecessary captures in hot paths. Use static lambdas when useful.
var numbers = Enumerable.Range(1, 10).Select(static x => x * 2);
Keep delegate logic readable. If a lambda becomes too large, move it into a named method.
var validOrders = orders.Where(IsValidOrder);
static bool IsValidOrder(Order order)
{
return order.Total > 0 && order.CustomerId is not null;
}