How Custom Event Accessors Work in C

Custom event accessors in C# allow developers to override the default behavior of event subscription and unsubscription by explicitly defining add and remove blocks. Similar to how property accessors (get and set) control access to private fields, event accessors give you fine-grained control over how delegate handlers are stored, synchronized, and executed. This article explains how custom event accessors function, their syntax, common use cases like memory optimization and thread safety, and key implementation considerations.

The Basics of Event Accessors

When you declare a standard field-like event in C#:

public event EventHandler ProcessCompleted;

The C# compiler automatically generates a private delegate field behind the scenes along with thread-safe add and remove methods using Interlocked.CompareExchange.

When you define custom event accessors, you take manual control over these operations by writing explicit add and remove blocks.

public class ProcessManager
{
    private EventHandler? _processCompleted;

    public event EventHandler ProcessCompleted
    {
        add
        {
            _processCompleted += value;
        }
        remove
        {
            _processCompleted -= value;
        }
    }
}

Within these blocks, the implicit contextual keyword value represents the delegate handler being added or removed.

When to Use Custom Event Accessors

1. Memory Optimization for Sparse Events

In graphical frameworks or systems with classes defining dozens or hundreds of events, creating a separate delegate field for every event can consume significant memory, especially if most instances subscribe to only a few events.

Custom accessors can map event registrations to a shared dictionary or System.ComponentModel.EventHandlerList:

using System.ComponentModel;

public class ComponentWithManyEvents
{
    private readonly EventHandlerList _events = new EventHandlerList();
    private static readonly object ClickEventKey = new object();

    public event EventHandler Click
    {
        add => _events.AddHandler(ClickEventKey, value);
        remove => _events.RemoveHandler(ClickEventKey, value);
    }

    protected virtual void OnClick(EventArgs e)
    {
        ((EventHandler?)_events[ClickEventKey])?.Invoke(this, e);
    }
}

2. Thread Safety and Synchronization

If your application requires custom synchronization mechanisms, such as logging subscriptions or wrapping access with custom locking mechanisms (e.g., lock or ReaderWriterLockSlim), custom accessors allow you to control thread synchronization:

public class SafeNotifier
{
    private EventHandler? _notifier;
    private readonly object _lock = new object();

    public event EventHandler Notifier
    {
        add
        {
            lock (_lock)
            {
                _notifier += value;
            }
        }
        remove
        {
            lock (_lock)
            {
                _notifier -= value;
            }
        }
    }
}

3. Forwarding and Interception

Custom accessors allow an event to act as a facade that forwards subscriptions to an underlying object or adds logging and validation logic during the subscription lifecycle:

public class ServiceWrapper
{
    private readonly UnderlyingService _service = new UnderlyingService();

    public event EventHandler StatusChanged
    {
        add
        {
            Console.WriteLine("Subscriber added.");
            _service.StatusChanged += value;
        }
        remove
        {
            Console.WriteLine("Subscriber removed.");
            _service.StatusChanged -= value;
        }
    }
}

Rules and Constraints

  • Both Accessors Required: You cannot provide only an add or only a remove accessor. Both must be implemented.
  • No Direct Invocation: You cannot invoke the event using its public identifier if you define custom accessors, because the compiler does not generate a backing delegate field. You must maintain and invoke your own underlying delegate or collection.
  • Thread Safety Responsibility: Removing the default implementation shifts full responsibility for thread safety to you. Without proper synchronization, concurrent subscriptions or unsubscriptions can lead to race conditions.