Why Events Power Reactive Applications
Events let objects notify others when something interesting happens without creating tight coupling between them. When you click a button in a GUI application, multiple systems need to respond: the UI might update, data might save, analytics might log the action. Without events, you'd need the button to know about every system that cares about clicks, creating a maintenance nightmare. Events flip this relationship so the button just announces "I was clicked" and interested parties listen independently.
This publisher-subscriber pattern is fundamental to reactive programming where your application responds to changes rather than constantly polling for them. A stock trading system uses events to notify subscribers when prices change. A game engine raises events when players take actions. Web applications fire events when data arrives from APIs. Events let you build systems where components react to changes without complex orchestration code coordinating everything.
C# provides first-class support for events through the event keyword, which builds on delegates to create safe publisher-subscriber relationships. You'll learn how events differ from regular delegates, master the standard EventHandler pattern that makes your events consistent with .NET conventions, understand thread-safety concerns when raising events, and see how to test event-driven code effectively. For deeper understanding of C# events and best practices, see Microsoft's official events documentation.
Understanding the Event Keyword
Events are built on delegates but add important restrictions. When you declare a delegate field as public, external code can invoke it, assign a completely new delegate to it, or even set it to null, wiping out all subscribers. The event keyword prevents these dangerous operations while still allowing external code to subscribe and unsubscribe. An event can only be invoked from the class that declares it, giving you control over when notifications happen.
Think of a newspaper subscription service. Readers can subscribe or unsubscribe, but they can't force the publisher to print an edition or prevent other readers from subscribing. Events provide exactly this relationship in code: publishers control when events fire, subscribers control whether they're listening, but neither can interfere with the other inappropriately.
Here's how events differ from plain delegates in practice:
public class Publisher
{
// Plain delegate - dangerous, allows external invocation
public Action OnDataChangedBad;
// Event - safe, restricts external access
public event Action OnDataChanged;
public void UpdateData(string newData)
{
// Internal code can invoke the event
OnDataChanged?.Invoke();
}
}
public class Usage
{
public void DemonstrateProblems()
{
var pub = new Publisher();
// Both allow subscription
pub.OnDataChangedBad += () => Console.WriteLine("Bad handler");
pub.OnDataChanged += () => Console.WriteLine("Safe handler");
// PROBLEM: Can invoke the delegate from outside!
pub.OnDataChangedBad?.Invoke(); // Compiles and runs
// SAFE: Cannot invoke event from outside
// pub.OnDataChanged?.Invoke(); // Won't compile!
// PROBLEM: Can replace all handlers
pub.OnDataChangedBad = () => Console.WriteLine("Replaced!");
// SAFE: Cannot assign to event
// pub.OnDataChanged = () => {}; // Won't compile!
}
}
The event keyword enforces the publisher-subscriber contract at compile time. External code gets += and -= operators to manage subscriptions, but it can't invoke the event or replace the subscriber list. This prevents bugs where one subscriber accidentally removes another or where external code triggers events at inappropriate times. Inside the Publisher class, you treat the event like a normal delegate and invoke it when needed.
Events also support multicast semantics automatically. When multiple handlers subscribe to an event, invoking it calls each handler in the order they subscribed. If any handler throws an exception, subsequent handlers won't run, so consider wrapping invocations in exception handling for robust systems where all subscribers should receive notifications even if some fail.
The Standard EventHandler Pattern
.NET has established conventions for how events should be designed. The standard pattern uses EventHandler where TEventArgs derives from EventArgs and contains any data subscribers need. This consistency makes events easier to understand and use correctly. When you follow the pattern, developers immediately recognize how to subscribe to your events and what information they'll receive.
The pattern includes two parameters: a sender object (typically the publisher itself) and an EventArgs object containing event-specific data. The sender helps when one handler serves multiple publishers and needs to know which one fired. Custom EventArgs classes let you pass rich data to subscribers without inventing new delegate signatures for every event.
Here's the canonical implementation following .NET's event design guidelines:
// Custom event args with relevant data
public class PriceChangedEventArgs : EventArgs
{
public decimal OldPrice { get; }
public decimal NewPrice { get; }
public decimal ChangePercent { get; }
public PriceChangedEventArgs(
decimal oldPrice,
decimal newPrice)
{
OldPrice = oldPrice;
NewPrice = newPrice;
ChangePercent = oldPrice == 0 ? 0 :
(newPrice - oldPrice) / oldPrice * 100;
}
}
public class Stock
{
private decimal _price;
public string Symbol { get; }
// Standard EventHandler pattern
public event EventHandler? PriceChanged;
public Stock(string symbol, decimal initialPrice)
{
Symbol = symbol;
_price = initialPrice;
}
public decimal Price
{
get => _price;
set
{
if (_price != value)
{
var oldPrice = _price;
_price = value;
// Raise event with thread-safe pattern
OnPriceChanged(new PriceChangedEventArgs(
oldPrice, value));
}
}
}
// Protected virtual method allows inheritance
protected virtual void OnPriceChanged(
PriceChangedEventArgs e)
{
// Thread-safe raise pattern
PriceChanged?.Invoke(this, e);
}
}
Notice the protected virtual OnPriceChanged method. This naming convention (OnEventName) is standard in .NET and provides an extensibility point. Derived classes can override this method to add behavior before or after the event fires without needing to subscribe to the event. The method accepts the event args so derived classes have access to the same information subscribers receive. This pattern appears throughout the .NET framework from Windows Forms to ASP.NET Core.
The nullable annotation on the event field (event EventHandler?) acknowledges that events might have no subscribers. The null-conditional operator in PriceChanged?.Invoke handles this gracefully. Before C# 6, you'd need to copy the event to a local variable, check for null, then invoke, making the code more verbose. The modern syntax is both safer and clearer.
Try It Yourself: Complete Working Example
Now let's build a practical event-driven system that demonstrates all these concepts working together. This example simulates a temperature sensor that raises events when readings change, showing how multiple subscribers can react independently to the same events.
Create a new console project and run this complete example:
var sensor = new TemperatureSensor("Living Room");
// Multiple independent subscribers
var display = new TemperatureDisplay();
var logger = new TemperatureLogger();
var alert = new TemperatureAlert(30.0m);
// Subscribe to events
sensor.TemperatureChanged += display.OnTemperatureChanged;
sensor.TemperatureChanged += logger.OnTemperatureChanged;
sensor.TemperatureChanged += alert.OnTemperatureChanged;
// Simulate temperature changes
Console.WriteLine("Simulating temperature changes...\n");
sensor.UpdateTemperature(22.5m);
sensor.UpdateTemperature(28.3m);
sensor.UpdateTemperature(31.7m); // Triggers alert
sensor.UpdateTemperature(25.0m);
// Clean up (prevent memory leaks)
sensor.TemperatureChanged -= display.OnTemperatureChanged;
sensor.TemperatureChanged -= logger.OnTemperatureChanged;
sensor.TemperatureChanged -= alert.OnTemperatureChanged;
// Event args with relevant data
public class TemperatureChangedEventArgs : EventArgs
{
public string SensorName { get; }
public decimal OldTemperature { get; }
public decimal NewTemperature { get; }
public DateTime Timestamp { get; }
public TemperatureChangedEventArgs(
string sensorName,
decimal oldTemp,
decimal newTemp)
{
SensorName = sensorName;
OldTemperature = oldTemp;
NewTemperature = newTemp;
Timestamp = DateTime.Now;
}
}
// Publisher
public class TemperatureSensor
{
private decimal _temperature;
public string Name { get; }
public event EventHandler?
TemperatureChanged;
public TemperatureSensor(string name)
{
Name = name;
_temperature = 20.0m;
}
public void UpdateTemperature(decimal newTemp)
{
if (_temperature != newTemp)
{
var oldTemp = _temperature;
_temperature = newTemp;
OnTemperatureChanged(
new TemperatureChangedEventArgs(
Name, oldTemp, newTemp));
}
}
protected virtual void OnTemperatureChanged(
TemperatureChangedEventArgs e)
{
TemperatureChanged?.Invoke(this, e);
}
}
// Subscriber 1: Display
public class TemperatureDisplay
{
public void OnTemperatureChanged(
object? sender,
TemperatureChangedEventArgs e)
{
Console.WriteLine(
$"[Display] {e.SensorName}: " +
$"{e.NewTemperature:F1}°C");
}
}
// Subscriber 2: Logger
public class TemperatureLogger
{
public void OnTemperatureChanged(
object? sender,
TemperatureChangedEventArgs e)
{
Console.WriteLine(
$"[Log] {e.Timestamp:HH:mm:ss} - " +
$"{e.SensorName} changed from " +
$"{e.OldTemperature:F1}°C to " +
$"{e.NewTemperature:F1}°C");
}
}
// Subscriber 3: Alert
public class TemperatureAlert
{
private readonly decimal _threshold;
public TemperatureAlert(decimal threshold)
{
_threshold = threshold;
}
public void OnTemperatureChanged(
object? sender,
TemperatureChangedEventArgs e)
{
if (e.NewTemperature > _threshold)
{
Console.WriteLine(
$"[ALERT] Temperature {e.NewTemperature:F1}°C " +
$"exceeds threshold {_threshold:F1}°C!");
}
}
}
Output:
Simulating temperature changes...
[Display] Living Room: 22.5°C
[Log] 14:23:15 - Living Room changed from 20.0°C to 22.5°C
[Display] Living Room: 28.3°C
[Log] 14:23:15 - Living Room changed from 22.5°C to 28.3°C
[Display] Living Room: 31.7°C
[Log] 14:23:15 - Living Room changed from 28.3°C to 31.7°C
[ALERT] Temperature 31.7°C exceeds threshold 30.0°C!
[Display] Living Room: 25.0°C
[Log] 14:23:15 - Living Room changed from 31.7°C to 25.0°C
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Each subscriber operates independently, reacting to temperature changes according to its specific purpose. The sensor doesn't know or care how many subscribers exist or what they do with the information. This loose coupling makes the system easy to extend: you could add a TemperatureStatistics subscriber to track averages, or a TemperatureController to adjust a thermostat, without modifying the sensor or existing subscribers. Event-driven architecture shines in scenarios like this where multiple concerns need to react to the same stimuli.
Testing Event-Driven Code
Events can be tricky to test because they represent asynchronous notifications between loosely coupled components. The key is verifying that events fire when expected and that subscribers receive the correct information. Testing helps catch issues like events not firing, firing multiple times, or passing incorrect data to handlers.
Here's a simple xUnit test that verifies event behavior:
public class TemperatureSensorTests
{
[Fact]
public void UpdateTemperature_RaisesEventWithCorrectData()
{
// Arrange
var sensor = new TemperatureSensor("Test");
TemperatureChangedEventArgs? receivedArgs = null;
sensor.TemperatureChanged += (sender, e) =>
receivedArgs = e;
// Act
sensor.UpdateTemperature(25.5m);
// Assert
Assert.NotNull(receivedArgs);
Assert.Equal("Test", receivedArgs.SensorName);
Assert.Equal(20.0m, receivedArgs.OldTemperature);
Assert.Equal(25.5m, receivedArgs.NewTemperature);
}
}
This test captures the event args in a closure and verifies they contain the expected values. The pattern works because the event fires synchronously during UpdateTemperature, so receivedArgs will be set before assertions run. For async events, you'd need different testing strategies involving TaskCompletionSource or async test methods.
Best Practices for Event-Driven Design
Always use the standard EventHandler pattern unless you have a compelling reason not to. Custom delegate signatures make your events inconsistent with .NET conventions and harder for developers to use. Even when you don't need custom data, use EventHandler with EventArgs.Empty rather than Action or custom delegates. This consistency helps developers understand your API instantly because they've seen the pattern thousands of times before.
Raise events using the null-conditional operator pattern: EventName?.Invoke(this, e). This is both thread-safe and concise. It atomically checks for subscribers and invokes them in one operation, preventing race conditions where subscribers could unsubscribe between your null check and invocation. The pattern became standard in C# 6 and should be used consistently throughout your codebase.
Make sure derived classes can participate in event handling by implementing the protected virtual OnEventName pattern. This allows subclasses to react to events or modify event raising behavior without subscribing to their own events. Place the actual event raising logic in these methods rather than duplicating it everywhere you trigger the event. This centralization makes it easy to add logging, validation, or other cross-cutting concerns to all event invocations.
Be careful about event handler memory leaks. When object A subscribes to events from object B, B holds a reference to A, preventing A from being garbage collected even if nothing else references it. Always unsubscribe in Dispose methods or when subscribers no longer need notifications. For UI applications where control lifetimes are managed by frameworks, consider weak event patterns that don't prevent garbage collection.
Consider whether events are the right tool for your scenario. Events work best for notifications where timing matters and multiple subscribers might care about the same occurrence. If you need request-response patterns, error propagation, or backpressure, consider alternatives like Task-based async methods, reactive frameworks like System.Reactive, or message brokers for distributed systems. Events are powerful but not a universal solution to all communication needs.