Request-Response Messaging Pattern With MassTransit in .NET
Learn the request-response messaging pattern with MassTransit in .NET using IRequestClient, timeouts, and multiple response types with simple examples.
Request-Response Messaging Pattern With MassTransit
Imagine you go to your favourite restaurant. You sit down, call the waiter, and say "One masala dosa, please." The waiter writes your order, walks to the kitchen, and comes back later with your hot dosa. You did not cook it yourself. You just asked and then waited for the answer.
That is exactly what the request-response pattern is in software. One service asks another service for something, then waits for a reply. The first service does not need to know how the work is done. It only cares about getting an answer.
In this post we will learn how to do this in .NET using MassTransit, a popular library for working with message brokers like RabbitMQ and Azure Service Bus. We will keep things simple and friendly, with small code examples you can follow step by step.
What does "request-response" really mean?
Most of us have used a normal method call. You call a method, it runs, and it gives you back a value. Easy.
But what if the code that does the work lives in a completely different application, maybe on another server? You cannot just call its method directly. You need to send it a message and wait for a message back.
The request-response pattern gives you the best of both worlds:
- It feels like a normal method call (you
awaitand get a result). - But underneath, it travels as messages through a broker.
Let us picture the flow.
The API service never talks to the Order Consumer directly. The broker sits in the middle and carries the messages. This keeps the two services loosely coupled. One can restart, scale up, or move to a new machine, and the other does not care.
Why not just call an HTTP API?
Good question. You can call another service over HTTP. So why use messaging?
Here is a simple comparison.
| Topic | Direct HTTP call | Request-response with MassTransit |
|---|---|---|
| Coupling | Caller needs the exact URL | Caller only needs the message type |
| Load balancing | You set it up yourself | Broker spreads work across consumers |
| Retries | Manual code | Built into the broker and MassTransit |
| Many replies | Hard | Easy (you can await several response types) |
| Failure when busy | Request is lost or errors | Request waits safely in a queue |
HTTP is great for simple cases. But when you have many services that must talk reliably, messaging shines. The message sits safely in a queue even if the consumer is busy or briefly offline.
A real example: checking an order status
Let us build a tiny example. A web API needs to check the status of an order. The order data lives in another service. We will send a request and wait for the reply.
Step 1: Define the messages
First, we define two simple message types. One for the request, one for the response. In MassTransit, messages are just plain C# records or classes.
// The request we send
public record CheckOrderStatus
{
public Guid OrderId { get; init; }
}
// The reply we get back
public record OrderStatusResult
{
public Guid OrderId { get; init; }
public string Status { get; init; } = string.Empty;
public DateTime LastUpdated { get; init; }
}Notice there is no special base class. These are just records. MassTransit handles all the wrapping and routing for you.
Step 2: Write the consumer
The consumer is the code that receives the request and sends back a reply. Think of it as the kitchen in our restaurant story.
public class CheckOrderStatusConsumer : IConsumer<CheckOrderStatus>
{
private readonly IOrderRepository _orders;
public CheckOrderStatusConsumer(IOrderRepository orders)
{
_orders = orders;
}
public async Task Consume(ConsumeContext<CheckOrderStatus> context)
{
var order = await _orders.FindAsync(context.Message.OrderId);
// Send the reply straight back to whoever asked
await context.RespondAsync(new OrderStatusResult
{
OrderId = order.Id,
Status = order.Status,
LastUpdated = order.UpdatedAt
});
}
}The key line is context.RespondAsync(...). This sends the response back to the original sender. MassTransit knows the return address because it was added to the request message automatically.
Step 3: Register everything
Now we wire up MassTransit in Program.cs. We tell it about our consumer and ask it to create a request client for our request type.
builder.Services.AddMassTransit(x =>
{
x.AddConsumer<CheckOrderStatusConsumer>();
// Create a request client for this message type
x.AddRequestClient<CheckOrderStatus>();
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host("localhost", "/", h =>
{
h.Username("guest");
h.Password("guest");
});
cfg.ConfigureEndpoints(context);
});
});AddRequestClient<CheckOrderStatus>() is the important part. It registers an IRequestClient<CheckOrderStatus> that we can inject anywhere.
Step 4: Send the request and await the reply
Finally, in our API endpoint, we inject the request client and use it.
app.MapGet("/orders/{id}/status", async (
Guid id,
IRequestClient<CheckOrderStatus> client) =>
{
Response<OrderStatusResult> response =
await client.GetResponse<OrderStatusResult>(
new CheckOrderStatus { OrderId = id });
return Results.Ok(response.Message);
});Look how clean this is. We call GetResponse<OrderStatusResult>(...) and await it. It feels just like a normal async method, even though a real message went out to a broker and came back.
Note: in prose, a route like GET /orders/{id}/status is written in backticks so the curly braces do not confuse the page.
How the whole thing flows
Let us walk through the journey of one request from start to finish.
Life of a request
Steps
Build
API builds CheckOrderStatus
Send
Client sends to broker
Consume
Consumer reads message
Respond
RespondAsync sends reply
Receive
API awaits the result
And here is the same idea as a sequence diagram, showing time moving from top to bottom.
The whole trip usually takes only a few milliseconds on a healthy system. But the magic is that the API code just sees a simple await.
Timeouts: do not wait forever
What if the consumer is down? Or the order service is very slow? You do not want your API stuck waiting forever.
MassTransit protects you. By default, every request has a timeout of 30 seconds. If no reply arrives in that time, MassTransit throws a RequestTimeoutException. Your code can catch it and return a friendly error.
You can change the timeout in two ways.
Per client, when registering:
x.AddRequestClient<CheckOrderStatus>(RequestTimeout.After(s: 10));Per request, when calling:
var response = await client.GetResponse<OrderStatusResult>(
new CheckOrderStatus { OrderId = id },
timeout: RequestTimeout.After(s: 5));Here is how to handle a timeout safely in an endpoint.
try
{
var response = await client.GetResponse<OrderStatusResult>(
new CheckOrderStatus { OrderId = id });
return Results.Ok(response.Message);
}
catch (RequestTimeoutException)
{
return Results.StatusCode(504); // Gateway Timeout
}This is a small but important habit. Always plan for the case where the answer never comes.
Timeout decision
Steps
Send
Request leaves the API
Wait
Timer starts (30s default)
Reply?
Did a response arrive?
Return
Yes: give result to caller
Timeout
No: throw RequestTimeoutException
Awaiting more than one kind of answer
Sometimes the consumer might reply with different outcomes. Maybe the order exists and you get a status. Or maybe the order is not found at all. MassTransit lets you wait for several response types at once.
First, define a second response type for the "not found" case.
public record OrderNotFound
{
public Guid OrderId { get; init; }
}The consumer decides which one to send back.
public async Task Consume(ConsumeContext<CheckOrderStatus> context)
{
var order = await _orders.FindAsync(context.Message.OrderId);
if (order is null)
{
await context.RespondAsync(new OrderNotFound
{
OrderId = context.Message.OrderId
});
return;
}
await context.RespondAsync(new OrderStatusResult
{
OrderId = order.Id,
Status = order.Status,
LastUpdated = order.UpdatedAt
});
}Now the caller can wait for either reply and react to whichever one arrives.
var response = await client.GetResponse<OrderStatusResult, OrderNotFound>(
new CheckOrderStatus { OrderId = id });
if (response.Is(out Response<OrderStatusResult>? status))
{
return Results.Ok(status.Message);
}
if (response.Is(out Response<OrderNotFound>? _))
{
return Results.NotFound();
}
return Results.StatusCode(500);This is very powerful. The caller does not have to guess. It simply handles each possible answer. You can wait for up to three response types this way using the built-in overloads. For more than three, you use the lower-level RequestHandle and Task.WhenAny.
Here is a small state view of how the caller reacts.
When should you use this pattern?
Request-response is great, but it is not always the right tool. Use this table as a quick guide.
| Situation | Good fit? | Why |
|---|---|---|
| You need an answer right now to continue | Yes | The caller waits for the reply |
| Reading data from another service | Yes | Clean and reliable |
| Fire-and-forget notifications | No | Use publish-subscribe instead |
| Very long jobs (minutes) | No | Use a saga or background job |
| Simple call inside one app | No | A normal method call is simpler |
A good rule: if the caller must wait for the result before it can move on, request-response fits well. If the caller can keep going without an answer, publish an event instead.
A note on licensing (important in 2026)
You should know one thing before you build on MassTransit. The library changed its licensing.
- MassTransit v8 stays open-source under the Apache 2.0 license. It will get security patches and critical bug fixes through at least the end of 2026.
- MassTransit v9 moves to a commercial license. Small organizations (under one million USD revenue) may qualify for a free tier. Other companies pay a minimum of around 400 USD per month, with higher tiers for larger enterprises.
So for learning and many existing projects, v8 is still free. For new long-term projects, check the license terms with your team first. This same shift happened to a few other popular .NET libraries around the same time, so it is good to stay aware.
Common mistakes to avoid
A few friendly warnings from real projects:
- Forgetting to register the request client. If you skip
AddRequestClient<T>(), injection fails at runtime. - No timeout handling. Always catch
RequestTimeoutExceptionso a slow consumer does not freeze your API. - Using request-response for everything. It adds waiting. For events that others just need to know about, publish instead.
- Heavy work in the consumer. Keep the consumer fast. If the job takes minutes, use a different pattern so you do not block the caller.
- Sharing one message type for unrelated requests. Give each request its own clear type. It keeps routing simple and readable.
Putting it all together
Here is the big picture once more, with everything connected.
Every arrow you see is just a message moving. The API stays clean. The consumer stays focused. The broker keeps them apart yet connected. That separation is what makes large systems easier to grow and maintain.
Quick recap
- The request-response pattern lets one service ask another for an answer and wait for it, like ordering food and waiting for the dish.
- In MassTransit you use
IRequestClient<T>to send the request andawaitthe reply. - The consumer uses
context.RespondAsync(...)to send the answer back to the sender. - Register the client with
AddRequestClient<T>()inProgram.cs. - The default timeout is 30 seconds; always handle
RequestTimeoutException. - You can wait for several response types (up to three with the built-in overloads), which is perfect for "found" vs "not found".
- Use this pattern when the caller must wait for a result; use publish-subscribe for events.
- MassTransit v8 is still open-source; v9 is commercial, so check licensing for new projects.
References and further reading
- Requests — MassTransit Documentation
- IRequestClient Reference — MassTransit
- Request-Response Messaging Pattern With MassTransit — Milan Jovanovic
- MediatR and MassTransit Going Commercial: What This Means For You — Milan Jovanovic
- MassTransit v9 Announcement
Related Patterns
Implementing the Saga Pattern with MassTransit in .NET
Learn the Saga pattern in .NET with MassTransit state machines — states, events, correlation, persistence, retries, and compensation, explained in simple, friendly steps.
MassTransit with RabbitMQ and Azure Service Bus: Is It Worth a Commercial License?
MassTransit went commercial in v9. See how it works with RabbitMQ and Azure Service Bus, what the new license costs, and whether it is worth paying for.
Using MassTransit with RabbitMQ and Azure Service Bus in .NET
Learn how MassTransit lets one set of .NET code run on both RabbitMQ and Azure Service Bus, with simple consumers, publishers, and config examples.
Event-Driven Microservices with Azure Service Bus in .NET
A friendly, step-by-step guide to building event-driven microservices in .NET using Azure Service Bus topics, subscriptions, and the ServiceBusProcessor.
MassTransit Outbox Pattern with EF Core and MongoDB in .NET
Learn the transactional outbox pattern in .NET using MassTransit with EF Core and MongoDB so your database and message broker never fall out of sync.
The Idempotent Consumer Pattern in .NET (And Why You Need It)
A friendly .NET guide to the idempotent consumer pattern: stop duplicate messages from double-charging customers using message ids, transactions, and EF Core.