- Building an Event Driven .NET Application: The Fundamentals
- Building an Event Driven .NET Application: Setting Up MassTransit and RabbitMQ
- Building an Event Driven .NET Application: Integration Testing Your MassTransit Message Bus
The purpose of this series is to go through and in-depth walkthrough of setting up an event driven architecture in a distributed .NET application.
This is the first post in the series where we’ll lay the foundation for your event driven knowledge moving forward.
An Event Driven Architecture (EDA) is used in distributed applications to pass messages around using events to send a notification some kind. This notification will signify that some significant state change has occurred so that other applications can react to that state change in whatever way is required by the business. For example, say a customer changes their address in an application, that application might send out a
CustomerAddressUpdated event and any other application that needs to react to that address change can do so.
Some applications take this a step further to an Event Sourcing pattern where the current state of an entity is derived entirely from the history of events that have happened to it over time. This post will be going in an Event Driven Architecture as oppose to an Event Sourcing Architecture.
You can check out a full talk on this by Martin Fowler if you'd like to hear more about the overall idea of these different patterns.
Let's cover some core concepts within a EDAs to lay a foundation for how things are working.
A message is a piece of information that we want to distribute to one or more locations. The content of the message can be anything and they can generally be categorized into events or commands depending on how they are being used. Commands are used in the imperative to tell a subscriber to do something like
UpdateAddress, where events are used in the past-participle to say that something occurred like
A message broker is a distinct piece of infrastructure that accepts incoming messages and routes them to any applications that have expressed interest in each message. You can think of this like a post office getting delivered mail and then bringing it to the designated address listed on the envelope or package. Common brokers are RabbitMQ and Kafka.
A publisher (a.k.a. producer) is an application that sends messages to the broker.
A subscriber (a.k.a. consumer) is an application that connects to the broker, expresses interest in one or more types of messages, and can then get those message from the broker as they become available.
A message bus is a library to help manage the publishing and subscribing interactions between the message broker and client applications. Some common service bus libraries in the .NET space are MassTransit, NServiceBus, Brighter, and Rebus.
You can think of these as the roads that the message bus will use to transport the message to the broker. In this mental model, the connection might be a road and the channels might be different lanes on the road.
Connections are a TCP link between the client application that handles major networking responsibilities, including authentication. Channels are lightweight virtual connections within a TCP connection that are used to transport messages to and from the message broker.
The implication here is that we can leave a TCP connection open and the lightweight channels can be created and deleted as needed without requiring us to go through the additional handshake work that comes with setting up a TCP connection.
CloudAMQP has a good writeup on their blog that goes into a bit more detail if you're interested.
So we have a publishing application that will send a message to the message broker for distribution to any subscribers that may be interested. In order to send this message out, we'll need to tell our message broker how we want to distribute those messages.
In order to understand this process, we're going to break the message broker into three important parts.
An exchange is where a message starts out in the broker and are in charge of routing incoming messages to the queue based on the rules defined by
exchange type passed in with the message. An exchange can be thought of as the post office of the broker.
Next, we have queues. A queue stores messages that have been passed to it from the exchange and are the part of the broker that our subscribers are actually subscribed to.
A binding is a relationship between an exchange and a queue. This can be simply read as: the queue is interested in messages from this exchange. These bindings have a unique identifier called a
binding key that the exchange can use when routing messages to particular queues. It's usually a good practice to send a single exchange type through them (more on those later).
It's worth noting that you can bind two exchanges together as well if needed (e.g. load balancing, sharing messages between exchanges, wire tapping, saving messages). See this writeup by CloudAMQP for more details.
So given that breakdown, our message will come into the broker and start at an exchange (the post office portion of our broker), but how does the exchange know which queue(s) to send the message to? Well, it's going to check the message metadata for a few things:
- Exchange Type: The exchange type will tell the exchange what type of distribution method it should use for this message.
- Routing Key: The routing key is a period (
.) delimited list of words that can be used to direct a message to one or more queues based on their binding key.
- Header Attributes: Contains attributes that are used to direct message routing when the message designates a
An exchange will start out by checking the
exchange type to see how it should route the incoming message. Let's look at the details for each exchange type.
fanout exchange type will tell the exchange to route messages to all of the queues bound to it.
For example, let's say we have a "checkout" exchange that has a "shipping" queue and an "inventory" queue bound to it. If the exchange receives a message with a
fanout exchange type, this message will be sent to both the "inventory" and the "shipping" queues.
direct exchange type will tell the exchange to route messages to the queue whose binding key matches the routing key that was sent with the message exactly.
For example, let's say our publishing application produces a message with a
direct exchange type and a
shipping.us.tv routing key. When the exchange gets this message, it will check for queue bindings that have a binding key (the identifier for an exchange-queue binding) that matches
topic exchange type is similar to the
direct type, but lets us check for a partial match between the routing key and the binding key. You can use a
* (asterisk) as a wildcard for a single word and a
# (hash) can substitute for zero or more words.
So in the
direct exchange example,
shipping.us.tv was only routed to the queue that had that exact binding, but if we used
shipping.*.tv the message would be duplicated and routed to all queues that started with
shipping and ended with
tv. So say we had a few more queues, the message would be routed to
shipping.ca.tv,but not to
shipping.us.shoes. Alternatively, if we has a routing key of
shipping.# the message would also go to
shipping.us.shoes because it also starts with
header exchange behaves very similar to the
topic exchange type, but ignore the routing key and use header metadata to figure out where to route the message instead. Because a
header exchange type is not limited a string like the
topic exchange type, it can be used as a more powerful type that routes using other data types like integers or hashes.
A long with a list of key value pairs, a special argument named
x-match can be sent in the header and assigned a value of
all being the default). When using
all, all the header key value pairs much match and when using
any, at least one of the key value pairs much match. Headers can also be built using various other operators to add additional rules to this matching pattern.
So in a
header example, let's say the binding between the exchange and one of the delivery queues has an
all and two key value pairs —
from: Atlanta and
to: Atlanta. In order for a message to get routed to this queue, it needs to have an
x-match value of
all and both of the key value pairs as well.
default exchange is another way of using the
direct exchange type also known as a nameless exchange and is unique to RabbitMQ and is not found in other brokers.
When using the
default exchange type (an exchange type with an empty string value, hence the 'nameless exchange' nickname), your message will be delivered to the queue with a name equal to the routing key of the message. Every queue is automatically bound to the default exchange with a routing key which is the same as the queue name.
RabbitMQ also has the concept of a
Dead Letter Exchange which silently drops messages that can't be routed to a queue by the broker. There are extensions to capture and handle these messages instead of dropping them and many buses give you the capability to handle these messages (e.g. MassTransit Dead-Letter Pipe)
- An application publishes a message to a message broker.
- The message will arrive in the exchange portion of the message broker.
- The exchange will check the configuration on the incoming message.
- Based on the configuration, the exchange will route the message to the appropriate queue based using whatever matching binding have been established to connect the queues and the exchange.
- The message will stay in the queue until it is picked up by a consumer.
- One or more subscribing applications will check the queue and consume the message when ready.
In the next post of the series, we’ll actually see how we can implement each of these exchanges in a .NET 5 web api using MassTransit and RabbitMQ.
Regardless, I hope this was helpful! I'd love to hear your thoughts on Twitter @pdevito3.
RabbitMQ is the message broker that we're going to be working with in the next post. There's some great resources below if you want to get some additional insight.