Building distributed applications might seem simple at first. It's just servers talking to each other. Right?
However, it opens a set of potential problems you must consider. What if the network has a hiccup? A service unexpectedly crashes? You try to scale, and everything crumbles under the load? This is where the way your distributed system communicates becomes critical.
Traditional synchronous communication, where services call each other directly, is inherently fragile. It creates tight coupling, making your whole application vulnerable to single points of failure.
To combat this, we can turn to distributed messaging (and introduce an entirely different set of problems, but that's a story for another issue).
One powerful tool for achieving this in the .NET world is MassTransit.
In this week's issue, we'll explore MassTransit's implementation of the request-response pattern.
Request-Response Messaging Pattern Introduction
Let's start by explaining how the request-response messaging pattern works.
The request-response pattern is just like making a traditional function call but over the network. One service, the requester, sends a request message and waits for a corresponding response message. This is a synchronous communication approach from the requester's side.
The good parts:
- Loose Coupling: Services don't need direct knowledge of each other, only of the message contracts. This makes changes and scaling easier.
- Location Transparency: The requester doesn't need to know where the responder is located, leading to improved flexibility.
The bad parts:
- Latency: The overhead of messaging adds some additional latency.
- Complexity: Introducing a messaging system and managing the additional infrastructure can increase project complexity.
Request-Response Messaging With MassTransit
MassTransitsupports the request-response messaging pattern out of the box. We can use a request client to send requests and wait for a response. The request client is asynchronous and supports the await
keyword. The request will also have a timeout of 30 seconds by default, to prevent waiting for the response for too long.
Let's imagine a scenario where you have an order processing system that needs to fetch an order's latest status. We can fetch the status from an Order Management service. With MassTransit, you'll create a request client to initiate the process. This client will send a GetOrderStatusRequest
message onto the bus.
public record GetOrderStatusRequest
{
public string OrderId { get; init; }
}
On the Order Management side, a responder (or consumer) will be listening for GetOrderStatusRequest
messages. It receives the request, potentially queries a database to get the status, and then sends a GetOrderStatusResponse
message back onto the bus. The original request client will be waiting for this response and can then process it accordingly.
public class GetOrderStatusRequestConsumer : IConsumer<GetOrderStatusRequest>
{
public async Task Consume(ConsumeContext<GetOrderStatusRequest> context)
{
// Get the order status from a database.
await context.ResponseAsync<GetOrderStatusResponse>(new
{
// Set the respective response properties.
});
}
}
Getting User Permissions In a Modular Monolith
Here's a real-world scenario where my team decided to implement this pattern. We were building a modular monolith, and one of the modules was responsible for managing user permissions. The other modules could call out to the Users module to get the user's permissions. And this works great while we are still inside a monolith system.
However, at one point we needed to extract one module into a separate service. This meant that the communication with the Users module using simple method calls would no longer work.
Luckily, we were already using MassTransit and RabbitMQ for messaging inside the system.
So, we decided to use the MassTransit request-response feature to implement this.
The new service will inject an IRequestClient<GetUserPermissions>
. We can use it to send a GetUserPermissions
message and await a response.
A very powerful feature of MassTransit is that you can await more than one response message. In this example, we're waiting for a PermissionsResponse
or an Error
response. This is great, because we also have a way to handle failures in the consumer.
internal sealed class PermissionService(
IRequestClient<GetUserPermissions> client)
: IPermissionService
{
public async Task<Result<PermissionsResponse>> GetUserPermissionsAsync(
string identityId)
{
var request = new GetUserPermissions(identityId);
Response<PermissionsResponse, Error> response =
await client.GetResponse<PermissionsResponse, Error>(request);
if (response.Is(out Response<Error> errorResponse))
{
return Result.Failure<PermissionsResponse>(errorResponse.Message);
}
if (response.Is(out Response<PermissionsResponse> permissionResponse))
{
return permissionResponse.Message;
}
return Result.Failure<PermissionsResponse>(NotFound);
}
}
In the Users module, we can easily implement the GetUserPermissionsConsumer
. It will respond with a PermissionsResponse
if the permissions are found or an Error
in case of a failure.
public sealed class GetUserPermissionsConsumer(
IPermissionService permissionService)
: IConsumer<GetUserPermissions>
{
public async Task Consume(ConsumeContext<GetUserPermissions> context)
{
Result<PermissionsResponse> result =
await permissionService.GetUserPermissionsAsync(
context.Message.IdentityId);
if (result.IsSuccess)
{
await context.RespondAsync(result.Value);
}
else
{
await context.RespondAsync(result.Error);
}
}
}
Closing Thoughts
By embracing messaging patterns with MassTransit, you're building on a much sturdier foundation. Your .NET services are now less tightly coupled, giving you the flexibility to evolve them independently and weather those inevitable network glitches or service outages.
The request-response pattern is a powerful tool in your messaging arsenal. MassTransit makes it remarkably easy to implement, ensuring that requests and responses are delivered reliably.
We can use request-response to implement communication between modules in a modular monolith. However, don't take this to the extreme, or your system might suffer from increased latency.
Start small, experiment, and see how the reliability and flexibility of messaging can transform your development experience.
That's all for this week. Stay awesome!
P.S. Whenever you're ready, there are 3 ways I can help you:
Modular Monolith Architecture (NEW): Join 450+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
Pragmatic Clean Architecture: Join 2,700+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
Patreon Community: Join a community of 1,050+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.