Skip to main content
SEMastery

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.

11 min readUpdated October 10, 2025

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 await and get a result).
  • But underneath, it travels as messages through a broker.

Let us picture the flow.

A request goes out, the consumer does the work, and a response comes back.

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.

TopicDirect HTTP callRequest-response with MassTransit
CouplingCaller needs the exact URLCaller only needs the message type
Load balancingYou set it up yourselfBroker spreads work across consumers
RetriesManual codeBuilt into the broker and MassTransit
Many repliesHardEasy (you can await several response types)
Failure when busyRequest is lost or errorsRequest 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

Build
Send
Consume
Respond
Receive

Steps

1

Build

API builds CheckOrderStatus

2

Send

Client sends to broker

3

Consume

Consumer reads message

4

Respond

RespondAsync sends reply

5

Receive

API awaits the result

Each step a request takes from API to consumer and back.

And here is the same idea as a sequence diagram, showing time moving from top to bottom.

A sequence view of one request and its response over time.

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

Send
Wait
Reply?
Return
Timeout

Steps

1

Send

Request leaves the API

2

Wait

Timer starts (30s default)

3

Reply?

Did a response arrive?

4

Return

Yes: give result to caller

5

Timeout

No: throw RequestTimeoutException

What happens when a reply is or is not received in time.

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.

The caller waits, then branches based on which response type came back.

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.

SituationGood fit?Why
You need an answer right now to continueYesThe caller waits for the reply
Reading data from another serviceYesClean and reliable
Fire-and-forget notificationsNoUse publish-subscribe instead
Very long jobs (minutes)NoUse a saga or background job
Simple call inside one appNoA 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 RequestTimeoutException so 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.

The full setup: API with a request client, broker, and a consumer with a repository.

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 and await the reply.
  • The consumer uses context.RespondAsync(...) to send the answer back to the sender.
  • Register the client with AddRequestClient<T>() in Program.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

Related Patterns