Hello. In the previous lesson, you chose collections based on the operations and guarantees the code needs: lookup, uniqueness, ordering, or shared access. This lesson shifts from storing data to passing behavior: how one part of a C# application can call supplied code or notify interested components without knowing their concrete types.
Priority: Must know. Spend about 40 minutes here. The interview goal is not merely to define a delegate or event, but to explain which mechanism you would choose, what it guarantees, and where its boundaries are.
A delegate is a typed reference to behavior
A delegate is a type-safe object that represents a method with a particular signature. The signature specifies the parameter types and return type that a compatible method must have.
public delegate void ImportCompleted(ImportSummary summary);
ImportCompleted is a delegate type, not a method and not a delegate instance. It says: “a compatible callback accepts an ImportSummary and returns nothing.”
You can then create a delegate instance by assigning a compatible method:
ImportCompleted callback = WriteImportAudit;
callback(summary);
static void WriteImportAudit(ImportSummary summary)
{
Console.WriteLine(
$"Import {summary.ImportId} completed with {summary.AcceptedCount} rows.");
}
The caller invokes callback, but the delegate invokes WriteImportAudit. This is the key indirection: the caller depends on a behavioral contract rather than a specific class or method implementation.

A delegate can refer to:
- a static method, where it stores the method reference;
- an instance method, where it stores both the method and the target object instance;
- a lambda expression, which provides the method inline.
For example:
ImportCompleted callback = summary =>
logger.LogInformation(
"Import {ImportId} completed with {AcceptedCount} accepted rows.",
summary.ImportId,
summary.AcceptedCount);
The lambda is simply another way to supply behavior compatible with the delegate’s signature.
A useful interview correction is:
A callback is not inherently asynchronous. It means code is supplied by one component and invoked by another, often later. The invocation may be synchronous unless the API explicitly introduces asynchronous behavior.
For ordinary cases, you normally use the built-in delegate types rather than declaring a custom delegate:
| Type | Meaning | Example use |
|---|---|---|
Action | No parameters, no return value | Run a simple notification |
Action<T> | Parameters, no return value | Report progress or process a value |
Func<TResult> | Returns a value | Supply a value factory |
Func<T, TResult> | Input and return value | Supply a calculation, mapping, or predicate |
Predicate<T> | Takes T, returns bool | Test a condition; less common than Func<T, bool> in new code |
| Custom delegate | A named domain-specific callback contract | A public API where the name adds clarity |
For example, a method that accepts a caller-supplied comparison rule might use:
var sortedClaims = claims
.OrderBy(claim => claim.SubmittedAt)
.ThenBy(claim => claim.ClaimNumber);
LINQ accepts delegates such as selector and predicate functions. You will examine LINQ execution behavior in the next lesson; for now, notice the design: the LINQ implementation owns the iteration, while the caller supplies the business rule.
Using Delegates (C# Programming Guide) - Microsoft Learn
Read Microsoft Learn’s “Using Delegates” to consolidate the delegate model, callback purpose, and multicast behavior.
On the “Using Delegates” page, begin with the opening explanation and examples. Read the delegate model, focusing on the distinction between the delegate type, a delegate instance, and the attached method. Then continue in the material immediately after the MethodClass example, where the discussion introduces multiple attached methods. Read multicast behavior. Note the invocation order and what happens when one handler throws an exception.
Delegates make callbacks flexible, but choose the callback contract deliberately
A callback works well when one operation needs a piece of caller-defined behavior.
For example, consider an import operation that performs its work and then invokes a callback:
public async Task<ImportSummary> ProcessAsync(
ImportRequest request,
Func<ImportSummary, Task>? onCompleted = null)
{
var summary = await ExecuteImportAsync(request);
if (onCompleted is not null)
{
await onCompleted(summary);
}
return summary;
}
The import service does not know whether the caller will log, update a UI model, emit a metric, or perform another local action. It only knows the callback contract.
Using Func<ImportSummary, Task> here is deliberate. It lets the callback perform asynchronous work, and the method can await it. Avoid accepting Action<ImportSummary> and passing it an async lambda when completion or failure matters: that commonly creates async void behavior, which the caller cannot await or reliably handle.
A callback is a good fit when:
- there is one intended caller-supplied behavior;
- the operation is still fundamentally request-response;
- the caller needs to influence processing through a return value or an awaited result;
- the delegate signature communicates the extension point clearly.
It is less appropriate when a component is announcing a fact to an unknown number of listeners. That is the role of an event.
Interview pause: delegate field or event?
Stop and answer aloud before reading the model answer.
A class exposes
public Action<string>? StatusChanged;. A teammate says this is effectively the same as declaringpublic event Action<string>? StatusChanged;. What is the practical difference, and which would you expose for notifications?
Model answer
Both use delegates, but they expose different permissions.
A public delegate field allows outside code to assign it, replace all handlers, set it to null, or invoke it directly. That means another class could accidentally erase existing listeners or falsely raise the notification.
A public event exposes subscription and unsubscription through += and -=, while only the declaring class can invoke or replace the underlying delegate. For publisher-subscriber notifications, I would expose an event because the publisher should own when the event is raised.
If I need a single callback supplied as part of one method call, I would use a delegate parameter such as Action<T> or Func<T, Task> rather than storing an event.
Lambdas: concise callbacks, with one lifetime caveat
A lambda is often the clearest way to provide short, local behavior:
var failedClaims = claims.Where(claim => claim.Status == ClaimStatus.Failed);
Here, claim => claim.Status == ClaimStatus.Failed is compatible with a delegate that accepts a claim and returns bool.
Lambdas can also capture local variables:
var completedCount = 0;
ImportCompleted callback = summary =>
{
completedCount += summary.AcceptedCount;
};
The lambda captures the variable completedCount, not a one-time copied value. That is useful, but it has implications when the delegate is stored and called later:
- the captured object can remain alive as long as the delegate remains reachable;
- a changing captured variable may have a different value by the time the callback runs;
- shared captured state needs the same thread-safety review as any other shared state.
For short, immediately invoked LINQ expressions, this is usually straightforward. For a long-lived event subscription or background callback, be more deliberate about object lifetime and state ownership.
Multicast delegates: one invocation can call several methods
Delegates can have an invocation list. Adding handlers with += creates a multicast delegate:
Action<ImportSummary> handlers = WriteAuditRecord;
handlers += PublishMetric;
handlers += NotifyOperations;
handlers(summary);
The handlers are invoked in registration order. This is the mechanism beneath normal C# events.
The behavior has important practical consequences:
-
An uncaught exception stops later handlers.
IfPublishMetricthrows,NotifyOperationsis not called. -
Return values do not compose naturally.
For a multicast delegate with a return value, the return value from the final handler is the one returned to the caller. This is a strong reason events conventionally usevoid. -
A multicast async delegate needs care.
If severalFunc<T, Task>handlers are attached, directly invoking and awaiting the delegate only awaits the final returned task. Do not treat a multicastFunc<T, Task>as a robust asynchronous event mechanism without explicitly managing each invocation. -
The publisher must define failure semantics.
For an optional telemetry notification, it may be reasonable to isolate and log a handler failure. For a business-critical operation, silently swallowing handler exceptions can hide data loss or an incomplete workflow.
This is why delegates are powerful but not magical. They decouple code at the method-call level; they do not automatically provide retries, durable delivery, transactionality, or cross-process communication.
Events: controlled, one-to-many notification
An event is a publisher-controlled notification mechanism built on delegates.
The publisher declares an event:
public sealed class ImportProcessor
{
public event EventHandler<ImportCompletedEventArgs>? Completed;
public ImportSummary Process(ImportRequest request)
{
var summary = ExecuteImport(request);
OnCompleted(new ImportCompletedEventArgs(
summary.ImportId,
summary.AcceptedCount,
summary.RejectedCount));
return summary;
}
private void OnCompleted(ImportCompletedEventArgs args)
{
Completed?.Invoke(this, args);
}
private static ImportSummary ExecuteImport(ImportRequest request)
{
// Import implementation omitted.
throw new NotImplementedException();
}
}
public sealed class ImportCompletedEventArgs : EventArgs
{
public ImportCompletedEventArgs(
Guid importId,
int acceptedCount,
int rejectedCount)
{
ImportId = importId;
AcceptedCount = acceptedCount;
RejectedCount = rejectedCount;
}
public Guid ImportId { get; }
public int AcceptedCount { get; }
public int RejectedCount { get; }
}
Subscribers attach handlers:
processor.Completed += OnImportCompleted;
static void OnImportCompleted(
object? sender,
ImportCompletedEventArgs args)
{
Console.WriteLine(
$"Import {args.ImportId}: {args.AcceptedCount} accepted, " +
$"{args.RejectedCount} rejected.");
}
Outside ImportProcessor, code can subscribe and unsubscribe:
processor.Completed -= OnImportCompleted;
But outside code cannot do either of these:
// Not allowed outside ImportProcessor:
// processor.Completed = null;
// processor.Completed?.Invoke(processor, args);
That restriction is the value of the event keyword. It protects the publisher’s notification mechanism: subscribers can listen, but only the publisher decides when a business event has occurred.
The standard .NET event pattern
The usual .NET convention is:
event EventHandler? SomethingHappened;
event EventHandler<TEventArgs>? SomethingHappened;
The standard handler shape is:
void Handler(object? sender, TEventArgs args)
It uses:
void, because zero or many subscribers can receive a notification;sender, identifying the object that raised it;EventArgsor a derived type, carrying facts about what happened.
Event arguments should usually be immutable facts. If subscribers are intentionally allowed to influence the publisher, such as requesting cancellation, the event-argument type may have a documented mutable property. That is a specialized protocol: define clearly whether any subscriber may cancel or whether another rule applies.
Watch Tarodev’s “C# Events & Delegates” for a compact code-led review of delegate assignment, built-in delegates, publisher-subscriber decoupling, event access restrictions, and unsubscription.
Watch delegate basics to review signature matching, invocation, null-safe invocation, and multicasting. Then watch built-in delegates for the practical roles of EventHandler, Action, and Func. Focus on how parameters and return types determine compatibility. Watch decoupling example, where multiple classes react to a publisher without the publisher directly depending on them. Then watch event restrictions to see precisely what the event keyword prevents outside callers from doing. Finally, watch subscription lifetime. Treat the example as a general object-lifetime rule, not only a game-development concern.
Events decouple notifications, not business guarantees
The main architectural benefit of an event is that a publisher depends on an event contract, not on every listener.
For instance, an import processor could raise Completed without knowing that one subscriber writes diagnostics and another updates an in-memory dashboard. Adding a new subscriber does not require changing the processor.
However, a senior-level answer must also state the boundary:
A normal in-process C# event is synchronous by default and exists only in the current process. It is not a durable message bus, and it does not guarantee that a notification survives a restart or reaches another application instance.
This matters in ASP.NET Core applications. If “send a confirmation email after a claim is approved” must reliably happen even if the process stops just after approval, an in-memory event handler is insufficient on its own. The approval and reliable downstream work need an appropriate persisted design. You will return to durable messaging and recovery paths in the architecture stage.
For local, optional notifications, ordinary events are often appropriate. For a critical request-response rule, a direct method call or interface contract is usually clearer. For a caller-supplied calculation or one-off completion action, a delegate parameter is often the better fit.
| Need | Prefer | Why |
|---|---|---|
| A caller supplies one behavior or calculation | Delegate parameter, such as Func<T, TResult> | The operation invokes a specific callback contract |
| One component announces something to many local listeners | Event | Publisher controls raising; subscribers can independently attach |
| One service requires another service to perform a mandatory capability | Interface and dependency injection | The dependency and its required result are explicit |
| Reliable delivery across restarts or application instances | Durable messaging or persisted workflow | In-memory delegates and events cannot provide this guarantee |
Subscription lifetime is a correctness concern
When a subscriber attaches to an event, the publisher’s delegate typically holds a reference to the subscriber’s handler and target object.
If the publisher lives longer than the subscriber, the subscriber can remain reachable and cannot be garbage-collected. This is a common source of memory leaks, especially with:
- singleton or static publishers;
- long-lived caches or application-wide coordinators;
- UI objects subscribing to services;
- repeatedly created scoped objects subscribing to a singleton.
Use a named handler or store the delegate instance when you will need to unsubscribe:
public sealed class ImportMonitor : IDisposable
{
private readonly ImportProcessor _processor;
public ImportMonitor(ImportProcessor processor)
{
_processor = processor;
_processor.Completed += OnImportCompleted;
}
private void OnImportCompleted(
object? sender,
ImportCompletedEventArgs args)
{
Console.WriteLine($"Monitoring import {args.ImportId}");
}
public void Dispose()
{
_processor.Completed -= OnImportCompleted;
}
}
This does not mean every subscription must always be manually removed. If the publisher and subscriber have the same lifetime, or the publisher is guaranteed to be collected first, explicit unsubscription may be unnecessary. The key is to reason about ownership and lifetime rather than applying an unconditional rule.
Avoid this when later unsubscription is required:
processor.Completed += (_, args) =>
Console.WriteLine(args.ImportId);
The expression creates a delegate instance, but writing the same lambda syntax later creates a different delegate instance. You would have no reference to remove the original handler.
Senior interview drill: decoupling with appropriate guarantees
Stop here and answer aloud for about two minutes before reading the model response.
A claim-import service currently saves an import, writes an audit record, emits telemetry, and sends a completion email in one method. A teammate proposes a C#
ImportCompletedevent so that audit, telemetry, and email become independent subscribers. How would you assess this design? What would you keep synchronous, and what questions would you ask?
Model answer
An in-process ImportCompleted event can reduce direct coupling if the publisher should only announce that an import completed and the subscribers are local, optional reactions. Telemetry is often a good candidate, provided failures are handled deliberately and do not incorrectly fail the import.
I would first clarify which actions are mandatory. If the audit record is required for compliance and must be committed with the import result, I would not make it a best-effort event handler. I would include it in the required persistence workflow or otherwise define a durable consistency mechanism.
Email should not necessarily block the user-facing import operation, but an ordinary C# event does not make it reliable or asynchronous. I would ask whether email must be sent exactly once, whether a restart or deployment can occur after the import commits, whether the application has multiple instances, and how failures and retries are observed. If delivery matters, the design needs durable work tracking rather than only an in-memory event subscription.
I would also define event timing: does ImportCompleted mean validation finished, database changes committed, or all downstream work finished? Finally, I would establish handler failure behavior, ownership of subscriptions, and observability. The publisher should remain decoupled from optional listeners, but it should not hide a mandatory business dependency behind an event.
Key takeaways
- A delegate represents a method with a specific signature. It enables callbacks by allowing code to accept or store behavior.
- Prefer built-in types such as
ActionandFuncunless a named custom delegate makes a public domain contract clearer. - A lambda is concise syntax for supplying compatible delegate behavior. Be aware of captured state and delegate lifetime.
- Delegates can multicast. Handlers run in registration order, and an uncaught exception prevents later handlers from running.
- An event is a publisher-controlled delegate-based notification mechanism. External code can subscribe and unsubscribe, but only the publisher can raise the event.
- Use standard
EventHandler<TEventArgs>for conventional .NET events. Events generally represent notifications, not request-response operations. - In-process events are synchronous by default and are not durable, transactional, or cross-instance messaging.
- If a long-lived publisher outlives subscribers, unsubscribe when the subscription is no longer needed.
Next, you will apply these callback concepts while examining LINQ query execution: deferred versus immediate execution, materialization, projection, filtering, and the risks of multiple enumeration.
Can't find a good explanation? Sign up and we'll make it for you
Sign up