Event-driven architecture is a powerful pattern for building scalable, responsive, and loosely coupled systems. In this article, we will look into event-driven architecture in C#. We’ll explore what the pattern is, its use cases, and its advantages through examples.

We’ll be using Docker to install and set up the RabbitMQ server in this article. So, the basic knowledge of Docker is a prerequisite.

To download the source code for this article, you can visit our GitHub repository.

With all that covered, let’s begin.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

What Is Event-Driven Architecture

Event-driven architecture (EDA) is an architectural pattern that revolves around the concept of events. Here, the asynchronous events define the flow of the system.

An event can be any occurrence in a system that causes a change of state. It can be a user action, a sensor reading, or an external trigger. The components of the system dispatch and consume these events. Thus, enabling loosely coupled and highly responsive architectures.

In EDA, we have three key components to cover.

Event Producers

Event producers are the source of events. These are the components that bring about the change of state. When something happens, like an IoT sensor sending an update to an automation system, an event producer generates an event to notify others about it.

Essentially, event producers are like messengers that create messages to send to everyone who’s subscribed.

Event Routers

When an event producer generates an event, it doesn’t necessarily know who should receive it. This is where event routers come in.

Event routers are intermediaries that receive events from producers and decide where to send them. They make sure the event reaches the correct consumer. They also act as a filter to ignore events that don’t satisfy certain criteria or rules defined within the architecture. Hence, reducing unimportant system overhead.

Event Consumers

Event consumers are the parts of our system that are interested in events.

They’re like subscribers or listeners waiting for something to occur. Consumers are the components that actually do something based on the events they receive.

They’re decoupled from producers and other consumers, thus they don’t need to know where the events came from or how other consumers are handling these events.

Event-Driven vs Request-Response and Pub-Sub

Now that we have an understanding of Event-Driven Architecture (EDA), let’s explore how it compares to two other similar architectural patterns: Request-Response and Publish-Subscribe (Pub-Sub).

EDA vs Request-Response

Request-Response is synchronous. As the name says, here, a client sends a request to a server, and the server processes that request and sends back a response.

The interaction is simple and direct and works for things like fetching data from a database or making an API call. However, it’s not as suited for complex scenarios where multiple components need to react to events independently.

Event-driven architecture (EDA), on the other hand, is an asynchronous, event-based communication system. It reacts to changes in the system state.

In EDA, events work like triggers that cause reactions in different parts of a system. Various components of the system generate and consume these events without being tightly coupled together. 

EDA vs Pub-Sub

Publish-Subscribe (Pub-Sub) is an architectural pattern often used in messaging systems. We have message publishers or pubs who send out information, and there are subscribers or subs who listen to the topics they’re subscribed to. 

The message is most important here. All of the subscribers get the same message. Pub-Sub is useful when multiple parts of a system need to know when something happens. However, it doesn’t cater to different types of messages.

Event-driven architecture acts as a superset of the Pub-Sub pattern. It uses Pub-Sub as a tool but covers more complex scenarios too. The focus isn’t solely on broadcasting one message to many.

An EDA system emphasizes reacting to a variety of events with different actions. We can have different consumers for different types of events. Thus, allowing for complex processing.

Now, let’s look into a practical example of implementing Event-Driven Architecture in C#. We’ll build a simple Home Appliances Automation System. In this system, we’ll use events to turn the lights on/off and to control the temperature of the thermostat.

Let’s start.

Prerequisites

Before we start, we need to set up a RabbitMQ server using Docker. We need the docker run command to spin up our server:

docker run -d --hostname rabbitmq --name rabbitmq-server -p 15672:15672 -p 5672:5672 rabbitmq:3-management

This initiates a RabbitMQ server container with the hostname “rabbitmq” and the container name “rabbitmq-server”.

We map the default RabbitMQ ports 5672 and 15672 from the host to the container, enabling external communication and access to the RabbitMQ Management Plugin interface.

We are using the specified Docker image, “rabbitmq:3-management” and running in detached mode. Thus, the container operates in the background allowing messaging with RabbitMQ through port mappings. 

Implementing Events in an Event-Driven Architecture

Events are the backbone of an EDA system. So, let’s start by defining the events (messages) that our system will use. We’ll define these events in a shared library as they need to be accessible from both the producers and the consumers.

Let’s create a new Class Library project named Events. In this project, we can define the events we will use in our application.

Let’s create a LightSwitchEvent record:

public record LightSwitchEvent(Guid CorrelationId, LightState State);

Here, we have a CorrelationId positional parameter that is a Guid. This acts as a unique identifier for every generated event to enable their tracking. The State positional parameter is an enumeration that contains the current state of the lights i.e. LightState.On or LightState.Off.

In our application, when a LightSwitchEvent is produced, it will contain a CorrelationId to uniquely identify the event and a State to indicate whether the lights should be turned on or off.

Consumers of this event will examine the State and act appropriately to control the lights.

Now, let’s create a ThermostatTempChangeEvent record:

public record ThermostatTempChangeEvent(Guid CorrelationId, decimal Temperature);

This event allows us to send information about changes in the thermostat’s temperature.

When a ThermostatTempChangeEvent is produced, it will contain a CorrelationId to uniquely identify the event and a Temperature to indicate what the updated temperature should be.

Implementing Consumers in an Event-Driven Architecture

With the events in place, let’s implement the subscribers (consumers) that will react to these events. We’ll have two subscribers listening to the two different types of events we created earlier.

Let’s create two Console Application projects named LightControlService and ThermostatControlService respectively. Next, we’ll need to add a couple of NuGet packages to these projects that we need to interact with RabbitMQ.

In the NuGet Package Manager Console, let’s install MassTransit:

Install-Package MassTransit

MassTransit works with message brokers and helps us implement distributed messaging patterns. It enables us to build an event-driven system that leverages a message broker like RabbitMQ in our case. 

Additionally, we need to install MassTransit.RabbitMQ:

Install-Package MassTransit.RabbitMQ

This is an extension package that allows us to integrate MassTransit with RabbitMQ.

Consumer for LightSwitchEvent

In the LightControlService project, let’s create a LightSwitchEventSubscriber class:

public class LightSwitchEventSubscriber : IConsumer<LightSwitchEvent>
{
    public async Task Consume(ConsumeContext<LightSwitchEvent> context)
    {
        var lightEvent = context.Message;
        var isSuccessful = await ControlLightsAsync(lightEvent);

        if (isSuccessful)
            Console.WriteLine($"Lights switched {lightEvent.State} successfully.");
        else
            Console.WriteLine($"Failed to control lights: {lightEvent.State}");
    }
}

This class implements the IConsumer<LightSwitchEvent> interface. The IConsumer interface comes from MassTransit, and we use it to mark consumers. We use it to define the type of messages our subscribers can handle.

The LightSwitchEventSubscriber class consumes and reacts to events of the LightSwitchEvent type. The Consume() method is the entry point for the incoming LightSwitchEvent messages. We extract the message and call the ControlLightsAsync() method to control the state of the lights.

Let’s create the ControlLightsAsync() method:

public static async Task<bool> ControlLightsAsync(LightSwitchEvent lightEvent)
{
    try
    {        
        await Task.Delay(TimeSpan.FromSeconds(2));

        if (lightEvent.State == LightState.On)
        {
            Console.WriteLine("Turning lights ON...");
        }
        else if (lightEvent.State == LightState.Off)
        {
            Console.WriteLine("Turning lights OFF...");
        }

        return true;
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Error controlling lights: {ex.Message}");

        return false;
    }
}

Here, we simulate a delay to represent network latency using Task.Delay(). Then, we check the state of the LightSwitchEvent and react to it.

Finally, let’s configure a MassTransit queue and assign LightSwitchEventSubscriber as the consumer of this queue:

static async Task Main()
{
    var busControl = Bus.Factory.CreateUsingRabbitMq(cfg =>
    {
        cfg.ReceiveEndpoint("lights", e =>
        {
            e.Consumer<LightSwitchEventSubscriber>();
        });
    });

    await busControl.StartAsync();

    Console.WriteLine("Light control service is running. Press any key to exit.");
    Console.ReadLine();

    await busControl.StopAsync();
}

In the Main() method of the Program class, we create an instance of a MassTransit bus control using Bus.Factory.CreateUsingRabbitMq().

This allows our application to connect to RabbitMQ and receive events. In the configuration, we name the queue “lights“. Additionally, we specify  LightSwitchEventSubscriber as the consumer for this queue. This means that the application is set up to consume and process events of the LightSwitchEvent type from the “lights” queue.

Now that we’ve seen how the LightSwitchEventSubscriber processes the LightSwitchEvent messages, let’s turn our attention to the next part of our Home Appliances Automation System: controlling the thermostat.

Consumer for ThermostatTempChangeEvent

Let’s create a class ThermostatEventSubscriber in the project ThermostatControlService:

public class ThermostatEventSubscriber : IConsumer<ThermostatTempChangeEvent>
{
    public async Task Consume(ConsumeContext<ThermostatTempChangeEvent> context)
    {
        var thermostatEvent = context.Message;

        var isSuccessful = await AdjustThermostatAsync(thermostatEvent);

        if (isSuccessful)
            Console.WriteLine($"Temperature changed to {thermostatEvent.Temperature}°C successfully.");
        else
            Console.WriteLine($"Failed to adjust thermostat to {thermostatEvent.Temperature}°C");
    }
}

Similar to the previous subscriber, ThermostatEventSubscriber implements the IConsumer<T> interface. It consumes the events of ThermostatTempChangeEvent type.

We extract the message and call  AdjustThermostatAsync() to change the temperature of the thermostat:

public static async Task<bool> AdjustThermostatAsync(ThermostatTempChangeEvent thermostatEvent)
{
    try
    {        
        await Task.Delay(TimeSpan.FromSeconds(2));

        Console.WriteLine($"Adjusting thermostat to {thermostatEvent.Temperature}°C...");

        return true;
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Error adjusting thermostat: {ex.Message}");

        return false;
    }
}

Finally, let’s tie everything up at the entry point of the ThermostatControlService application:

static async Task Main()
{
    var busControl = Bus.Factory.CreateUsingRabbitMq(cfg =>
    {
        cfg.ReceiveEndpoint("thermostat", e =>
        {
            e.Consumer<ThermostatEventSubscriber>();
        });
    });

    await busControl.StopAsync();

    Console.WriteLine("Thermostat control service is running. Press any key to exit.");
    Console.ReadLine();

    await busControl.StopAsync();
}

In the MassTransit configuration in the Main() method of the Program class, we name the queue “thermostat“. Next, we specify  ThermostatEventSubscriber as the consumer for this queue.

Now, the application is set up to consume and process events of the ThermostatTempChangeEvent type from the “thermostat” queue.

Both the subscribers LightSwitchEventSubscriber and ThermostatEventSubscriber can run independently. They can react to events as they occur and there’s no tight coupling between them.

So, now we have our events and a couple of consumers listening to those events. However, how do we trigger these events?

This is where the event producers come into the picture.

Implementing Producers in an Event-Driven Architecture

Producers generate and send events to RabbitMQ.

For our application, we’ll create a producer to simulate user interactions like turning lights on/off or adjusting the thermostat temperature.

Now, let’s create another Console Application project named Publisher.

Finally, let’s add the same NuGet packages that we added to the subscriber projects:

  • MassTransit
  • MassTransit.RabbitMQ

In the Program class, let’s create a ControlLights() method:

static async Task ControlLights(IBusControl busControl, LightState state)
{
    var lightEndpoint = await busControl.GetSendEndpoint(new Uri("rabbitmq://localhost/lights"));
    await lightEndpoint.Send<LightSwitchEvent>(new { State = state });

    Console.WriteLine($"Lights switched {state}");
}

The method enables us to send a LightSwitchEvent to control the state of the lights in our application. Let’s create a ControlThermostat() method:

static async Task ControlThermostat(IBusControl busControl, decimal newTemperature)
{
    var thermostatEndpoint = await busControl.GetSendEndpoint(new Uri("rabbitmq://localhost/thermostat"));
    await thermostatEndpoint.Send<ThermostatTempChangeEvent>(new { NewTemperature = newTemperature });

    Console.WriteLine($"Thermostat adjusted to {newTemperature}°C");
}

ControlThermostat() serves as a mechanism for simulating user interaction to adjust thermostat temperature.

Finally, in the Main() method let’s provide a way for the user to interact with the system:

static async Task Main()
{
    var busControl = Bus.Factory.CreateUsingRabbitMq();

    await busControl.StartAsync();

    Console.WriteLine("Home Automation system is running.");

    while (true)
    {
        Console.WriteLine("Choose an action:");
        Console.WriteLine("1. Turn Lights On");
        Console.WriteLine("2. Turn Lights Off");
        Console.WriteLine("3. Adjust Thermostat");

        var choice = Console.ReadLine();

        switch (choice)
        {
            case "1":
                await ControlLights(busControl, LightState.On);
                break;
            case "2":
                await ControlLights(busControl, LightState.Off);
                break;
            case "3":
                Console.WriteLine("Enter new thermostat temperature:");
                if (decimal.TryParse(Console.ReadLine(), out decimal newTemperature))
                {
                    await ControlThermostat(busControl, newTemperature);
                }
                else
                {
                    Console.WriteLine("Invalid temperature input.");
                }
                break;
            default:
                Console.WriteLine("Please select out of the given options.");
                break;
        }
    }
}

Here, we set up a simple UI and ask for numerical input allowing the user to enter 1 to switch on the lights, 2 to switch off the lights, and 3 to adjust the thermostat temperature to any desired value.

When we run the application, we can interact with the Home Appliances Automation System:

Choose an action:
1. Turn Lights On
2. Turn Lights Off
3. Adjust Thermostat
3
Enter new thermostat temperature:
22.7
Thermostat adjusted to 22.7°C

Event-Driven Architecture Across Platforms

So far, we’ve explored Event-Driven Architecture, including event producers, routers, and consumers. We implemented an example using RabbitMQ as the base of our event-driven application.

However, it’s essential to know that one of the key strengths of EDA is that it’s technology-agnostic. The core concept of EDA is about how systems produce and react to events, and it doesn’t depend on a specific messaging service. Hence, it is able to adapt to a wide array of messaging systems and platforms.

Let’s look at a few alternatives to RabbitMQ.

Apache Kafka

Apache Kafka is an event streaming platform that offers distributed, highly scalable, and fault-tolerant message processing.

It stores messages in ordered logs called topics and enables event-driven architectures. Additionally, it excels in scenarios where high throughput, low latency, and message retention are critical. Kafka can handle real-time data streams and event-driven systems efficiently.

Azure Service Bus

Azure Service Bus is an event-driven messaging service provided by Microsoft Azure.

It can be a preferred choice when building applications within the Azure ecosystem. It integrates seamlessly with various Azure services. Azure Service Bus provides features like message persistence, transaction support, and advanced message filtering. Hence, this makes it a robust choice for applications in a cloud-native environment.

When to Use Event-Driven Architecture

The primary use case of event-driven systems is in real-time applications. In applications like financial trading platforms responsiveness to market changes is essential. We treat every change in a stock’s price as an event and EDA can ensure that these events are processed as soon as they occur.

Another important use case for event-driven architecture is scalability. This is essential for systems facing unpredictable workloads like an e-commerce platform. EDA allows us to scale specific components independently, adding more consumers or producers as needed. This is helpful in handling increased traffic during sales without overburdening the entire system.

Additionally, EDA helps us process a large volume of data efficiently using parallel processing. It enables us to split a large processing task into smaller parallel tasks. As events occur, they can trigger processing tasks without waiting for others to complete. Hence, leading to a more responsive system.

The components of an event-driven system communicate through events but don’t need to be aware of each other’s functionalities. This decoupling simplifies maintenance and reduces the risk of introducing bugs. Thus, we can update one component without affecting the others.

An EDA system is also inherently fault-tolerant due to this decoupling. A failure in one component doesn’t necessarily disrupt the complete system. It allows us to introduce redundancy into our systems such that redundant components can seamlessly take over when others fail.

When Not to Use Event-Driven Architecture

The event-driven architecture is suited where components of the system react to changes in the system or events while considering the current state and context of the system. In cases where our application performs stateless operations like a simple request-response interaction that doesn’t rely on event coordination, we should prefer simpler architectural patterns. 

EDA is useful in environments where there are several producers and consumer components that react to multiple events. However, for an application that doesn’t involve multiple consumers responding to events, an EDA infrastructure can be an overkill. It might introduce unnecessary complexities to the system.

Monitoring is another aspect to pay attention to. EDA can make monitoring and debugging more challenging than a simple request-response architecture. It’s more difficult to track the flow of data in an event-driven architecture and hence, difficult to pinpoint the source of an issue.

A key consideration to be had in distributed systems is eventual consistency. It arises from the asynchronous and decoupled nature of EDA. In an event-driven system, there are various events processed independently and often at different times. This can lead to situations where every part of the system may not be immediately aware of data or state changes. In other words, the system might not be in a fully consistent state at every moment but will eventually be consistent over time. Thus, in situations where immediate consistency is critical, alternative architectural patterns that prioritize real-time synchronization may be more appropriate.

Conclusion

In this article, we explored Event-Driven Architecture (EDA) in C#. We looked into the core usage, advantages, limitations, and how this architectural pattern differs from other similar ones.

We also learned about its practical implementation using a simple Home Appliances Automation System.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!