The capstone of Part III: everything from delegates to the standard event pattern, now used to pull an application apart into pieces that don't know about each other.
Picture an OrderService.PlaceOrder() method written the straightforward way: it saves the order, then calls _inventoryService.ReduceStock(order), then calls _emailService.SendConfirmation(order). It works. Then the business asks for an SMS notification too — you edit OrderService again. Then a loyalty-points system — you edit it again. A year later, PlaceOrder() is forty lines long, OrderService has constructor dependencies on six unrelated services, and nobody can safely change the inventory logic without recompiling and re-testing the order flow.
OrderService's actual job is placing an order. It has been quietly forced into a second job: knowing about — and personally calling — every single thing in the system that cares when an order is placed. Event-driven design is the fix: OrderService raises a signal that "an order was placed," and everything else that cares about that fact listens for it independently, without OrderService ever knowing they exist.
Event-driven design is structuring parts of an application so that a component announces "something happened" using an event, instead of directly calling every other component that might need to react to it. The component raising the event — the publisher — has no reference to, and no knowledge of, whichever components — the subscribers — end up listening. Everything you've learned in Part III, from multicast delegates through the standard EventArgs/OnXxx pattern in lesson 106, is the mechanism this is built on. This lesson is about what you do with that mechanism at the level of application design.
In an event-driven design, dependencies point in one direction only: subscribers depend on the publisher's event (they need to know it exists in order to subscribe to it), but the publisher has zero dependency on any subscriber — no field, no constructor parameter, no using statement referencing a single one of them. New reactions to the same event are added by writing a new subscriber and wiring it up at the application's composition point, without modifying the publisher's code at all — a direct application of the open/closed principle (open for extension, closed for modification).
OrderService holds references to InventoryService, EmailService, ...OrderServiceOrderService in isolationOrderService knows only about its own OrderPlaced eventOrderServiceOrderService is trivially testable with no subscribers wired up at allWhen OrderService calls _inventoryService.ReduceStock(order) and _emailService.SendConfirmation(order) directly, three problems compound as the system grows:
OrderService and adding another dependency and another call — a class that should own "placing an order" keeps absorbing responsibilities that belong elsewhere.SendConfirmation throws because the mail server is briefly down, that exception happens inside PlaceOrder() itself — a failure in notifications can prevent (or appear to prevent) the order from being placed at all, even though those are two entirely separate concerns.OrderService happens to call, purely to satisfy its constructor.OrderService is only responsible for one thing: placing the order, and announcing that it did. It declares an OrderPlaced event and raises it once the order is genuinely placed. It has no idea whether zero, one, or ten things are listening — and that's precisely the point. Each concern that needs to react — inventory, email, SMS, loyalty points — becomes its own small class that subscribes to the event and handles its own piece of the reaction, including its own error handling.
This is Part III coming together: lesson 096–097 gave you multicast delegates, lesson 105 gave you event for encapsulated subscription, and lesson 106 gave you the standard pattern for carrying rich data safely. Event-driven design is simply using that machinery deliberately, at the level of how classes in your application are allowed to depend on each other.
OrderService
→ calls InventoryService
→ calls EmailService
→ calls SmsService
→ calls LoyaltyService
(knows every one, by name)
OrderService
→ raises OrderPlaced
⤳ InventoryService listens
⤳ EmailService listens
⤳ SmsService listens
⤳ LoyaltyService listens
(knows none of them)
public class OrderPlacedEventArgs : EventArgs
{
public int OrderId { get; }
public string CustomerEmail { get; }
// ...
}
public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;
// PlaceOrder(...) saves the order, then calls OnOrderPlaced(e)
OrderService has no field, no constructor parameter, referencing any subscriber.InventoryUpdater — knows nothing about email or SMSOrderConfirmationEmailer — knows nothing about inventoryorderService.OrderPlaced += inventoryUpdater.Handle;
orderService.OrderPlaced += emailer.Handle;
OrderService and its subscribers exist together.using System;
public class Publisher
{
public event EventHandler<string>? SomethingHappened;
protected virtual void OnSomethingHappened(string detail)
=> SomethingHappened?.Invoke(this, detail);
public void DoWork() => OnSomethingHappened("work finished");
}
class Program
{
static void Main()
{
var publisher = new Publisher();
// Two completely independent subscribers — neither knows the other exists
publisher.SomethingHappened += (sender, detail) => Console.WriteLine($"Logger: {detail}");
publisher.SomethingHappened += (sender, detail) => Console.WriteLine($"Metrics: recorded '{detail}'");
publisher.DoWork();
// Logger: work finished
// Metrics: recorded 'work finished'
}
}
Code → Meaning → Result: Publisher never mentions logging or metrics anywhere in its own code — it just does its work and raises an event. Main, acting as the composition point, decides that a logger and a metrics recorder both care about this particular event. Delete either subscription line and Publisher doesn't change at all — it still works exactly the same.
Here's the order-processing scenario in full: OrderService raises OrderPlaced, and two independent subscribers — InventoryUpdater and OrderConfirmationEmailer — react to it, each with its own single responsibility. Notice that OrderService contains zero references to either of them.
using System;
using System.Collections.Generic;
// ─── Event data ───
public class OrderPlacedEventArgs : EventArgs
{
public int OrderId { get; }
public string CustomerEmail { get; }
public IReadOnlyList<string> ItemSkus { get; }
public OrderPlacedEventArgs(int orderId, string customerEmail, IReadOnlyList<string> itemSkus)
{
OrderId = orderId;
CustomerEmail = customerEmail;
ItemSkus = itemSkus;
}
}
// ─── Publisher — knows NOTHING about inventory or email ───
public class OrderService
{
public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;
protected virtual void OnOrderPlaced(OrderPlacedEventArgs e)
=> OrderPlaced?.Invoke(this, e);
public void PlaceOrder(int orderId, string customerEmail, IReadOnlyList<string> itemSkus)
{
// ... core responsibility only: validate and persist the order ...
Console.WriteLine($"Order #{orderId} saved.");
OnOrderPlaced(new OrderPlacedEventArgs(orderId, customerEmail, itemSkus));
}
}
// ─── Subscriber 1 — its own, isolated concern ───
public class InventoryUpdater
{
public void HandleOrderPlaced(object? sender, OrderPlacedEventArgs e)
{
foreach (var sku in e.ItemSkus)
Console.WriteLine($" [Inventory] Reduced stock for {sku}");
}
}
// ─── Subscriber 2 — its own, isolated concern ───
public class OrderConfirmationEmailer
{
public void HandleOrderPlaced(object? sender, OrderPlacedEventArgs e)
=> Console.WriteLine($" [Email] Confirmation sent to {e.CustomerEmail}");
}
// ─── Composition root — the ONLY place that knows everyone ───
class Program
{
static void Main()
{
var orderService = new OrderService();
var inventoryUpdater = new InventoryUpdater();
var emailer = new OrderConfirmationEmailer();
orderService.OrderPlaced += inventoryUpdater.HandleOrderPlaced;
orderService.OrderPlaced += emailer.HandleOrderPlaced;
orderService.PlaceOrder(1001, "sam@example.com", new[] { "SKU-42", "SKU-7" });
// Order #1001 saved.
// [Inventory] Reduced stock for SKU-42
// [Inventory] Reduced stock for SKU-7
// [Email] Confirmation sent to sam@example.com
}
}
If the business asks for SMS notifications tomorrow, the fix is a new OrderSmsNotifier class and one more line in Main: orderService.OrderPlaced += smsNotifier.HandleOrderPlaced;. OrderService, InventoryUpdater, and OrderConfirmationEmailer are not touched, not recompiled for a reason related to this change, and not re-tested for behavior they don't own.
A phone tree is what OrderService looks like without events: the person at the top has to personally know and dial every single number, in order, and if someone new needs to be told, that top person has to learn a new phone number and add another call. A notice board is the event-driven version: OrderService pins one notice — "Order #1001 placed" — and walks away. Anyone in the building who cares reads the board on their own schedule and reacts however they see fit. The board doesn't know or care who's reading it, and a new department can start reading it tomorrow without anyone updating the board itself.
It's important to be precise about what's actually happening at runtime here, because the word "event" invites a bigger mental picture than what's really going on in-process. Everything in this lesson is still the exact same multicast delegate invocation list from lesson 097: OnOrderPlaced(e) calls OrderPlaced?.Invoke(this, e), which walks the invocation list and calls InventoryUpdater.HandleOrderPlaced, then OrderConfirmationEmailer.HandleOrderPlaced, synchronously, on the same thread, in subscription order — one after another, each one blocking until it returns, all still inside the original call to PlaceOrder(). There's no queue, no separate process, and no network hop anywhere in this picture. If InventoryUpdater's handler takes three seconds, PlaceOrder() doesn't return until those three seconds have passed.
One consequence worth internalizing: if any subscriber's handler throws an unhandled exception, that exception propagates straight back up through ?.Invoke, out of OnOrderPlaced, and out of PlaceOrder() itself — and any subscriber later in the invocation list than the one that threw never runs at all. A misbehaving email handler can, in this design, prevent inventory from ever being updated for that call, simply because it happened to be registered before it in the list.
This lesson's pattern — a C# event, subscribers in the same process, synchronous invocation, everyone sharing one call stack and one memory space — is in-process event-driven design. A much larger idea shares the name: event-driven architecture, where publishers and subscribers are entirely separate services, often on separate machines, communicating asynchronously through a message broker (RabbitMQ, Kafka, Azure Service Bus). There, a "subscriber" isn't a C# object holding a reference — it's an independent process that reads messages off a durable queue, possibly minutes later, possibly after a crash and restart. That's a genuinely different set of tools and trade-offs — durability, retries, network failures, eventual consistency — and it belongs to a later, Advanced-tier topic. Everything in this lesson stays inside a single process on a single call stack.
A command is an instruction — imperative, forward-looking, and it can be refused: PlaceOrder(), CancelOrder(). An event describes a fact that has already happened — past tense, and by the time anyone hears about it, it's not up for debate: OrderPlaced, OrderCancelled. Naming an event in the imperative (PlaceOrder as an event name, for instance) is a signal that the design has drifted — an event's name should always read like something that's already true.
OrderService catching an exception from a subscriber and then deciding, on its behalf, "if inventory update fails, I should roll back the order" — this quietly reintroduces the exact coupling events were meant to remove, just hidden behind an event subscription instead of a direct call.
Each subscriber owns its own failure handling. If a genuine cross-cutting rule like "the whole order must roll back if inventory fails" is a real business requirement, that's a sign the two operations aren't actually independent — model it as one transactional operation instead of forcing events to simulate it.
Wiring up a slow subscriber (one that calls a mail server, say) and assuming PlaceOrder() returns immediately while that subscriber "handles it later" — as shown under the hood above, it doesn't; every handler runs synchronously, in order, before PlaceOrder() returns.
If a reaction genuinely needs to happen asynchronously, the subscriber's handler is responsible for kicking off that async work itself (e.g. queuing a background job) — the event mechanism itself stays synchronous.
Trying to make OrderPlaced "ask" a subscriber for an approval decision or a computed result — EventHandler is void-returning by design; there's no clean way to get a single answer back out of a multicast call with multiple subscribers.
If you need an answer back — "is this order allowed to proceed?" — that's a direct method call with a return value (or a dedicated validation pipeline), not an event. Events are for one-way, fire-and-forget notifications that any number of independent parties may or may not be listening to.
event/EventArgs/OnXxx machinery from lessons 105–106 to let a publisher announce a fact without knowing or caring who, if anyone, is listening.You've seen how events decouple a publisher from its reactions, and where the boundaries of that idea sit. Let's confirm the details — and wrap up Part III.
1. In the OrderService example, why doesn't OrderService hold a reference to InventoryUpdater or OrderConfirmationEmailer?
Correct: B
Why B is correct: This is the central goal of event-driven design covered in this lesson — the publisher announces a fact through its event and has no dependency on any specific subscriber. The composition root, not OrderService, is what wires the two together.
Why A is incorrect: C# has no such restriction; classes can and often do reference many other classes. The lack of a reference here is a deliberate design choice, not a language limit.
Why C is incorrect: Both classes are ordinary instance classes constructed in Main — nothing about them being static is involved.
Why D is incorrect: Nothing about the event keyword prevents a publisher from also holding direct references to other classes — this is purely an intentional application-design decision.
Reinforcement: Decoupling in this design is a choice you make, not a constraint the language forces on you.
2. When OrderService.PlaceOrder() raises OrderPlaced with two subscribers attached, how do the two subscribers actually execute?
Correct: B
Why B is correct: As covered under the hood, this is still the same synchronous multicast delegate invocation list from lesson 097 — every subscriber runs in order on the calling thread, and PlaceOrder() doesn't return until all of them have finished.
Why A is incorrect: There is no automatic parallelism or background threading involved — that description matches a message-queue architecture, not in-process C# events.
Why C is incorrect: Every subscriber in the invocation list runs (barring an earlier exception) — subscribing doesn't remove or skip earlier subscribers.
Why D is incorrect: There's no queue and no delay — this describes external event-driven architecture, explicitly called out as a different, later topic.
Reinforcement: In-process events are synchronous and ordered — never assume background or parallel execution without writing it yourself.
3. What is the key difference between the in-process event-driven design covered in this lesson and a full external event-driven architecture using a message queue?
Correct: B
Why B is correct: This is exactly the distinction drawn in Common Confusion — this lesson's events share one call stack and one memory space; a message-broker architecture spans independent processes/services with asynchronous, often durable, delivery.
Why A is incorrect: They share a design goal (decoupling a publisher from its reactions) but are built with entirely different mechanics and trade-offs — conflating them is precisely the confusion this lesson warns against.
Why C is incorrect: Message queues are an independent messaging mechanism; they aren't tied specifically to the C# event keyword at all.
Why D is incorrect: Neither in-process C# events nor message queues inherently require a database — that's not the distinguishing factor between them.
Reinforcement: Same underlying idea, two very different implementations — know which one you're actually using.
4. A developer wants OrderService.PlaceOrder() to return true or false depending on whether a fraud-check subscriber approves the order. Is an event the right tool for this?
Correct: B
Why B is correct: This matches Mistake 3 directly — events are one-way and void-returning by design. Needing an answer back is a signal to use a direct method call or a dedicated validation pipeline, not to force it through an event.
Why A is incorrect: EventHandler's signature is void — a handler cannot return a value through the event invocation mechanism at all.
Why C is incorrect: Even with exactly one subscriber, the standard EventHandler delegate type still has no return value — the count of subscribers doesn't change the signature.
Why D is incorrect: The problem isn't the topic (fraud/approval) — it's the shape of the mechanism (needing a returned result). Events are fine for announcing "an order was flagged," just not for asking a yes/no question and getting one specific answer back.
Reinforcement: Recognize the need for a return value as the signal that you've reached for the wrong tool.
That's Part III complete — from multicast delegates through custom delegates, Action/Func/Predicate, lambdas, closures, local functions, higher-order functions, and finally events at both the mechanism and design level. You now have the full functional-C# toolkit that everything from LINQ to ASP.NET Core middleware is built on top of.
dotnetmadeeasy.com — Learn C# and .NET, the right way.