How Events Encapsulate Delegates in C

In C#, events provide a specialized mechanism to implement the publisher-subscriber pattern securely by wrapping underlying delegates. While standard delegates offer flexibility by treating methods as first-class objects, exposing them publicly creates critical vulnerabilities such as unauthorized external invocation and accidental handler overwriting. This article explains how the event keyword encapsulates delegate instances by generating restricted accessors, enforcing compile-time boundaries, and ensuring that only the defining class can trigger the event.

The Problem with Public Delegates

When a delegate is declared as a public field, any external class with access to the object can interact with it without restriction. Consider a public delegate field:

public class Downloader
{
    public Action<int> ProgressChanged;
}

Exposing a raw delegate like this presents two major problems:

  1. Direct External Invocation: Any external code can call downloader.ProgressChanged(100) directly, broadcasting a false state change even if no download occurred.

  2. Destructive Overwriting: An external subscriber can accidentally use the assignment operator (=) instead of the addition assignment operator (+=):

    downloader.ProgressChanged = MyHandler; // Overwrites all other subscribed listeners

    Furthermore, any external caller can completely clear the invocation list by setting downloader.ProgressChanged = null;.

How the event Keyword Solves the Issue

Declaring a delegate with the event keyword introduces an abstraction layer similar to how properties encapsulate private fields.

public class Downloader
{
    public event Action<int> ProgressChanged;
}

When the C# compiler encounters an event declaration, it does not expose the delegate field directly. Instead, it performs two critical operations behind the scenes:

  1. Generates a Private Delegate Field: The compiler creates a hidden, private delegate backing field (typically named something like ProgressChanged internally). Because the field is private, it cannot be read, invoked, or directly assigned outside of the declaring class.
  2. Generates Public Accessor Methods: The compiler creates two public accessor methods: add_ProgressChanged and remove_ProgressChanged. These methods manage the invocation list using thread-safe operations (Delegate.Combine and Delegate.Remove).

Compile-Time Enforcement

The presence of the event keyword changes how the compiler parses access to the member from external code:

  • Restricted Operators: Outside the declaring class, only the += and -= operators are permitted. The compiler maps += to the generated add accessor and -= to the remove accessor.
  • Prohibited Invocation: Attempting to invoke the event from outside the declaring class using downloader.ProgressChanged(50) results in a compile-time error: CS0070: The event 'Downloader.ProgressChanged' can only appear on the left hand side of += or -= (except when used from within the type 'Downloader').
  • Prohibited Direct Assignment: Attempting to assign downloader.ProgressChanged = MyHandler or set it to null also triggers a compile-time error.

Raising the Event Safely

Because external classes can only subscribe or unsubscribe, the publishing class retains exclusive ownership over when and how the delegate is invoked. The publishing class typically triggers the event using a null-conditional check:

public class Downloader
{
    public event Action<int> ProgressChanged;

    public void StartDownload()
    {
        // Safe invocation: only the declaring class can invoke the delegate
        ProgressChanged?.Invoke(50);
    }
}

By restricting direct access to the delegate's invocation list and restricting external consumers to add and remove operations, C# events effectively eliminate state interference and protect the integrity of the publisher-subscriber relationship.