When Default Events Aren't Enough
Picture building a real-time stock ticker where thousands of clients subscribe to price updates. Your typical event works fine until you realize every subscription creates a strong reference that prevents garbage collection. Or you need to log every subscriber for debugging—except standard events don't give you that hook. Or multiple threads subscribe simultaneously and you hit race conditions.
The add and remove contextual keywords let you implement custom event accessors that handle exactly these scenarios. When clients subscribe using += or unsubscribe with -=, your accessor code runs instead of the compiler's default implementation. You control storage, threading, logging, and cleanup.
You'll learn when custom accessors beat auto-implemented events, how to write thread-safe subscription logic, and how to store event handlers using WeakReference to avoid memory leaks. By the end, you'll have working patterns you can drop into production code whenever standard events fall short.
Understanding Standard Event Behavior
Before diving into custom accessors, you need to see what the compiler does for you. An auto-implemented event creates a private backing field and generates add/remove accessors automatically. When you write public event EventHandler MyEvent;, the compiler creates hidden accessors that use lock-based thread-safe subscription logic.
This works for most cases but hides the underlying mechanism. Once you understand the generated pattern, you can replace it with custom logic that fits your specific requirements.
namespace EventDemo;
public class TemperatureSensor
{
// Auto-implemented event - compiler generates backing field
public event EventHandler<TemperatureChangedEventArgs>? TemperatureChanged;
private double currentTemp = 20.0;
public void UpdateTemperature(double newTemp)
{
currentTemp = newTemp;
// Thread-safe event raising pattern
TemperatureChanged?.Invoke(this,
new TemperatureChangedEventArgs(currentTemp));
}
}
public class TemperatureChangedEventArgs : EventArgs
{
public double Temperature { get; }
public TemperatureChangedEventArgs(double temp)
{
Temperature = temp;
}
}
// Usage
var sensor = new TemperatureSensor();
sensor.TemperatureChanged += (sender, e) =>
Console.WriteLine($"Temp: {e.Temperature}°C");
sensor.UpdateTemperature(25.5);
The compiler generates a private backing field and thread-safe add/remove accessors behind the scenes. Subscribers use += and -= without seeing this complexity. But when you need control over subscription logic, you must implement these accessors manually.
Implementing Custom Event Accessors
Custom accessors give you full control over event subscription. You declare a private backing field, then define add and remove blocks that handle subscription changes. Inside these blocks, the value keyword represents the event handler being added or removed.
The pattern looks similar to property accessors but serves a different purpose. Instead of getting and setting a value, you're managing a list of delegates. You must handle thread-safety yourself since the compiler no longer generates protected accessors.
namespace EventDemo;
public class StockTicker
{
// Backing field for custom event
private EventHandler<PriceChangedEventArgs>? priceChanged;
// Custom event with explicit add/remove
public event EventHandler<PriceChangedEventArgs> PriceChanged
{
add
{
// Thread-safe subscription using Interlocked
EventHandler<PriceChangedEventArgs>? oldHandler;
EventHandler<PriceChangedEventArgs>? newHandler;
do
{
oldHandler = priceChanged;
newHandler = (EventHandler<PriceChangedEventArgs>?)
Delegate.Combine(oldHandler, value);
}
while (Interlocked.CompareExchange(
ref priceChanged, newHandler, oldHandler) != oldHandler);
Console.WriteLine($"Subscriber added. Total: {GetInvocationCount()}");
}
remove
{
// Thread-safe unsubscription
EventHandler<PriceChangedEventArgs>? oldHandler;
EventHandler<PriceChangedEventArgs>? newHandler;
do
{
oldHandler = priceChanged;
newHandler = (EventHandler<PriceChangedEventArgs>?)
Delegate.Remove(oldHandler, value);
}
while (Interlocked.CompareExchange(
ref priceChanged, newHandler, oldHandler) != oldHandler);
Console.WriteLine($"Subscriber removed. Total: {GetInvocationCount()}");
}
}
protected virtual void OnPriceChanged(PriceChangedEventArgs e)
{
priceChanged?.Invoke(this, e);
}
private int GetInvocationCount()
{
return priceChanged?.GetInvocationList().Length ?? 0;
}
public void UpdatePrice(string symbol, decimal price)
{
OnPriceChanged(new PriceChangedEventArgs(symbol, price));
}
}
public class PriceChangedEventArgs : EventArgs
{
public string Symbol { get; }
public decimal Price { get; }
public PriceChangedEventArgs(string symbol, decimal price)
{
Symbol = symbol;
Price = price;
}
}
The Interlocked.CompareExchange pattern ensures thread-safety without locks. It repeatedly tries to update the backing field until it succeeds, handling concurrent subscriptions correctly. The logging inside accessors shows when subscriptions change—useful for debugging memory leaks or unexpected subscriber counts.
Notice that value represents the handler being added or removed. You combine or remove it from the backing field using Delegate.Combine and Delegate.Remove. This mimics what the compiler generates for auto-implemented events, but now you control the process.
Using WeakReference for Memory-Safe Events
Standard events create strong references to subscribers. If you subscribe a long-lived object to a short-lived event source and forget to unsubscribe, you leak memory. Custom accessors let you use WeakReference to avoid this problem.
A weak event stores handlers that don't prevent garbage collection. When the subscriber is no longer referenced elsewhere, it can be collected even if still subscribed. This pattern appears in WPF's WeakEventManager and works well for pub/sub scenarios where lifetime management is complex.
namespace EventDemo;
public class EventBus
{
private readonly List<WeakReference<EventHandler<MessageEventArgs>>>
handlers = new();
private readonly object lockObj = new();
public event EventHandler<MessageEventArgs> MessageReceived
{
add
{
lock (lockObj)
{
handlers.Add(new WeakReference<EventHandler<MessageEventArgs>>(
value));
}
}
remove
{
lock (lockObj)
{
handlers.RemoveAll(wr =>
{
if (wr.TryGetTarget(out var handler))
{
return handler == value;
}
return true; // Clean up dead references
});
}
}
}
public void PublishMessage(string content)
{
EventHandler<MessageEventArgs>[] snapshot;
lock (lockObj)
{
// Get alive handlers and clean dead ones
snapshot = handlers
.Where(wr => wr.TryGetTarget(out _))
.Select(wr =>
{
wr.TryGetTarget(out var handler);
return handler!;
})
.ToArray();
// Remove dead references
handlers.RemoveAll(wr => !wr.TryGetTarget(out _));
}
var args = new MessageEventArgs(content);
foreach (var handler in snapshot)
{
handler(this, args);
}
}
}
public class MessageEventArgs : EventArgs
{
public string Content { get; }
public MessageEventArgs(string content) => Content = content;
}
The WeakReference<T> collection stores handlers without preventing collection. When raising the event, you filter out dead references and clean them from the list. This adds complexity but prevents memory leaks in long-running applications with dynamic subscriptions.
The lock ensures thread-safety during subscription changes and cleanup. While simpler than lock-free approaches, it works well for most scenarios. Use this pattern when subscribers have unpredictable lifetimes or when unsubscription discipline is unreliable.
Try It Yourself — Event Logger
Build a simple notification system that logs every subscription and unsubscription. This demonstrates custom accessors with minimal complexity while showing the control they provide.
Steps
- Init a new console project:
dotnet new console -n EventLogger
- Navigate into the folder:
cd EventLogger
- Replace Program.cs with the code below
- Update EventLogger.csproj as shown
- Execute:
dotnet run
using System;
var notifier = new Notifier();
EventHandler<string> handler1 = (s, msg) => Console.WriteLine($"[H1] {msg}");
EventHandler<string> handler2 = (s, msg) => Console.WriteLine($"[H2] {msg}");
notifier.Notify += handler1;
notifier.Notify += handler2;
notifier.Send("First message");
notifier.Notify -= handler1;
notifier.Send("Second message");
class Notifier
{
private EventHandler<string>? notify;
public event EventHandler<string> Notify
{
add
{
Console.WriteLine($"[LOG] Adding subscriber");
notify += value;
}
remove
{
Console.WriteLine($"[LOG] Removing subscriber");
notify -= value;
}
}
public void Send(string message) => notify?.Invoke(this, message);
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
What you'll see
[LOG] Adding subscriber
[LOG] Adding subscriber
[H1] First message
[H2] First message
[LOG] Removing subscriber
[H2] Second message
Each subscription change logs before executing. You can extend this pattern to track subscription counts, validate handlers, or integrate with dependency injection systems that manage event lifetime.
Event Design Guidelines
Microsoft's event design guidelines recommend specific patterns for event naming, arguments, and raising. Following these conventions makes your events consistent with framework patterns and easier for other developers to use.
Use EventHandler<T> for custom arguments: Define a custom EventArgs-derived class for your data and use EventHandler<TEventArgs> as your event type. This matches framework conventions and enables tools to recognize event patterns.
Always check for null before invoking: Use the null-conditional operator when raising events: myEvent?.Invoke(this, args). This prevents exceptions when no subscribers exist and is more concise than null checks.
Make event raising protected and virtual: Provide a protected virtual OnEventName method that raises the event. This lets derived classes override event behavior or prevent raising in specific scenarios. The pattern appears throughout .NET framework types.
Document thread-safety guarantees: If your custom accessors provide thread-safe subscription, state this clearly in XML comments. Callers need to know whether they can subscribe from multiple threads concurrently without external synchronization.
Clean up subscriptions in Dispose: If your type is disposable, set the backing field to null in your Dispose method. This releases all subscriber references and helps garbage collection. Document that clients should unsubscribe before disposal for best practices.
See Microsoft's event design guidelines at https://learn.microsoft.com/en-us/dotnet/csharp/events-overview for complete recommendations.