Callbacks in a Notification System
Picture a notification system that sends alerts when orders complete. You need a way for different parts of your application to react when an order processes. Email notifications, inventory updates, and analytics tracking all need to run when the event fires. Should you use delegates or interfaces for these callbacks?
Delegates excel at single-method scenarios like this. You can register multiple handlers with minimal boilerplate, and each subscriber provides a simple method reference. The notification system calls all registered handlers without knowing their types or implementations. This pattern appears throughout .NET in events, LINQ, and async callbacks.
You'll learn when delegates provide the cleanest solution and when interfaces make more sense. By understanding the strengths of each approach, you'll write more maintainable code that uses the right abstraction for each scenario.
Using Delegates for Simple Callbacks
Delegates define method signatures that can reference any compatible method. Think of them as type-safe function pointers. When you only need to call a single method, delegates provide a lightweight way to pass behavior without creating full classes.
The built-in Action and Func delegates handle most scenarios. Action works for methods that return void, while Func handles methods with return values. These generic delegates save you from declaring custom delegate types for every callback signature.
Delegates support multicast behavior where one delegate instance can call multiple methods sequentially. This makes them perfect for event notifications where many subscribers need to respond to a single event. The publisher doesn't know or care how many handlers are registered.
public class OrderProcessor
{
// Delegate for order completion notification
public event Action OrderCompleted;
public void ProcessOrder(Order order)
{
// Process the order
order.Status = OrderStatus.Completed;
order.CompletedAt = DateTime.UtcNow;
// Notify all subscribers
OrderCompleted?.Invoke(order);
}
}
public class EmailNotifier
{
public void SendConfirmation(Order order)
{
Console.WriteLine($"Sending email for order {order.Id}");
}
}
public class InventoryManager
{
public void UpdateStock(Order order)
{
Console.WriteLine($"Updating inventory for order {order.Id}");
}
}
// Usage
var processor = new OrderProcessor();
var emailer = new EmailNotifier();
var inventory = new InventoryManager();
// Subscribe multiple handlers
processor.OrderCompleted += emailer.SendConfirmation;
processor.OrderCompleted += inventory.UpdateStock;
processor.ProcessOrder(new Order { Id = 123 });
The OrderCompleted event uses Action<Order> to notify subscribers. Each handler receives the order and performs its specific task. The processor doesn't know about EmailNotifier or InventoryManager classes. This loose coupling makes it easy to add or remove handlers without changing the processor. The null-conditional operator ensures safe invocation even when no subscribers exist.
When Interfaces Provide Better Structure
Interfaces define contracts with multiple related methods. When your callback needs state, multiple operations, or fits into a dependency injection container, interfaces offer better organization than delegates.
An interface makes dependencies explicit and visible. When you see IOrderHandler in a constructor, you know that class depends on order processing behavior. With delegates, the dependency might be hidden in a method parameter or property setter, making the design less obvious.
Testing becomes simpler with interfaces too. Mocking frameworks like Moq work naturally with interfaces, generating test doubles automatically. You can verify method calls, set up specific return values, and check interaction patterns easily. Delegates require manual test doubles or capturing invocations in variables.
public interface IOrderHandler
{
void HandleOrder(Order order);
bool CanHandle(Order order);
int Priority { get; }
}
public class PaymentProcessor : IOrderHandler
{
private readonly IPaymentGateway _gateway;
public PaymentProcessor(IPaymentGateway gateway)
{
_gateway = gateway;
}
public int Priority => 1;
public bool CanHandle(Order order)
{
return order.TotalAmount > 0;
}
public void HandleOrder(Order order)
{
_gateway.ProcessPayment(order.TotalAmount);
Console.WriteLine($"Payment processed for order {order.Id}");
}
}
public class OrderProcessor
{
private readonly IEnumerable _handlers;
public OrderProcessor(IEnumerable handlers)
{
_handlers = handlers.OrderBy(h => h.Priority);
}
public void ProcessOrder(Order order)
{
foreach (var handler in _handlers)
{
if (handler.CanHandle(order))
{
handler.HandleOrder(order);
}
}
}
}
The IOrderHandler interface defines three members that work together. CanHandle filters which orders a handler processes, Priority controls execution order, and HandleOrder does the actual work. PaymentProcessor injects its own dependency (IPaymentGateway) and maintains state across calls. The processor receives all handlers through constructor injection, making dependencies clear and testable. This structure scales better than delegates when handlers need complex logic or multiple coordinating methods.
Composing Behavior with Func and Action
Delegates shine in functional programming patterns where you compose small, focused functions. LINQ operators like Where, Select, and OrderBy all take delegates, letting you build complex queries from simple building blocks.
You can pass delegates inline as lambdas, reference existing methods, or compose them together. This flexibility makes delegates perfect for transformation pipelines where each step is a pure function without side effects or state.
The Func delegate family supports up to 16 parameters plus a return value. Action handles void-returning methods with up to 16 parameters. These cover nearly every callback scenario without custom delegate declarations. When you need custom delegates, define them for domain clarity, not technical necessity.
public class DataPipeline
{
private readonly List> _transformations = new();
private readonly List> _sideEffects = new();
public DataPipeline AddTransform(Func transform)
{
_transformations.Add(transform);
return this;
}
public DataPipeline AddSideEffect(Action effect)
{
_sideEffects.Add(effect);
return this;
}
public T Execute(T input)
{
var result = input;
// Apply transformations
foreach (var transform in _transformations)
{
result = transform(result);
}
// Execute side effects
foreach (var effect in _sideEffects)
{
effect(result);
}
return result;
}
}
// Usage
var pipeline = new DataPipeline()
.AddTransform(s => s.Trim())
.AddTransform(s => s.ToUpperInvariant())
.AddSideEffect(s => Console.WriteLine($"Processed: {s}"))
.AddSideEffect(s => File.AppendAllText("log.txt", s + "\n"));
string result = pipeline.Execute(" hello world ");
This pipeline uses Func<T, T> for transformations that modify data and Action<T> for side effects like logging. Each delegate is a single, composable operation. The fluent API makes building pipelines readable and maintainable. Trying to do this with interfaces would require creating wrapper classes for every operation, adding boilerplate without benefit. Delegates keep the focus on behavior, not ceremony.
Integrating with Dependency Injection
Dependency injection containers work best with interfaces and concrete types. While you can register delegates, interfaces provide better discoverability and clearer service contracts.
When registering services, the container needs to know what to inject. IOrderHandler is self-documenting, while Action<Order> is generic and could mean anything. Interfaces also support decoration and interception more naturally in DI frameworks.
However, you can register delegate factories that create instances. This pattern works well when the delegate wraps complex construction logic or conditionally returns different implementations. The container calls your factory delegate each time it resolves the service.
using Microsoft.Extensions.DependencyInjection;
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
// Register interface-based services (preferred)
services.AddScoped();
services.AddScoped();
// Register delegate factory for complex creation
services.AddScoped>(provider =>
{
return notifierType => notifierType switch
{
"email" => provider.GetRequiredService(),
"sms" => provider.GetRequiredService(),
_ => throw new ArgumentException($"Unknown notifier: {notifierType}")
};
});
// Register delegate for simple callback
services.AddSingleton>(msg =>
{
Console.WriteLine($"[LOG] {DateTime.UtcNow}: {msg}");
});
}
}
public class OrderService
{
private readonly IEnumerable _handlers;
private readonly Func _notifierFactory;
public OrderService(
IEnumerable handlers,
Func notifierFactory)
{
_handlers = handlers;
_notifierFactory = notifierFactory;
}
public void ProcessOrder(Order order, string notificationType)
{
foreach (var handler in _handlers)
{
handler.HandleOrder(order);
}
var notifier = _notifierFactory(notificationType);
notifier.Send($"Order {order.Id} completed");
}
}
The service collection registers multiple IOrderHandler implementations automatically. The container injects all registered handlers as IEnumerable<IOrderHandler>. The delegate factory provides runtime selection of notifiers based on string input, which is harder to express with pure interface registration. The simple logging Action shows how delegates work for fire-and-forget operations. Choose interfaces for core services and delegates for factories or simple callbacks.
Comparing Approaches
Choose delegates when you need a single method callback, event notification, or functional composition. They work great for LINQ transformations, async callbacks, and simple strategy patterns. Delegates reduce boilerplate and make code more concise when you don't need multiple related methods.
Choose interfaces when you have multiple related methods, need dependency injection support, or want explicit contracts. Interfaces provide better IntelliSense, clearer documentation, and easier mocking. They make dependencies visible in constructors rather than hidden in delegate parameters.
If unsure, start with delegates for single-method scenarios and refactor to interfaces when you add a second related method. Monitor readability: if you're creating many small classes that implement single-method interfaces, delegates might simplify the design. If delegates lead to confusing lambda chains or unclear dependencies, interfaces bring structure.
Hands-On: Event Publisher System
Build a notification system that demonstrates both delegates and interfaces. You'll implement event publishing with multicast delegates and structured handlers with interfaces.
Steps
- Init project:
dotnet new console -n EventPub
- Open folder:
cd EventPub
- Update Program.cs below
- Modify EventPub.csproj
- Start:
dotnet run
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
// Event publisher using delegates
public class EventPublisher
{
public event Action? MessagePublished;
public void Publish(string message)
{
Console.WriteLine($"Publishing: {message}");
MessagePublished?.Invoke(message);
}
}
// Subscribers
void LogToConsole(string msg) => Console.WriteLine($" [Console] {msg}");
void LogToFile(string msg) => Console.WriteLine($" [File] Writing: {msg}");
// Demo
var publisher = new EventPublisher();
// Subscribe handlers
publisher.MessagePublished += LogToConsole;
publisher.MessagePublished += LogToFile;
publisher.MessagePublished += msg => Console.WriteLine($" [Lambda] {msg}");
// Publish event
publisher.Publish("System started");
publisher.Publish("User logged in");
// Unsubscribe one handler
publisher.MessagePublished -= LogToFile;
Console.WriteLine("\nAfter unsubscribing file logger:");
publisher.Publish("Configuration changed");
Console
Publishing: System started
[Console] System started
[File] Writing: System started
[Lambda] System started
Publishing: User logged in
[Console] User logged in
[File] Writing: User logged in
[Lambda] User logged in
After unsubscribing file logger:
Publishing: Configuration changed
[Console] Configuration changed
[Lambda] Configuration changed
Testing Strategies
Testing delegate-based code requires different techniques than interface-based code. Understanding both approaches helps you verify behavior effectively.
For delegates, create test doubles by passing lambda expressions or method references. Capture invocations in a list to verify the delegate was called with expected arguments. This works well for simple callbacks but becomes verbose for complex scenarios with multiple method calls.
Interfaces integrate naturally with mocking frameworks like Moq or NSubstitute. You can verify method calls, set up return values, and check interaction patterns in one line. The mocking framework generates the test double automatically, reducing boilerplate. For complex verification, interfaces save time and make tests more readable.
Consider testability when choosing between delegates and interfaces. If you find yourself writing complex delegate capture logic in multiple tests, an interface might simplify testing. If your tests just need to verify a callback ran, delegates work fine with simple lambda test doubles.