Event-Driven Programming
Imagine you are sitting in a room waiting for the phone to ring. You don’t constantly check the phone every second, asking: “Did someone call? Did someone call now?”. You're just doing your work. And when the phone rings, you react.
It turns out many real-life processes work exactly this way. Moreover, this is how many applications work, too. This way of writing programs can even lead to a completely different programming style: instead of constantly checking whether something happened, a program can simply wait for events and react when they occur.
This concept is known as Event-Driven Programming. In this article, we will explore what this paradigm is all about: the different ways components communicate — such as Observer, Mediator, and Event Bus — and common techniques for handling events, including debouncing vs throttling, buffering, batch processing, and event aggregation.
In case you are interested in other programming paradigms, look here:
- Structured Programming
- Object-Oriented Programming
- Aspect-Oriented Programming
- Functional Programming
- Event-Driven Programming
- Reactive Programming
Are you ready?😃 Let us begin.
Event-Driven Programming
The popularity of event-driven architectures comes from a simple fact: the imperative paradigm (executing commands step by step) doesn’t reflect how the real world works.
While most programs execute code in a linear flow, each step is explicitly invoked after the next one:
var order = CreateOrder();
SendNotification(order);
ChargePayment(order);
LogOrder(order);In the real world (and in many systems too), actions are triggered as a result of other actions.
var onEvent = _eventBroker.When<OrderCreated>();
onEvent.Execute((o) => SendNotification(o));
onEvent.Execute((o) => ChargePayment(o));
onEvent.Execute((o) => LogOrder(o));The simple concept of publishing events and responding with callbacks can serve as the foundation for an entire programming paradigm:
Event-Driven Programming (EDP) is a programming paradigm in which operations are triggered by changes in state of other objects.
Many things can trigger events: a user clicking a button, a payment succeeding, an object being added to a collection — essentially, anything that changes the system’s state.
Once you start noticing it, event-driven programming appears everywhere 😁.
However, applying an event-driven approach can also make the program harder to reason about. Instead of a clear sequence of function calls, behavior is spread across many event handlers, which can complicate debugging, make it harder to trace the flow of execution, and increase the cognitive load for developers.
Let’s take a closer look and see what it means in practice.
Core Concepts
At its core, event-driven programming models systems around the occurrence of events rather than direct method calls.
Something happens → An event is published → Callbacks are triggered.
In this paradigm, you can highlight the following components:
- event — a fact that something happened (
UserRegisteredEvent, etc) - event source (producer/publisher/emitter) — someone who produces an event
- event listener (consumer/subscriber/sink) — someone who reacts to the event
- event broker (event loop) — a tool through which the source and the listener exchange events
Event Communication Patterns
In event-driven systems, components need a way to produce and react to events. Several patterns organize this communication, each providing a different level of decoupling and control.
Let’s look at some common approaches.
Observer
Imagine you build a system that sends notifications when an order is created. Multiple parts of the system may be interested in this event — one component may send an email, another update the cache, and a third one writes logs.
A naive approach would be to call all these components directly from the order service.
public class OrderService
{
. . .
public void CreateOrder(string orderId)
{
var order = new Order();
_emailService.SendEmail(order);
_cacheService.UpdateCache(order);
_logger.AutidLog(order);
}
}But this tightly couples the order creation logic with every feature that needs to react to it. As the system grows, the code becomes harder to maintain.
To solve this, we can apply the Observer pattern. Publishes an event so anyone can subscribe to it and react when it happens.
public class OrderService
{
public event Action<Order> OrderCreated;
public void CreateOrder(string orderId)
{
var order = new Order();
OrderCreated?.Invoke(order);
}
}
public class EmailNotifier
{
public void OnOrderCreated(Order order)
{
. . .
}
}
------------------------------------------------------------------------
orderService.OrderCreated += _emailNotifier.OnOrderCreated;
orderService.OrderCreated += _cacheService.OnOrderCreated;
orderService.OrderCreated += _logger.OnOrderCreated;This way, the event producer does not need to know who is listening. New functionality can be added by simply creating a new event handler without modifying the existing code.
This pattern is widely used in UI frameworks like WinForms, HTML + JavaScript, or any MVP model, where the UI control and its event handler are naturally tied together. However, in the backend API server, where component state is renewed per request, maintaining persistent subscriptions across requests is difficult.
Mediator
Imagine you have multiple services in a system that need to communicate with each other in both directions. If each service directly triggers the others, you’d have multiple subscriptions and dependencies everywhere, and the system quickly becomes tangled and hard to maintain.
To address this, we can use the Mediator pattern. With it, all communication is centralized — services interact with each other indirectly through the mediator.
public interface IEventMediator
{
event Action<Order> OrderCreated;
void PublishOrderCreatedEvent(Order order);
event Action<Order> OrderUpdated;
void PublishOrderUpdatedEvent(Order order);
}
public class OrderService(IEventMediator _mediator)
{
public void CreateOrder(string orderId)
{
var order = new Order(orderId);
_mediator.PublishOrderCreatedEvent(order);
}
}
________________________________________________________________________
_mediator.OrderCreated += _emailNotifier.OnOrderCreated;
_mediator.OrderCreated += _cacheService.OnOrderCreated;
_mediator.OrderCreated += _logger.OnOrderCreated;Here, email, cache, or logging services do not know about the OrderService . The mediator handles all communication.
The always-on Mediator works well in backend systems, where components are often short-lived or stateless, because it is durable, centralizes interactions, and avoids the problem of maintaining persistent subscriptions.
Bus
If you want to take decoupling one step further, just like the mediator, you can implement an Event Bus to coordinate communication between components.
Get iamprovidence’s stories in your inbox
Join Medium for free to get updates from this writer.
With a mediator, there is a central object that knows how components interact. But an event bus removes even that knowledge. Components simply publish events, and any component that is interested can subscribe to them.
interface IEvent { }
class OrderCreatedEvent : IEvent { . . . }
class OrderUpdatedEvent : IEvent { . . . }
public interface IEventBus
{
void Subscribe<TEvent>(Action<TEvent> handler);
void Publish(IEvent e);
}
----------------------------------------------------------------------
// a component can publish an event:
_eventBus.Publish(new OrderCreatedEvent());
// and other components can subscribe to it:
_eventBus.Subscribe<OrderCreatedEvent>(e =>
{
// send email
});
_eventBus.Subscribe<OrderCreatedEvent>(e =>
{
// update metrics
});However, this flexibility comes with a trade-off. With an event bus, it becomes harder to track navigation in the codebase. Since there are no direct references between components, IDE tools cannot easily show the full flow of events. Understanding who publishes and who handles an event often requires additional investigation and clean code conventions.
Event Processing Patterns
With these core concepts in place, we can look at common patterns used in event-driven systems. These patterns solve recurring problems such as how events are delivered, how event flow is managed, and how event frequency is controlled.
Debouncing
Imagine you implement a typical search bar. Every time a user types a character, an event fires.
If we send a request to the API on every keystroke and rerender the results immediately, it becomes wasteful and can overload the system.
To optimize this, we can apply debouncing. It will trigger one event only when the user has stopped typing in the search bar for at least 500 milliseconds.
There are many similar scenarios where events fire very frequently, but the work they trigger is expensive — typing in an input field, resizing a window (where you could have thousand events per second), applying many filters, etc.
In such cases, debouncing delays execution of an event handler until a certain amount of time has passed without new events.
Throttling
Imagine you design a fighting game. Each time a user presses a button, it triggers an event.
Usually, players press buttons very rapidly to perform an attack. For example, 10 times within a second, while the character in the game is only able to perform one attack.
In such cases, it makes sense to throttle the events. Throttling ensures that the event is triggered once within a certain period.
There are many other scenarios where this technique is useful — such as rapid mouse clicks, users clicking a button multiple times instead of once, continuous scrolling, or rate limiting.
Buffering
Both debouncing and throttling reject excessive events to execute an operation once in a certain period.
However, sometimes you need to handle all the events. For example, your system processes images uploaded by users. Each time a user uploads an image, an event is generated to start processing it.
The thing is that processing an image takes time, and multiple users may upload images faster than the system can handle them.
In such cases, it makes sense to buffer the events. Instead of processing each event immediately, the system places events in a queue, allowing consumers to handle them at their own pace.
This approach ensures that consumers can handle the workload at a sustainable rate. Buffering is commonly used in systems where the event producer is faster than the consumer, such as video processing, order processing, or background task execution.
Batch Processing
Let’s say your component writes logs from all the events. Every time something happens, a log event is generated and persisted in a database.
If the system writes every log entry to the storage immediately, it may perform many small and expensive operations.
In such cases, it makes sense to apply batch processing. Instead of processing each event right away, the system handles a group of them.
For example, the application may collect log events for a few seconds, buffer them to a queue, and then write them to the database in a one batch.
This approach is useful to reduce the large number of operations and improve performance when many events occur within a short period of time.
Event Aggregation / Coalescing
It is common for a website analytics system to track how many users are viewing a page in real time. Every user action — like loading a page or clicking a link — generates an event.
If thousands of users interact at once, processing each event individually could overload the system.
In such cases, it makes sense to aggregate events. Instead of updating the dashboard for every single event, the system collects events over a short time window — say 1 second — combines them into one event, and then updates the total number of active users at once.
This approach reduces the number of events, improves performance, and still provides accurate data.
Personal Opinion🤔
Event-Driven Programming is a powerful paradigm that describes how code inside an application is structured; however, it is rarely used in isolation and is typically combined with other programming approaches when applied, if applied at all 😬.
Developers are generally more familiar with imperative programming, and most frameworks are designed to encourage that style. Even though, in C#, events exist as a language feature, they are somewhat obscure and rarely used, except in Windows Forms, while modern UI frameworks tend to rely on data binding instead.
Beign said that EDP still shines in user interfaces, games, monitoring, and other event-driven systems, where it keeps the UI responsive and prevents the application from freezing when actions are triggered.
It can also be applied in backend systems, particularly in Domain-Driven Design (DDD) or microservices architectures, to improve loose coupling, scalability, and performance.
Moreover, this simple code style inspired an entire architecture style, Event-Driven Architectures (EDA), where events coordinate actions across services.
Conclusion
Event-Driven Programming is a paradigm that focuses on reacting to events rather than executing instructions sequentially.
It promotes loose coupling and responsiveness. EDP helps manage complexity by decoupling components, enabling asynchronous processing, improving modularity and reusability, and allowing the system to react and scale more easily.
Here are some of the main advantages:
- loose coupling. components don’t need to know about each other. They only communicate through events.
- modularity: separating event generation from event handling allows components to be developed, tested, and maintained independently
- scalability
— producers don’t wait for consumers to finish, they emit an event and continue their work.
— multiple consumers can process the same event in parallel. - extensibility. you can add new features without changing existing components.
💬 Let me know in the comments how often you face the Event-Driven Paradigm?
👏 Clap if you find something new here
☕️ Support me if you want
✅ Follow
😉 And stay awesome









