Skip to main content

📝 Lesson 2.2: Events

Events let an object announce "something happened!" and let any number of other objects react — without the announcer knowing who's listening. It's the pattern behind button clicks, notifications, and much of .NET.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the publisher/subscriber pattern and how events build on delegates
  • Declare and raise an event, and subscribe with += / unsubscribe with -=
  • Use the standard EventHandler pattern
  • Pass data to subscribers with a custom EventArgs
  • Explain why events are safer than exposing a plain delegate

Estimated Time: 60 minutes

Project: Build a publisher that notifies multiple subscribers when something changes.

In This Lesson

The Publisher/Subscriber Idea

An event is a message an object sends when something notable happens. The object that raises the event is the publisher; objects that want to react are subscribers. The publisher doesn't know or care who is listening — it just broadcasts.

📖 Definition

Event: a member of a class that lets other code subscribe to be notified when something happens. Built on delegates, an event can call many subscriber methods when it is raised.

Think of a newsletter. The publisher sends out an issue; every subscriber receives it. New readers can subscribe and existing ones can unsubscribe at any time, and the publisher never needs a hard-coded list of names.

graph TD P["Publisher
raises an event"] --> S1["Subscriber A reacts"] P --> S2["Subscriber B reacts"] P --> S3["Subscriber C reacts"] style P fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style S1 fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style S2 fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style S3 fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

Events build directly on the delegates from Lesson 2.1 — an event is essentially a carefully-guarded delegate that holds a list of subscriber methods to call.

A First Event

Let's build an alarm clock that announces when it rings. The publisher declares an event and raises it at the right moment:

class AlarmClock
{
    // Declare an event based on the Action delegate (no data payload)
    public event Action Rang;

    public void Tick()
    {
        Console.WriteLine("Tick...");
        // Raise the event — notify all subscribers (if any)
        Rang?.Invoke();
    }
}

⚠️ Always use ?.Invoke() to raise

If no one has subscribed, the event is null, and calling it directly would throw a NullReferenceException. The null-conditional ?.Invoke() safely does nothing when there are no subscribers — the standard, correct way to raise an event.

Now some subscribers react when the alarm rings:

AlarmClock clock = new AlarmClock();

// Subscribe: add methods to be called when Rang is raised
clock.Rang += () => Console.WriteLine("😴 Wake up!");
clock.Rang += () => Console.WriteLine("☕ Starting the coffee machine.");

clock.Tick();   // raises Rang → both subscribers run

Output:

Tick...
😴 Wake up!
☕ Starting the coffee machine.

Subscribing and Unsubscribing

You subscribe with += and unsubscribe with -=. Multiple subscribers can attach, and they're all invoked in order when the event is raised (this is a multicast delegate under the hood).

void MorningRoutine() => Console.WriteLine("Getting up.");

clock.Rang += MorningRoutine;   // subscribe a named method
clock.Tick();                   // MorningRoutine runs

clock.Rang -= MorningRoutine;   // unsubscribe
clock.Tick();                   // MorningRoutine no longer runs

💡 To unsubscribe, keep a reference

You can only -= a subscriber you can name — a named method or a lambda you stored in a variable. An inline lambda subscribed anonymously can't be removed later, because you have nothing to reference. If you'll need to unsubscribe, use a named method or save the lambda.

The Standard EventHandler Pattern

Using Action works, but .NET has a widely-followed convention for events that all its own APIs use. Following it makes your code familiar to every C# developer. The convention uses the built-in EventHandler delegate, whose subscribers take two parameters:

  • object sender — the object that raised the event.
  • EventArgs e — data about the event (or EventArgs.Empty if none).
class AlarmClock
{
    public event EventHandler Rang;   // the standard event delegate

    public void Tick()
    {
        Console.WriteLine("Tick...");
        Rang?.Invoke(this, EventArgs.Empty);   // 'this' is the sender
    }
}
AlarmClock clock = new AlarmClock();

clock.Rang += (sender, e) => Console.WriteLine("Wake up!");
clock.Tick();

✅ Why follow the convention?

Every event in the .NET UI frameworks, timers, and libraries uses this (sender, e) shape. Matching it means your events feel native, your subscribers know what to expect, and tooling works smoothly.

Passing Data with EventArgs

Often subscribers need details about what happened. You provide them by creating a class that derives from EventArgs (inheritance from the intro course, again) and using the generic EventHandler<T>:

// 1) Define a data payload
class TemperatureChangedEventArgs : EventArgs
{
    public double NewTemperature { get; }

    public TemperatureChangedEventArgs(double newTemperature)
    {
        NewTemperature = newTemperature;
    }
}

// 2) Publisher raises the event with data
class Thermostat
{
    public event EventHandler<TemperatureChangedEventArgs> TemperatureChanged;

    private double _temperature;
    public double Temperature
    {
        get => _temperature;
        set
        {
            _temperature = value;
            TemperatureChanged?.Invoke(this, new TemperatureChangedEventArgs(value));
        }
    }
}
Thermostat thermostat = new Thermostat();

thermostat.TemperatureChanged += (sender, e) =>
{
    Console.WriteLine($"Temperature is now {e.NewTemperature}°C");
    if (e.NewTemperature > 30)
    {
        Console.WriteLine("⚠️ It's getting hot!");
    }
};

thermostat.Temperature = 22;   // raises the event
thermostat.Temperature = 35;   // raises again → warning fires

Output:

Temperature is now 22°C
Temperature is now 35°C
⚠️ It's getting hot!

The subscriber reads e.NewTemperature to get the detail it needs. Setting the property is all it takes to notify everyone listening.

Why Events, Not Plain Delegates?

An event is a delegate with guardrails. If you exposed a public delegate field instead, any outside code could misuse it:

Outside code could…Public delegate fieldevent
Subscribe (+=) / unsubscribe (-=)YesYes ✅
Raise it (invoke it)Yes ⚠️No — only the declaring class ✅
Overwrite all subscribers (=)Yes ⚠️No ✅
💡 The point of event: It lets outsiders subscribe and unsubscribe, but only the publishing class can raise the event or clear its subscribers. That encapsulation (a theme from the intro's OOP) prevents a whole category of bugs — subscribers can't accidentally trigger or wipe each other.

✅ Rule of thumb

When you want to let other code react to something in your class, expose an event — not a public delegate. Raise it from inside the class with ?.Invoke(...).

Exercise & Quiz

🏋️ Exercise: A Bank Account with Notifications

Objective: Build a publisher that raises an event (with data) that multiple subscribers react to.

Instructions:

  1. Create a new project called AccountEvents.
  2. Create DepositEventArgs : EventArgs with an Amount property and a NewBalance property.
  3. Create a BankAccount class with a Balance and an event EventHandler<DepositEventArgs> Deposited. In a Deposit(decimal amount) method, increase the balance and raise the event.
  4. Subscribe two handlers: one prints a receipt, another prints a "bonus points earned" message. Then make a deposit.

Starter Code:

BankAccount account = new BankAccount();

// TODO: subscribe two handlers to account.Deposited

account.Deposit(100m);

class DepositEventArgs : EventArgs
{
    public decimal Amount { get; }
    public decimal NewBalance { get; }
    public DepositEventArgs(decimal amount, decimal newBalance)
    {
        Amount = amount;
        NewBalance = newBalance;
    }
}

class BankAccount
{
    public decimal Balance { get; private set; }
    public event EventHandler<DepositEventArgs> Deposited;

    public void Deposit(decimal amount)
    {
        // TODO: add to Balance, then raise Deposited with a DepositEventArgs
    }
}
💡 Hint

In Deposit: Balance += amount; then Deposited?.Invoke(this, new DepositEventArgs(amount, Balance));. Subscribe with account.Deposited += (sender, e) => ... and read e.Amount / e.NewBalance.

✅ Solution
BankAccount account = new BankAccount();

account.Deposited += (sender, e) =>
    Console.WriteLine($"Receipt: deposited {e.Amount:C}. Balance: {e.NewBalance:C}");

account.Deposited += (sender, e) =>
    Console.WriteLine($"You earned {(int)(e.Amount)} bonus points!");

account.Deposit(100m);

class DepositEventArgs : EventArgs
{
    public decimal Amount { get; }
    public decimal NewBalance { get; }
    public DepositEventArgs(decimal amount, decimal newBalance)
    {
        Amount = amount;
        NewBalance = newBalance;
    }
}

class BankAccount
{
    public decimal Balance { get; private set; }
    public event EventHandler<DepositEventArgs> Deposited;

    public void Deposit(decimal amount)
    {
        Balance += amount;
        Deposited?.Invoke(this, new DepositEventArgs(amount, Balance));
    }
}

Output:

Receipt: deposited $100.00. Balance: $100.00
You earned 100 bonus points!

🎯 Quick Quiz

Question 1: In the publisher/subscriber pattern, who raises the event?

Question 2: Why raise an event with Rang?.Invoke(...) instead of Rang.Invoke(...)?

Question 3: What can outside code do with an event that it can't do with a plain public delegate?

Summary

🎉 Key Takeaways

  • An event implements publisher/subscriber: a publisher raises it, and any number of subscribers react — built on delegates.
  • Subscribe with +=, unsubscribe with -= (keep a reference to what you subscribed so you can remove it).
  • Always raise with EventName?.Invoke(...) to safely handle the no-subscribers (null) case.
  • Follow the standard pattern: EventHandler / EventHandler<T> with (object sender, EventArgs e); pass data via a custom EventArgs.
  • An event encapsulates its delegate — outsiders may subscribe/unsubscribe, but only the class can raise or clear it.

📚 Additional Resources

🚀 What's Next?

You've now used delegates directly and through events. In Lesson 2.3: LINQ, you'll see delegates and lambdas at their most powerful — querying and transforming collections with beautifully concise code.

🎉 Events mastered!

Your objects can now broadcast and react to change. Next, the crown jewel of functional-style C#: LINQ.