Understanding Events in C#
I assume you have basic knowledge of delegates at this point. If not, please go back and read my first post in this section about events and delegates - Understanding Delegates in C#.
Using events we as the developers can provide a standardized way to implement the observer pattern in our applications. The observer pattern is a way for objects to notify other objects when something interesting happens.
What Are Events?
An event is a special type of a multicast delegate, where only the class declaring it can invoke it. Ever heard of encapsulation? Here you have it! Using events will encapsulate the logic and that is the very difference from a regular delegate.
I often tell other to think of events as a publish-subscribe setup.
- The publisher will raise an event when something happens. This could be a domain event (
UserCreatedDomainEvent). - The subscriber will listen for the events (the domain event in this example), and it will respond when the event arrives.
- A subscriber is a standalone piece of code. They should not interact/affect others behavior
- A subscriber should not trigger events itself. Only the event publisher should be allowed for raising it. e.g. an entity/domain object.
Why Use Events Instead of Plain Delegates?
When using an event you are promised some safety, that is not the case with delegates. Let me show you π
// β Problem with public delegates
public delegate void WorkDelegate(int hours);
public WorkDelegate WorkPerformed; // Anyone can invoke this!
// β
Solution with events
public event WorkDelegate WorkPerformed; // Only this class can invokeBy using events, external code is only able to subscribe (+=) or unsubscribe -=. It won't be able to invoke the event or clear any subscriber of the event.
Creating Custom Events with Custom Delegates
Ever heard of workers? I will now show you how to build a worker system that can track when work is done. The first thing you will have to do is defining a custom delegate, and event.
Define a custom delegate
// 1. Define a custom delegate
public delegate void WorkPerformedDelegate(int hours, string workType);
public class Worker
{
// 2. Declare an event using that delegate
public event WorkPerformedDelegate? WorkPerformed;
// 3. Method to perform work
public void DoWork(int hours, string workType)
{
Console.WriteLine($"Working on {workType} for {hours} hours...");
// 4. Raise the event
OnWorkPerformed(hours, workType);
}
// 5. Protected method to raise the event
// This pattern allows derived classes to customize event raising
protected virtual void OnWorkPerformed(int hours, string workType)
{
// Invoke the event if there are subscribers
WorkPerformed?.Invoke(hours, workType);
}
}Using the custom event
var worker = new Worker();
// Subscribe to the event
worker.WorkPerformed += (hours, workType) =>
{
Console.WriteLine($" β Logged: {hours} hours of {workType}");
};
worker.WorkPerformed += (hours, workType) =>
{
Console.WriteLine($" β Notified manager about {workType}");
};
// Trigger the work
worker.DoWork(8, "Development");
/* Output:
Working on Development for 8 hours...
β Logged: 8 hours of Development
β Notified manager about Development
*/Creating Custom EventArgs
Instead of passing multiple parameters, it's a best practice to create a custom EventArgs class:
// Custom EventArgs class
public class WorkPerformedEventArgs : EventArgs
{
public int Hours { get; }
public string WorkType { get; }
public DateTime Timestamp { get; }
public WorkPerformedEventArgs(int hours, string workType)
{
Hours = hours;
WorkType = workType;
Timestamp = DateTime.Now;
}
}Why would you do that? π€ It's simple..
- Having a custom class for event arguments, allows you to add new properties without breaking any of your existing code.
- You can group related data in a single object.
- It will follow standard .NET conventions.
Using The Built-in Generic EventHandler<T>
In C# we got something named EventHandler<T> and it is generic. Using this you can get rid of the custom delegates. π Let me show you how we can refactor our code to use that generic instead.
Defining the EventHandler
public class Worker
{
// No custom delegate needed π
// EventHandler<T> is defined as:
// public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e)
public event EventHandler<WorkPerformedEventArgs>? WorkPerformed;
public void DoWork(int hours, string workType)
{
Console.WriteLine($"Starting: {workType}");
for (int i = 1; i <= hours; i++)
{
// Simulate work
Console.WriteLine($" Hour {i}...");
// Raise event
OnWorkPerformed(i, workType);
}
Console.WriteLine("Work completed!");
}
protected virtual void OnWorkPerformed(int hours, string workType)
{
var args = new WorkPerformedEventArgs(hours, workType);
WorkPerformed?.Invoke(this, args);
}
}Clean and easy to maintain! π
Using The Generic Event
Now that we have defined the generic event, let's write some code that can actually use it.
var worker = new Worker();
worker.WorkPerformed += (sender, e) =>
{
Console.WriteLine($" [Logger] {e.Hours}h on {e.WorkType} at {e.Timestamp:HH:mm:ss}");
};
worker.DoWork(3, "Code Review");
/* Output:
Starting: Code Review
Hour 1...
[Logger] 1h on Code Review at 14:30:15
Hour 2...
[Logger] 2h on Code Review at 14:30:15
Hour 3...
[Logger] 3h on Code Review at 14:30:15
Work completed!
*/The 1h, 2h, and 3h will arrive within the same second as we have no timer in the for loop earlier, but this was only meant for simulating some work. Let's see how we can use the built-in event handler to avoid any custom data.
Using Built-in EventHandler
When you have an event that do not need any custom data, there is no need for the generic. Instead we can swop it, and use the non-generic EventHandler.
public class Worker
{
public event EventHandler<WorkPerformedEventArgs>? WorkPerformed;
public event EventHandler? WorkCompleted; // No custom data needed π
public void DoWork(int hours, string workType)
{
for (int i = 1; i <= hours; i++)
{
OnWorkPerformed(i, workType);
}
// Notify that all work is done
OnWorkCompleted();
}
protected virtual void OnWorkPerformed(int hours, string workType)
{
var args = new WorkPerformedEventArgs(hours, workType);
WorkPerformed?.Invoke(this, args);
}
protected virtual void OnWorkCompleted()
{
// EventArgs.Empty is a reusable empty instance
WorkCompleted?.Invoke(this, EventArgs.Empty);
}
}That is how easy it is to work with events in .NET - pretty cool right?! π₯ Thanks to the awesome engineers behind C# and the .NET framework, we get a lot of features out-of-the box and we are not forced to re-invent the wheel. I love it! π
So with all those small examples, let's put them all in a dish and see what we can do.
Building a Order Processing System
Let's take a real-world example where we will use what you just learned. This example is a simple order processing system that I just created.
// EventArgs for order processing
public class OrderProcessedEventArgs : EventArgs
{
public string OrderId { get; }
public decimal Amount { get; }
public string Status { get; }
public OrderProcessedEventArgs(string orderId, decimal amount, string status)
{
OrderId = orderId;
Amount = amount;
Status = status;
}
}
public class OrderProcessor
{
// Custom delegate with custom event
public delegate void OrderValidationDelegate(string orderId);
public event OrderValidationDelegate? OrderValidating;
// Generic EventHandler<T>
public event EventHandler<OrderProcessedEventArgs>? OrderProcessed;
// Standard EventHandler
public event EventHandler? ProcessingCompleted;
public void ProcessOrder(string orderId, decimal amount)
{
Console.WriteLine($"\n{'='.ToString().PadLeft(50, '=')}");
Console.WriteLine($"Processing Order: {orderId}");
Console.WriteLine($"{'='.ToString().PadLeft(50, '=')}");
// Raise custom event
OnOrderValidating(orderId);
// Simulate processing
Console.WriteLine(" β Processing payment...");
// Raise generic event
OnOrderProcessed(orderId, amount, "Completed");
// Raise standard event
OnProcessingCompleted();
}
protected virtual void OnOrderValidating(string orderId)
{
Console.WriteLine($" β Validating order {orderId}");
OrderValidating?.Invoke(orderId);
}
protected virtual void OnOrderProcessed(string orderId, decimal amount, string status)
{
var args = new OrderProcessedEventArgs(orderId, amount, status);
OrderProcessed?.Invoke(this, args);
}
protected virtual void OnProcessingCompleted()
{
ProcessingCompleted?.Invoke(this, EventArgs.Empty);
}
}
// Usage
var processor = new OrderProcessor();
// Subscribe to validation event (custom delegate)
processor.OrderValidating += (orderId) =>
{
Console.WriteLine($" β Validated: {orderId}");
};
// Subscribe to processed event (generic EventHandler<T>)
processor.OrderProcessed += (sender, e) =>
{
Console.WriteLine($" β Order {e.OrderId}: ${e.Amount} - {e.Status}");
};
// Subscribe to completion event (standard EventHandler)
processor.ProcessingCompleted += (sender, e) =>
{
Console.WriteLine(" β All processing completed!");
Console.WriteLine($"{'='.ToString().PadLeft(50, '=')}");
};
// Process orders
processor.ProcessOrder("ORD-001", 99.99m);
processor.ProcessOrder("ORD-002", 149.50m);Code is clean and easy for other engineers to maintain or extend. The code above is a combination of custom events (delegates), work simulation, generic and standard events.
When running that code, we will get the following.
==================================================
Processing Order: ORD-001
==================================================
β Validating order ORD-001
β Validated: ORD-001
β Processing payment...
β Order ORD-001: $99.99 - Completed
β All processing completed!
==================================================
==================================================
Processing Order: ORD-002
==================================================
β Validating order ORD-002
β Validated: ORD-002
β Processing payment...
β Order ORD-002: $149.50 - Completed
β All processing completed!
==================================================That is how easily you can build an order processor. No custom packages, etc... everything straight out of the C# language and .NET framework. It's clean and powerful. π
My Best Practices for Raising Events
I always follow this pattern when I am raising events in any application I am a part of.
protected virtual void OnEventName(EventArgs e)
{
// 1. Make it virtual so derived classes can override
// 2. Use null-conditional operator for safety
// 3. Pass 'this' as sender to identify event source
EventName?.Invoke(this, e);
}I am using the virtual keyword to allow derived classes to do the following.
- Add their own custom behavior before or after raising the event.
- Stop the event from being raised under certain conditions.
- Modify the event arguments.
When am I Using Each of The Event Types?
I know it can seem a bit confusing with custom, generic, non-generic, etc... Let's break them down and I will tell you what I am using for what/when.
Custom Delegate + Event
Use this when you are in a need of a super specific signature that doesn't fit the standard pattern.
public delegate void ProgressDelegate(int percent, string message);
public event ProgressDelegate? Progress;EventHandler<T>
This is the one I am using for 95% of my cases. It is also the recommended approach.
public event EventHandler<OrderProcessedEventArgs>? OrderProcessed;EventHandler
This one should be used when you do not need to provide any custom data for the event.
public event EventHandler? Completed;Key Takeaways
Summing it all down. An event in C# is an encapsulated delegate that provide a safe and managed way for objects to communicate. When you need to pass data along your events, use the EventArgs, and utilize EventHandler<T> as the way for declaring your events.
Best practice is to follow the conventional OnEventName() method when you are raising new events. Mark the event methods virtual to provide support for extensibility in any derived class. Always use the null-conditional operator (.?) when raising an event and that you can only raise an event in the class that declared it.
What's Next?
That was quite a bit, but awesome that you made it to the end! Give yourself a high-five on that π Now that you know how to create and raise events, I think it is time to have a look at how we can subscribe to them.
In the next section we will take a closer look at how we properly subscribe and unsubscribe to events. I will also give you an introduction on how to avoid some common pitfalls (that I personally got into when I first started making publisher-subscriber apps) - especially memory leaks are one that many stumble upon.