1. Introduction
A typical beginner might ask: "Where are events used in practice? Are all interactions between classes done via events instead of direct method calls?" The simple answer: events and delegates aren't magic that solve every architectural problem. But without them, a modern app quickly turns into a tightly coupled mess where changing one component forces changes in many others. Using events and delegates helps you build flexible, extensible, and maintainable systems.
- UI programming (WinForms, WPF, Xamarin, MAUI): handling clicks, hovers, text input, and other user actions.
- Asynchronous operations: file download completion, fetching data from the network, timer triggers.
- Plugin and extensible system architecture: lets you plug in new modules without tight integration into the core code.
- Signaling/broadcast systems: notify many interested components about events that happened.
- Observing state changes (the "observer"): reacting to a new message, data change, UI update.
Alright — let's get to it!
2. Architectural skeleton
Imagine you're building a small console app — a "Mini-chat" for in-company learning (or just to level up your skills). We have entities: a User, a Chat, and maybe a Bot assistant. When a user sends a message, the chat should notify all connected users and bots so they print the message to the screen or, in the bot's case, generate an auto-reply. Classic scenario: the publisher raises an event (event), handlers subscribe to an EventHandler/EventHandler<TEventArgs>, and a handler method like OnMessageReceived reacts to the notification.
// User class (subscriber)
public class User
{
public string Name { get; }
public User(string name)
{
Name = name;
}
// Method that will be the event handler
public void OnMessageReceived(object? sender, MessageEventArgs e)
{
Console.WriteLine($"[{Name}] saw a new message: {e.MessageText}");
}
}
// Event args
public class MessageEventArgs : EventArgs
{
public string MessageText { get; }
public MessageEventArgs(string text) => MessageText = text;
}
// Chat class (event publisher)
public class ChatRoom
{
public event EventHandler<MessageEventArgs>? MessageReceived;
public void SendMessage(string text)
{
// Raise the event (notify all subscribers)
MessageReceived?.Invoke(this, new MessageEventArgs(text));
}
}
Usage example:
var chat = new ChatRoom();
var user1 = new User("Anton");
var user2 = new User("Maria");
chat.MessageReceived += user1.OnMessageReceived;
chat.MessageReceived += user2.OnMessageReceived;
chat.SendMessage("Hello, everyone! 😊");
In the console you'll see two messages — both users learned about the new message.
3. Dynamic subscribe and unsubscribe
In real life a user might leave the chat and no longer want to receive messages. Let's teach the user to unsubscribe properly (the -= operator):
// In class User you can add an "Unsubscribe" method
public void Unsubscribe(ChatRoom chat)
{
chat.MessageReceived -= OnMessageReceived;
}
Extend the example:
var chat = new ChatRoom();
var user1 = new User("Anton");
var user2 = new User("Maria");
chat.MessageReceived += user1.OnMessageReceived;
chat.MessageReceived += user2.OnMessageReceived;
chat.SendMessage("First news");
user2.Unsubscribe(chat); // Maria leaves the chat
chat.SendMessage("Maria won't see this message anymore");
Dynamic subscribe/unsubscribe happens everywhere: windows close, tabs are destroyed, short-lived services unsubscribe from global event sources. Remember — a subscriber that doesn't unsubscribe becomes a zombie, and your app becomes a memory-leaker!
4. Message handling and generating a reply
Now let's add a bot that reacts to every message. Let the bot say "Hello!" automatically if the message contains the word "bot". We'll also look at multiple subscriptions and how multicast delegates work.
public class Bot
{
public string Name { get; }
public Bot(string name) => Name = name;
public void OnMessageReceived(object? sender, MessageEventArgs e)
{
// Bot reacts to the keyword
if (e.MessageText.Contains("bot", StringComparison.OrdinalIgnoreCase))
{
if (sender is ChatRoom chatRoom)
{
Console.WriteLine($"[Bot {Name}]: Hello! How can I help?");
// Bot sends a reply
chatRoom.SendMessage($"Bot {Name} is ready to help you.");
}
}
}
}
Use it:
var chat = new ChatRoom();
var user = new User("Evgeny");
var bot = new Bot("Assistant");
chat.MessageReceived += user.OnMessageReceived;
chat.MessageReceived += bot.OnMessageReceived;
// Evgeny writes a message that triggers the bot
chat.SendMessage("Hi, bot, how are you?");
The console might show (we're demonstrating call order — it's not guaranteed!):
[Evgeny] saw a new message: Hi, bot, how are you?
[Bot Assistant]: Hello! How can I help?
[Evgeny] saw a new message: Bot Assistant is ready to help you.
[Bot Assistant]: Hello! How can I help?
[Evgeny] saw a new message: Bot Assistant is ready to help you.
[Bot Assistant]: Hello! How can I help?
...
Spot the potential bug? Right — an infinite loop: the bot reacts to its own messages (it sees the word "bot" again). One way to prevent this is to add a simple check:
public void OnMessageReceived(object? sender, MessageEventArgs e)
{
// Bot doesn't react to its own messages
if (e.MessageText.Contains("bot", StringComparison.OrdinalIgnoreCase) &&
!e.MessageText.Contains(Name))
{
if (sender is ChatRoom chatRoom)
{
Console.WriteLine($"[Bot {Name}]: Hello! How can I help?");
chatRoom.SendMessage($"Bot {Name} is ready to help you.");
}
}
}
In practice these situations are a great prompt to think about handling cyclic events, using lambda expressions, or even implementing a cancellation mechanism for subsequent handlers (see earlier lectures).
5. Asynchronous operations and callbacks
Very often events are used to signal the completion of an asynchronous operation. For example, downloading data from the internet or a long calculation: a Completed event notifies about the end, and a method RunLongOperationAsync performs the long-running work.
public class LongRunner
{
// Event signaling completion
public event EventHandler<EventArgs>? Completed;
public async Task RunLongOperationAsync()
{
Console.WriteLine("Long operation started...");
await Task.Delay(2000); // This simulates long work
Console.WriteLine("Operation finished, notifying subscribers.");
Completed?.Invoke(this, EventArgs.Empty);
}
}
Client code:
var runner = new LongRunner();
// Subscribe to the completion event
runner.Completed += (sender, e) =>
{
Console.WriteLine("Notification received: operation completed!");
};
await runner.RunLongOperationAsync();
This is the foundation of async interaction — events are often used in UI frameworks to signal loading completion, animation end, user clicks, etc.
6. Signaling systems
Let's look at another classic task: a notification system (for example, in an online store when an item goes on sale). All interested "listeners" learn about it via the SaleOccurred event:
public class SaleNotifier
{
public event EventHandler<SaleEventArgs>? SaleOccurred;
public void AnnounceSale(string product, decimal newPrice)
{
SaleOccurred?.Invoke(this, new SaleEventArgs(product, newPrice));
}
}
public class SaleEventArgs : EventArgs
{
public string Product { get; }
public decimal NewPrice { get; }
public SaleEventArgs(string product, decimal price)
{
Product = product; NewPrice = price;
}
}
public class Customer
{
public string Name { get; }
public Customer(string name) => Name = name;
public void OnSale(object? sender, SaleEventArgs e)
{
Console.WriteLine($"[{Name}] got a notification: {e.Product} now costs {e.NewPrice.ToString("F2")} units!");
}
}
Use it:
var notifier = new SaleNotifier();
var c1 = new Customer("Andrey");
var c2 = new Customer("Olga");
notifier.SaleOccurred += c1.OnSale;
notifier.SaleOccurred += c2.OnSale;
notifier.AnnounceSale("Kettle", 999.99m);
If one of the customers is no longer interested — unsubscribe:
notifier.SaleOccurred -= c2.OnSale;
notifier.AnnounceSale("Mixer", 1999.99m);
This pattern is used for broadcasting signals (notification, pub/sub), which is very important in modern architectures.
7. Implementing a cancellation mechanism for the event chain
Sometimes one of the handlers should "stop" further notification. People usually use an EventArgs descendant with a cancel flag. In the example below we manually iterate over GetInvocationList() and break execution when Cancel is true.
public class CancelEventArgs : EventArgs
{
public bool Cancel { get; set; }
}
public class EventSource
{
public event EventHandler<CancelEventArgs>? SomethingHappened;
public void DoSomething()
{
var args = new CancelEventArgs();
// Classic loop to iterate subscribers (if you need control over order)
var handlers = SomethingHappened?.GetInvocationList();
if (handlers != null)
{
foreach (var handler in handlers)
{
((EventHandler<CancelEventArgs>)handler)(this, args);
if (args.Cancel)
{
Console.WriteLine("The message chain was stopped.");
break;
}
}
}
}
}
Use it:
var source = new EventSource();
source.SomethingHappened += (s, e) =>
{
Console.WriteLine("First handler");
};
source.SomethingHappened += (s, e) =>
{
Console.WriteLine("Second handler cancels the event.");
e.Cancel = true;
};
source.SomethingHappened += (s, e) =>
{
Console.WriteLine("This handler will not be called.");
};
source.DoSomething();
Result:
First handler
Second handler cancels the event.
The message chain was stopped.
This mechanism is useful for validation, handling events before a window closes, permission checks, and other tasks where you need to say "Stop! Don't process further."
8. Thread-safety and managing subscribers
In multithreaded apps where subscribers can be added or removed while an event is being raised, it's important to follow thread-safe patterns (see the official documentation). You can implement the event manually with add and remove accessors and guard access with lock:
public class CustomEvent
{
private EventHandler? _handlers;
public event EventHandler SomethingHappened
{
add
{
lock (this) // Thread-safe
{
_handlers += value;
}
}
remove
{
lock (this)
{
_handlers -= value;
}
}
}
protected void RaiseEvent()
{
// Use a copy
EventHandler? handler;
lock (this)
{
handler = _handlers;
}
handler?.Invoke(this, EventArgs.Empty);
}
}
This approach is rare (the default mechanism is usually enough), but sometimes it's needed for high-load systems.
9. Events in UI — WinForms/WPF/MAUI
WinForms:
private void button1_Click(object sender, EventArgs e)
{
MessageBox.Show("Button clicked!");
}
Behind this method there's a subscription mechanism:
button1.Click += button1_Click;
WPF/MAUI: events like PropertyChanged (notifications about model property changes):
public class MyViewModel : INotifyPropertyChanged
{
private string _value;
public string Value
{
get => _value;
set
{
if (_value != value)
{
_value = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value));
}
}
}
public event PropertyChangedEventHandler? PropertyChanged;
}
Frameworks heavily use events to build reactive interfaces (MVVM).
10. Common mistakes and pitfalls when working with events
Mistake #1: causing memory leaks.
This is the most common problem. If a short-lived object (for example, a window or a temporary service) subscribes to an event of a long-lived object (like a global cache or a static class) and doesn't unsubscribe when it's destroyed, it'll stay in memory forever. Always implement unsubscription in Dispose or another appropriate lifecycle place.
Mistake #2: creating infinite event cycles.
A subscriber may react to an event by doing something that raises the same event again. That leads to infinite recursion and the app crashing with a StackOverflowException. Always check conditions so a handler doesn't react to events it itself caused.
Mistake #3: relying on the order of subscriber calls.
Never rely on handlers being called in the same order they were subscribed. The C# specification and CLR implementation don't guarantee that. If order matters, invoke subscribers manually via GetInvocationList() in the sequence you need.
Mistake #4: unsafe event handling in multithreaded environments.
If subscribers are added or removed from one thread while the event is raised from another, you can get a race condition. Classic protection is copying the delegate to a local variable before invoking:
var handler = MyEvent;
handler?.Invoke(this, EventArgs.Empty);
Or use lock when implementing events manually with add/remove.
GO TO FULL VERSION