If you’ve been writing C# for a while, you’ve used delegates and events — but it’s easy to treat them as “that thing UI frameworks do” and move on.

I used to do that too. Then I started building more modular services and realized delegates/events are one of the cleanest ways to decouple behavior without pulling in a full message bus.

Let’s walk through practical patterns you can use today.

Delegates as behavior slots

A delegate is just a type-safe function reference. That sounds small, but it gives you a flexible seam for injecting behavior.

using System;

public static class RetryRunner
{
    public static T RunWithRetry<T>(Func<T> operation, int maxAttempts)
    {
        if (operation is null) throw new ArgumentNullException(nameof(operation));
        if (maxAttempts < 1) throw new ArgumentOutOfRangeException(nameof(maxAttempts));

        Exception? lastError = null;

        for (int attempt = 1; attempt <= maxAttempts; attempt++)
        {
            try
            {
                return operation();
            }
            catch (Exception ex)
            {
                lastError = ex;
            }
        }

        throw new InvalidOperationException("Operation failed after retries.", lastError);
    }
}

The key point: RunWithRetry knows nothing about what it runs. You pass behavior in, and the utility handles policy.

Prefer Func/Action for simple contracts

You don’t always need a custom delegate type. Func<> and Action<> keep things compact when the intent is obvious.

using System;
using System.Collections.Generic;

public sealed class Pipeline
{
    private readonly List<Action<string>> _steps = [];

    public void AddStep(Action<string> step)
    {
        if (step is null) throw new ArgumentNullException(nameof(step));
        _steps.Add(step);
    }

    public void Execute(string input)
    {
        foreach (Action<string> step in _steps)
        {
            step(input);
        }
    }
}

This pattern works great for light processing pipelines, validation chains, and customizable hooks.

Events are publish/subscribe with guardrails

Events wrap delegates with a constraint: external code can subscribe/unsubscribe, but can’t invoke the event directly. That makes your API safer.

using System;

public sealed class JobProcessor
{
    public event EventHandler<JobCompletedEventArgs>? JobCompleted;

    public void Process(Guid jobId)
    {
        // Simulate work...
        DateTimeOffset completedAt = DateTimeOffset.UtcNow;

        OnJobCompleted(new JobCompletedEventArgs(jobId, completedAt));
    }

    private void OnJobCompleted(JobCompletedEventArgs args)
    {
        JobCompleted?.Invoke(this, args);
    }
}

public sealed class JobCompletedEventArgs : EventArgs
{
    public JobCompletedEventArgs(Guid jobId, DateTimeOffset completedAt)
    {
        JobId = jobId;
        CompletedAt = completedAt;
    }

    public Guid JobId { get; }
    public DateTimeOffset CompletedAt { get; }
}

That null-conditional ?.Invoke is important: if nobody subscribed, nothing happens.

Don’t forget to unsubscribe when lifetimes differ

The most common event bug I see is accidental retention: a long-lived publisher keeps a short-lived subscriber alive.

using System;

public sealed class MetricsReporter : IDisposable
{
    private readonly JobProcessor _processor;

    public MetricsReporter(JobProcessor processor)
    {
        _processor = processor;
        _processor.JobCompleted += OnJobCompleted;
    }

    private void OnJobCompleted(object? sender, JobCompletedEventArgs e)
    {
        Console.WriteLine($"Job {e.JobId} finished at {e.CompletedAt:O}");
    }

    public void Dispose()
    {
        _processor.JobCompleted -= OnJobCompleted;
    }
}

If you subscribe in a constructor, it’s usually a good hint that you should unsubscribe in Dispose.

A practical rule of thumb

Here’s what I use:

  • Use delegates (Func/Action) when a caller provides behavior to one callee.
  • Use events when many listeners react to something that happened.
  • Keep event args small and focused on facts, not commands.
  • Be explicit about subscriber lifetime to avoid leaks.

Final thought

Delegates and events aren’t old-school leftovers — they’re still sharp tools for building decoupled, testable C# code.

If your class is growing lots of “also do this” branches, that’s a smell. A delegate seam or an event hook often gives you cleaner extension points with less friction.