Day 17: Events - When Your Code Yells FIRE and Someone Actually Listens
arrow_back All Posts
April 06, 2026 11 min read .NET/C#

Day 17: Events - When Your Code Yells FIRE and Someone Actually Listens

Yesterday you learned about delegates and lambdas — you turned methods into objects, passed them around like notes in class, and wrote anonymous functions on the fly. Today we're building on top of that. What happens when an object wants to announce something — shout it from the rooftop — without knowing or caring who's listening? That's an event. And it's how most real-world C# applications actually communicate.

1. The Fire Alarm Analogy

Picture the fire alarm in your office building. The smoke detector doesn't know who works in the building. It doesn't have a contact list. It doesn't send personalized emails. It just detects smoke and screams.

The sprinkler system hears the alarm and starts spraying water. The emergency lights flip on. The elevator locks itself on the ground floor. The fire department gets a signal. And Jerry from accounting runs straight to the parking lot without saving his spreadsheet.

None of these subscribers know about each other. The sprinklers don't know Jerry exists. Jerry doesn't know about the elevator. They all just react to the same alarm independently.

That's the publish/subscribe pattern. The smoke detector is the publisher — it raises the event. Everything else is a subscriber — it responds to the event. The publisher and subscribers are completely decoupled. The smoke detector works the same whether one thing is listening or a thousand.

In C#, events are this exact mechanism, built on top of the delegates you learned yesterday.

2. Events vs Delegates — Why Bother With a New Keyword?

"Wait," you might be thinking. "Can't I just use a delegate field for this? Why do I need a whole new keyword?"

You could use a raw delegate. But it's like leaving your car unlocked with the keys in the ignition — technically functional, sure, but you're asking for trouble.

class AlarmWithDelegate
{
    // A raw delegate field — anyone can mess with this
    public Action? OnAlarmTriggered;
}

var alarm = new AlarmWithDelegate();
alarm.OnAlarmTriggered += () => Console.WriteLine("Sprinklers activated!");
alarm.OnAlarmTriggered += () => Console.WriteLine("Jerry is running!");

// 🚨 Problem 1: Outside code can INVOKE the alarm — false alarm!
alarm.OnAlarmTriggered?.Invoke();

// 🚨 Problem 2: Outside code can WIPE OUT all subscribers
alarm.OnAlarmTriggered = null; // Sprinklers? Jerry? Gone.

// 🚨 Problem 3: Outside code can REPLACE everyone with its own handler
alarm.OnAlarmTriggered = () => Console.WriteLine("Only I matter now.");

Three nasty problems. Any code with access to the object can invoke the delegate (triggering a false fire alarm), wipe the subscriber list, or replace everyone else's handler with its own.

Now watch what happens with the event keyword:

class AlarmWithEvent
{
    // The event keyword puts a bouncer at the door
    public event Action? OnAlarmTriggered;

    public void DetectSmoke()
    {
        Console.WriteLine("Smoke detected!");
        OnAlarmTriggered?.Invoke(); // Only the owning class can invoke
    }
}

var alarm = new AlarmWithEvent();
alarm.OnAlarmTriggered += () => Console.WriteLine("Sprinklers activated!");
alarm.OnAlarmTriggered += () => Console.WriteLine("Jerry is running!");

// alarm.OnAlarmTriggered?.Invoke(); // ❌ Compiler error! Can't invoke from outside
// alarm.OnAlarmTriggered = null;    // ❌ Compiler error! Can only use += and -=

alarm.DetectSmoke(); // ✅ The only way to trigger the alarm

The event keyword acts as a bouncer. Outside code can only subscribe (+=) and unsubscribe (-=). Only the class that owns the event can invoke it or assign it. That's the safety net you want in any real application.

3. The EventHandler Pattern — The .NET Convention

You could use event Action for everything, but .NET has a standard pattern that the entire ecosystem follows. It's called the EventHandler pattern, and once you learn it, you'll recognize it everywhere:

// The standard signature: (object sender, EventArgs e)
class SmokeSensor
{
    public event EventHandler? SmokeDetected;

    public void CheckForSmoke(bool thereIsSmoke)
    {
        if (thereIsSmoke)
        {
            Console.WriteLine("🔥 Sensor triggered!");
            // 'this' tells subscribers WHO raised the event
            // EventArgs.Empty means "no extra data to report"
            SmokeDetected?.Invoke(this, EventArgs.Empty);
        }
    }
}

var sensor = new SmokeSensor();

sensor.SmokeDetected += (sender, args) =>
{
    Console.WriteLine($"Sprinklers: On! (alert from {sender!.GetType().Name})");
};

sensor.SmokeDetected += (sender, args) =>
{
    Console.WriteLine("Fire department: Dispatched!");
};

sensor.CheckForSmoke(true);
// Output:
// 🔥 Sensor triggered!
// Sprinklers: On! (alert from SmokeSensor)
// Fire department: Dispatched!

The convention is two parameters:

  • object sender — the object that raised the event. This lets subscribers know who is yelling. In a building with fifty sensors, you'd want to know which one went off.
  • EventArgs e — extra data about the event. EventArgs.Empty means "nothing special to report." But often you do want to pass details — which brings us to the next section.

Why follow this pattern? Because every .NET developer on Earth expects it. Break the convention and your code becomes the programming equivalent of a door that you push when the sign says pull.

4. Custom EventArgs — Passing Data With Your Alarm

"There's smoke!" is useful. "There's smoke on the 3rd floor, temperature is 200°C, and it smells like Jerry microwaved fish again!" is much more useful.

Custom EventArgs let you attach data to your events:

class SmokeEventArgs : EventArgs
{
    public string Location { get; }
    public double TemperatureCelsius { get; }
    public DateTime DetectedAt { get; }

    public SmokeEventArgs(string location, double temperatureCelsius)
    {
        Location = location;
        TemperatureCelsius = temperatureCelsius;
        DetectedAt = DateTime.Now;
    }
}

class SmartSmokeSensor
{
    public string Floor { get; }

    // EventHandler<T> — the generic version that carries your custom data
    public event EventHandler<SmokeEventArgs>? SmokeDetected;

    public SmartSmokeSensor(string floor) => Floor = floor;

    public void CheckTemperature(double celsius)
    {
        if (celsius > 60) // 60°C is definitely not a thermostat problem
        {
            SmokeDetected?.Invoke(this,
                new SmokeEventArgs(Floor, celsius));
        }
    }
}

var sensor = new SmartSmokeSensor("3rd Floor - Kitchen");

sensor.SmokeDetected += (sender, e) =>
{
    Console.WriteLine($"⚠️ ALERT at {e.Location}!");
    Console.WriteLine($"   Temperature: {e.TemperatureCelsius}°C");
    Console.WriteLine($"   Time: {e.DetectedAt:HH:mm:ss}");
};

sensor.SmokeDetected += (sender, e) =>
{
    if (e.TemperatureCelsius > 100)
        Console.WriteLine("🚨 CRITICAL: Evacuate now!");
    else
        Console.WriteLine("🔶 WARNING: Go investigate.");
};

sensor.CheckTemperature(85);
// Output:
// ⚠️ ALERT at 3rd Floor - Kitchen!
//    Temperature: 85°C
//    Time: 14:32:07
// 🔶 WARNING: Go investigate.

EventHandler<SmokeEventArgs> is the generic version of EventHandler. The T must inherit from EventArgs. This gives each subscriber the specific data it needs — a sprinkler system cares about the location, a dashboard cares about the temperature, and Jerry cares about how fast he needs to run.

5. Building a Temperature Monitor — The Full Picture

Let's wire up something more realistic. A TemperatureSensor that monitors readings and fires different events depending on what's happening:

class TemperatureChangedEventArgs : EventArgs
{
    public double OldTemperature { get; }
    public double NewTemperature { get; }
    public double Difference => NewTemperature - OldTemperature;

    public TemperatureChangedEventArgs(double oldTemp, double newTemp)
    {
        OldTemperature = oldTemp;
        NewTemperature = newTemp;
    }
}

class TemperatureSensor
{
    private double _currentTemp;

    public event EventHandler<TemperatureChangedEventArgs>? TemperatureChanged;
    public event EventHandler? OverheatWarning;

    public double WarningThreshold { get; set; } = 80.0;

    public double CurrentTemperature
    {
        get => _currentTemp;
        set
        {
            if (Math.Abs(value - _currentTemp) < 0.001) return; // Skip noise

            double old = _currentTemp;
            _currentTemp = value;

            TemperatureChanged?.Invoke(this,
                new TemperatureChangedEventArgs(old, value));

            if (value >= WarningThreshold)
                OverheatWarning?.Invoke(this, EventArgs.Empty);
        }
    }
}

// --- Wire up the subscribers ---
var sensor = new TemperatureSensor { WarningThreshold = 75.0 };

// Subscriber 1: a logging display
sensor.TemperatureChanged += (sender, e) =>
{
    string arrow = e.Difference > 0 ? "↑" : "↓";
    Console.WriteLine($"Display: {e.NewTemperature:F1}°C {arrow} (was {e.OldTemperature:F1}°C)");
};

// Subscriber 2: an overheat alarm
sensor.OverheatWarning += (sender, e) =>
{
    Console.WriteLine("🚨 OVERHEAT! Check equipment immediately!");
};

// Simulate temperature readings
sensor.CurrentTemperature = 22.5;
// Display: 22.5°C ↑ (was 0.0°C)

sensor.CurrentTemperature = 45.0;
// Display: 45.0°C ↑ (was 22.5°C)

sensor.CurrentTemperature = 78.3;
// Display: 78.3°C ↑ (was 45.0°C)
// 🚨 OVERHEAT! Check equipment immediately!

sensor.CurrentTemperature = 72.1;
// Display: 72.1°C ↓ (was 78.3°C)

Notice the sensor has zero knowledge of what's subscribed. You could add ten more subscribers — a fan controller, a shutdown switch, a Slack notification — and the sensor code doesn't change by a single character. That's decoupling done right.

6. Subscribing, Unsubscribing, and the Memory Leak You Didn't See Coming

Subscribing is +=. Unsubscribing is -=. Simple enough. But there's a trap hiding here that bites even experienced developers.

class EmailNotifier
{
    public void OnOverheat(object? sender, EventArgs e)
    {
        Console.WriteLine("📧 Sending overheat email to admin...");
    }
}

var sensor = new TemperatureSensor();
var notifier = new EmailNotifier();

// Subscribe
sensor.OverheatWarning += notifier.OnOverheat;

// Later, when notifications are no longer needed
sensor.OverheatWarning -= notifier.OnOverheat;

Here's the trap: when you subscribe to an event, the publisher holds a reference to the subscriber through the delegate. Even if you set notifier = null and figure the garbage collector will clean it up — it won't. The sensor still holds a reference to notifier via the event's delegate chain. Your notifier stays alive in memory like a zombie.

This is a memory leak. In long-running applications — web servers, desktop apps, games — it quietly eats memory until your app crawls to a halt or crashes at 3 AM.

The rule: if the publisher outlives the subscriber, always unsubscribe. A clean pattern is to pair subscribe/unsubscribe using IDisposable:

class DashboardWidget : IDisposable
{
    private readonly TemperatureSensor _sensor;

    public DashboardWidget(TemperatureSensor sensor)
    {
        _sensor = sensor;
        _sensor.TemperatureChanged += HandleChange;
    }

    private void HandleChange(object? sender, TemperatureChangedEventArgs e)
    {
        Console.WriteLine($"Dashboard updated: {e.NewTemperature:F1}°C");
    }

    public void Dispose()
    {
        // Remove the reference so this widget can be garbage collected
        _sensor.TemperatureChanged -= HandleChange;
    }
}

// Use 'using' to guarantee cleanup
using var widget = new DashboardWidget(sensor);
sensor.CurrentTemperature = 55.0; // Dashboard updated: 55.0°C
// widget.Dispose() called automatically — no memory leak

Remember IDisposable from Day 12? This is exactly the kind of cleanup it was designed for.

7. Events in the Wild — You've Already Met Them

Events aren't academic. They're the nervous system of .NET applications. You've been bumping into them whether you realized it or not.

UI button clicks — every button, textbox, and dropdown in WinForms, WPF, and MAUI uses events:

// The button is the publisher, your form is the subscriber
button.Click += (sender, e) =>
{
    Console.WriteLine("Button clicked!");
};

File system watcher — get notified when files change on disk:

using var watcher = new FileSystemWatcher(@"C:\MyFolder");
watcher.Filter = "*.txt";
watcher.EnableRaisingEvents = true;

watcher.Created += (s, e) => Console.WriteLine($"New file: {e.Name}");
watcher.Changed += (s, e) => Console.WriteLine($"Modified: {e.Name}");
watcher.Deleted += (s, e) => Console.WriteLine($"Deleted: {e.Name}");
watcher.Renamed += (s, e) => Console.WriteLine($"Renamed: {e.OldName} → {e.Name}");

Timers — run code on a schedule:

using var timer = new System.Timers.Timer(2000); // Fire every 2 seconds
timer.Elapsed += (sender, e) =>
{
    Console.WriteLine($"Tick! {e.SignalTime:HH:mm:ss}");
};
timer.Start();

Console.WriteLine("Timer running... press Enter to stop.");
Console.ReadLine();

Every one of these follows the same EventHandler pattern: some object raises an event, your code subscribes to react. Once you see it, you can't unsee it.

8. Your Homework: Build a Stock Price Monitor

Create a StockTicker class that simulates stock price changes and fires events when prices move. Subscribers should react to every price change and yell louder when swings are big:

class PriceChangedEventArgs : EventArgs
{
    public string Symbol { get; }
    public decimal OldPrice { get; }
    public decimal NewPrice { get; }
    public decimal ChangePercent => OldPrice == 0 ? 0
        : Math.Round((NewPrice - OldPrice) / OldPrice * 100, 2);

    public PriceChangedEventArgs(string symbol, decimal oldPrice, decimal newPrice)
    {
        Symbol = symbol;
        OldPrice = oldPrice;
        NewPrice = newPrice;
    }
}

class StockTicker
{
    private readonly Dictionary<string, decimal> _prices = [];

    public event EventHandler<PriceChangedEventArgs>? PriceChanged;
    public event EventHandler<PriceChangedEventArgs>? BigSwing;

    public void UpdatePrice(string symbol, decimal newPrice)
    {
        decimal oldPrice = _prices.GetValueOrDefault(symbol, 0m);
        _prices[symbol] = newPrice;

        var args = new PriceChangedEventArgs(symbol, oldPrice, newPrice);
        PriceChanged?.Invoke(this, args);

        // Moves bigger than 5% get their own event
        if (Math.Abs(args.ChangePercent) > 5)
            BigSwing?.Invoke(this, args);
    }
}

// --- Wire it up ---
var ticker = new StockTicker();

ticker.PriceChanged += (s, e) =>
    Console.WriteLine($"  {e.Symbol}: ${e.OldPrice} → ${e.NewPrice} ({e.ChangePercent:+0.00;-0.00}%)");

ticker.BigSwing += (s, e) =>
    Console.WriteLine($"  ⚠️ BIG MOVE on {e.Symbol}! {e.ChangePercent:+0.00;-0.00}%");

ticker.UpdatePrice("MSFT", 420.00m);
ticker.UpdatePrice("MSFT", 425.50m);   // Small move — just PriceChanged
ticker.UpdatePrice("MSFT", 398.00m);   // Big drop — both events fire
ticker.UpdatePrice("AAPL", 185.00m);
ticker.UpdatePrice("AAPL", 196.75m);   // +6.35% — BigSwing!

Bonus challenge: Create a TradeBot class that implements IDisposable. It should subscribe to BigSwing and print "AUTO-BUY" on drops or "AUTO-SELL" on spikes. Make sure Dispose() unsubscribes properly — no zombie bots.

Summary of Day 17

  • Events implement the publish/subscribe pattern — publishers raise events, subscribers react, and neither needs to know about the other.
  • The event keyword wraps a delegate with safety: outside code can only subscribe (+=) and unsubscribe (-=), never invoke or overwrite.
  • The EventHandler pattern(object sender, EventArgs e) — is the standard .NET convention. Use it.
  • Create custom EventArgs subclasses to pass specific data with your events.
  • Always unsubscribe (-=) when the subscriber's lifetime is shorter than the publisher's — otherwise you get memory leaks.
  • Events are everywhere in .NET: button clicks, file watchers, timers, HTTP pipelines, and more.
  • Events are delegates with guardrails. Yesterday's lesson gave you the engine; today you got the steering wheel.

Tomorrow: we'll tackle Async and Await — teaching your code to do multiple things at once without losing its mind. Ever clicked a button and watched an entire app freeze while it waited for a slow API call? We're going to fix that. 🚀

See you on Day 18!

Share
FM

Farhad Mammadov

.NET Engineer & Cloud Architect · Bayern, Germany. Writing about scalable backend systems, AWS, and SRE.