← Open in the full interactive course (progress tracking, search & more)

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.

What Is It?

The Simple Explanation

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.

The Technical Definition

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).

Direct Calls (Tightly Coupled)

Event-Driven (Decoupled)

Why Does It Exist?

The Problem — Direct Calls Force One Class to Know Everything

When OrderService calls _inventoryService.ReduceStock(order) and _emailService.SendConfirmation(order) directly, three problems compound as the system grows:

The Solution — Publish a Fact, Let Others React Independently

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.

The key insight

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.

Big Picture

BEFORE vs AFTER — WHO KNOWS ABOUT WHOM
WITHOUT EVENTS
OrderService
  → calls InventoryService
  → calls EmailService
  → calls SmsService
  → calls LoyaltyService
  (knows every one, by name)
WITH EVENTS
OrderService
  → raises OrderPlaced
      ⤳ InventoryService listens
      ⤳ EmailService listens
      ⤳ SmsService listens
      ⤳ LoyaltyService listens
  (knows none of them)

How It Works

WIRING AN EVENT-DRIVEN FLOW
1. DEFINE THE EVENT'S DATA
public class OrderPlacedEventArgs : EventArgs
{
    public int OrderId { get; }
    public string CustomerEmail { get; }
    // ...
}
2. PUBLISHER DECLARES AND RAISES THE EVENT — NOTHING ELSE
public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;
// PlaceOrder(...) saves the order, then calls OnOrderPlaced(e)
3. EACH SUBSCRIBER IS ITS OWN INDEPENDENT CLASS
4. THE COMPOSITION ROOT WIRES EVERYTHING TOGETHER
orderService.OrderPlaced += inventoryUpdater.Handle;
orderService.OrderPlaced += emailer.Handle;
5. ORDER PLACED → EVERY SUBSCRIBER RUNS, EACH HANDLING ITS OWN CONCERN

Simple Example

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.

Real-World Example

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.

Analogy

A Notice Board, Not a Phone Tree

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.

Under the Hood

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.

Common Confusion

1. "Event-driven design" and "event-driven architecture" are the same thing — they aren't

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.

2. "Event" and "command" sound similar, but mean opposite things

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.

Common Mistakes

Mistake 1 — Letting the publisher reach back into a subscriber's business

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.

Mistake 2 — Assuming subscribers run in parallel or "in the background"

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.

Mistake 3 — Using an event where you actually need a return value

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.

When Should I Use It?

Mental Model

Publisher = announces a fact, knows nothing about who's listening.
Subscriber = decides for itself whether and how to react, owns its own failures.
Composition root = the only place that connects publisher to subscribers.
Still one call stack, one thread, synchronous, in-process — not a message queue, not a distributed system.

Key Takeaway

Looking ahead: Everything here stays inside one process and one call stack. The Advanced tier revisits this exact idea — a publisher announcing "an order was placed" — but stretched across independent services communicating over a message broker, where subscribers can be down, slow, or processing the message minutes later. The design instinct (decouple the publisher from its reactions) carries forward; the mechanics change completely.

Check Your Understanding

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?

Show answer

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?

Show answer

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?

Show answer

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?

Show answer

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.