← Open in the full interactive course (progress tracking, search & more)

A multicast delegate with the doors locked — subscribers can only add and remove themselves, never wipe out everyone else or ring the bell from outside.

Back in lesson 097, you learned that += on a multicast delegate adds a subscriber, and that anyone holding the delegate can invoke it. Now imagine that delegate is a public field on a class you don't own:

public class Button
{
    public Action OnClick; // just a plain public delegate field
}

Anyone with a reference to a Button can do myButton.OnClick = null;, silently deleting every other handler that was subscribed. They can also do myButton.OnClick();, faking a click from completely outside the button — nothing stops them. A plain delegate field gives away far more control than "notify me when something happens" should ever require. The event keyword exists to close that gap.

What Is It?

The Simple Explanation

An event is a controlled way for a class to announce "something happened" to whoever is interested — without letting those interested parties interfere with each other, or trigger the announcement themselves. It's built directly on top of the multicast delegates you already know, with one crucial restriction added.

The Technical Definition

The event keyword, placed before a delegate-typed field declaration, restricts what code outside the declaring class is allowed to do with it: external code may only use += (subscribe) and -= (unsubscribe). It may not assign with a bare =, and it may not invoke the delegate directly. Only code inside the declaring class retains full access — including the ability to raise (invoke) it.

public class Button
{
    public event Action? OnClick; // now protected — only Button itself can invoke or reset this
}

Plain Public Delegate Field

event-Qualified Field

Why Does It Exist?

The Problem — A Raw Delegate Field Gives Away Too Much Control

A plain public Action OnSomethingHappened; field looks like a subscription point, but it's actually a fully open door. Every one of these is legal, and every one of them is a bug waiting to happen in a large codebase with many subscribers:

The Solution — Restrict the Field's Public Surface

The event keyword narrows what's exposed outside the class down to exactly two operations: subscribing and unsubscribing. Assignment with = and direct invocation both become compile errors from outside the declaring class — the compiler enforces this, it's not just a naming convention or a guideline developers have to remember to follow.

The key insight

event doesn't give you a new communication mechanism — the underlying delegate and its invocation list work exactly as covered in lesson 097. What event adds is encapsulation: the declaring class stays the only one allowed to decide when the announcement actually happens.

Big Picture

Operation Code Inside the Declaring Class Code Outside the Declaring Class
+= (subscribe) Allowed Allowed
-= (unsubscribe) Allowed Allowed
= (replace/reset) Allowed Compile error
Invoke directly Allowed Compile error

How It Works

DECLARING, SUBSCRIBING TO, AND RAISING AN EVENT
1. DECLARE THE EVENT
public event EventHandler<int>? ThresholdReached;
2. SUBSCRIBERS ATTACH HANDLERS FROM OUTSIDE
counter.ThresholdReached += (sender, value) => Console.WriteLine($"Reached {value}!");
3. ONLY THE DECLARING CLASS RAISES IT
ThresholdReached?.Invoke(this, currentValue); // "this" — inside the class itself
4. EVERY SUBSCRIBER RUNS, IN SUBSCRIPTION ORDER

Simple Example

using System;

public class Counter
{
    public event Action<int>? ThresholdReached; // event, not a plain delegate field
    private int _count;

    public void Increment()
    {
        _count++;
        if (_count == 5)
            ThresholdReached?.Invoke(_count); // only Counter itself can do this
    }
}

class Program
{
    static void Main()
    {
        var counter = new Counter();

        counter.ThresholdReached += value => Console.WriteLine($"Handler A: reached {value}");
        counter.ThresholdReached += value => Console.WriteLine($"Handler B: reached {value}");

        for (int i = 0; i < 5; i++)
            counter.Increment();

        // counter.ThresholdReached = null;         //  compile error — not allowed from outside
        // counter.ThresholdReached(5);              //  compile error — not allowed from outside

        // Handler A: reached 5
        // Handler B: reached 5
    }
}

Code → Meaning → Result: Both handlers subscribe with += and both run when Counter itself decides to raise the event, in the order they subscribed. The two commented-out lines are exactly what a plain public Action<int> field would have allowed — event turns both into compile-time errors.

Real-World Example

The standard .NET signature for events is EventHandler (no data beyond "it happened") and its generic counterpart EventHandler<TEventArgs> (carries event-specific data). Both follow a fixed shape: (object? sender, TEventArgs e) — the sender lets a handler subscribed to multiple objects tell which one raised the event.

using System;

public class StockTicker
{
    public event EventHandler<decimal>? PriceChanged; // sender + a decimal payload
    private decimal _price;

    public void UpdatePrice(decimal newPrice)
    {
        _price = newPrice;
        PriceChanged?.Invoke(this, newPrice); // "this" tells subscribers which ticker fired
    }
}

class Program
{
    static void Main()
    {
        var ticker = new StockTicker();

        ticker.PriceChanged += (sender, price) =>
        {
            var source = (StockTicker)sender!;
            Console.WriteLine($"Price updated to {price:C}");
        };

        ticker.UpdatePrice(142.50m); // Price updated to $142.50
        ticker.UpdatePrice(145.10m); // Price updated to $145.10
    }
}

EventHandler<decimal> here is really just a delegate type shaped like Action<object?, decimal> — nothing exotic, just a standard, recognizable signature every .NET developer knows on sight. The next lesson formalizes this further with a dedicated EventArgs subclass instead of a bare decimal, which is the pattern you'll actually see throughout the .NET ecosystem.

Analogy

A Doorbell You Can Hear, Not Ring, From Outside

A plain delegate field is like a doorbell wired so that anyone passing by can not only press the button, but also rewire it or rip the whole thing off the wall. An event is the same doorbell with a locked cover: visitors can press it (subscribe/unsubscribe their own reaction to hearing it), but only the homeowner — the class that installed it — can actually make it ring, and nobody outside can disconnect the wiring for everyone else.

Under the Hood

A field-like event (the plain public event Action? Something; style shown so far) compiles into three things: a private backing delegate field (holding the same kind of invocation list from lesson 097), plus a compiler-generated public add accessor and remove accessor. The += and -= syntax you write is translated into calls to those accessors, which internally call Delegate.Combine and Delegate.Remove — the exact operations from lesson 097 — usually guarded by a lock to make concurrent subscribe/unsubscribe calls from multiple threads safe. Everything about the invocation list itself — order, multicast behavior, target-plus-method-pointer entries — is unchanged from ordinary multicast delegates; event only changes what's publicly accessible, not how invocation works.

Common Confusion

1. "event is a new delegate type" — it isn't

event is a modifier applied to an existing delegate-typed field declaration (Action, Func, EventHandler, or a custom delegate) — it doesn't introduce a new kind of delegate. public event Action? OnClick; still uses the ordinary Action delegate type from lesson 098; event only restricts what external code can do with that field.

2. "Any Action or Func field is basically an event" — no, capability differs sharply

A field declared as public Action<T> SomeField; and one declared as public event Action<T> SomeField; can look nearly identical at the declaration site, but external code's capabilities are completely different — full read/write/invoke access versus subscribe/unsubscribe only. Always reach for event when the field's purpose is genuinely "notify me," not "let external code fully control this delegate."

Common Mistakes

Mistake 1 — Forgetting the null-conditional operator when raising an event

ThresholdReached(_count); — if nobody has subscribed, the event field is null, and this throws a NullReferenceException.

Always raise with ThresholdReached?.Invoke(_count);. It's the idiomatic, safe way to raise any event that might currently have zero subscribers.

Mistake 2 — Assuming you can invoke another class's event directly

Writing someOtherObject.SomeEvent(); from outside someOtherObject's class, expecting it to behave like calling any other delegate — this simply doesn't compile.

If external code genuinely needs to trigger behavior, the declaring class should expose a proper public method (like Increment() in the example above) that does its own work and then raises the event internally — external code never invokes the event field itself.

When Should I Use It?

Mental Model

event = a multicast delegate field + "outsiders may only += / -=."
Only the declaring class may assign with = or invoke it directly.
Same invocation-list mechanics as lessons 096–097 — the restriction is entirely about access, not behavior.

Key Takeaway


Check Your Understanding

You've seen why raw delegate fields are dangerous, and how event fixes it. Let's confirm the details.

1. What can external code do to a field declared as public event Action? Something;, from outside the declaring class?

Show answer

Correct: A

Why A is correct: This is exactly what the event keyword restricts external code to — subscribing and unsubscribing. Everything else (assignment, invocation) remains available only inside the declaring class.

Why B is incorrect: Direct assignment with = from outside the declaring class is a compile-time error — that's precisely the capability event removes.

Why C is incorrect: This describes a plain public delegate field, not an event-qualified one — the whole point of event is to prevent this.

Why D is incorrect: Subscribing from outside is exactly what events are designed to allow — that's the entire purpose of exposing one publicly.

Reinforcement: +=/-= only, from the outside — that's the whole restriction event adds.

2. Why should you raise an event using SomeEvent?.Invoke(...) instead of SomeEvent(...)?

Show answer

Correct: B

Why B is correct: An event field with no subscribers is null by default. Invoking a null delegate directly throws; the null-conditional operator checks for null first and skips the call safely if there are no subscribers.

Why A is incorrect: There's no meaningful performance difference — the reason to use it is correctness, specifically avoiding a crash, not speed.

Why C is incorrect: SomeEvent(...) is valid syntax from inside the declaring class — it's just unsafe when there might be zero subscribers.

Why D is incorrect: ?.Invoke only guards against a null delegate — it does nothing about exceptions thrown from inside a subscriber's handler.

Reinforcement: Always assume an event might have zero subscribers when you raise it, and guard accordingly.

3. What is the standard delegate shape used by EventHandler<TEventArgs>?

Show answer

Correct: B

Why B is correct: The standard .NET event pattern always includes the sender (so a handler subscribed to multiple sources can tell which one raised it) alongside the event-specific data.

Why A is incorrect: This omits the sender parameter, which is part of the standard convention — missing it makes the handler unable to identify the source when subscribed to multiple publishers.

Why C is incorrect: Event handlers are void-returning — like Action, not Func — since raising an event isn't asking subscribers to compute and return a value.

Why D is incorrect: This describes a plain Action, not EventHandler<TEventArgs>, which always carries at least the sender and the event args.

Reinforcement: Recognize the (sender, args) shape on sight — it's everywhere across .NET event-based APIs.

You now understand why events exist and how they lock down a multicast delegate safely. Next: the full standard event pattern — custom EventArgs, the protected virtual OnXxx raiser method, and two real gotchas: memory leaks and thread safety.


dotnetmadeeasy.com — Learn C# and .NET, the right way.