Messaging Patterns

Learn the three core messaging patterns in Mocha - events (pub/sub), send (fire-and-forget), and request/reply - and when to use each one.

Messaging patterns

Every message you send answers one of three questions: "Who needs to know?" - an event, broadcast to anyone who cares. "Who should act?" - a command, directed at a single handler. "What is the result?" - a request that blocks until the handler replies. Choosing the wrong pattern is the most common messaging architecture mistake. This page explains each pattern, when to use it, and the anti-pattern to avoid.

PatternBus methodHandler interfaceDelivery
Event (pub/sub)PublishAsyncIEventHandler<TEvent>One-to-many: all subscribers receive a copy
Request (send)SendAsyncIEventRequestHandler<TRequest>One-to-one: a single handler processes it
Request/ReplyRequestAsyncIEventRequestHandler<TRequest, TResponse>One-to-one: sender awaits a typed response

Events (pub/sub)

Events represent something that happened. The publisher does not know or care who receives the event - that question is answered by whoever subscribes. Zero, one, or many handlers can react to the same event type. If no handler is registered, the event is silently discarded.

This implements the Publish-Subscribe Channel pattern.

Naming convention: Name events using noun-verb past tense. OrderPlaced, PaymentCompleted, UserRegistered. The past tense signals that something already happened - the publisher is not directing anyone to act.

Publish an event and handle it

By the end of this section, you will have two independent handlers both processing the same published event.

Define the event

C#
namespace MyApp.Messages;
public sealed record OrderPlacedEvent
{
public required Guid OrderId { get; init; }
public required string CustomerId { get; init; }
public required decimal TotalAmount { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
}

Implement two handlers

C#
using Mocha;
namespace MyApp.Handlers;
// Handler 1: Create an invoice when an order is placed
public class BillingHandler(ILogger<BillingHandler> logger)
: IEventHandler<OrderPlacedEvent>
{
public async ValueTask HandleAsync(
OrderPlacedEvent message,
CancellationToken cancellationToken)
{
logger.LogInformation(
"Creating invoice for order {OrderId}, amount {Amount}",
message.OrderId,
message.TotalAmount);
// Create invoice logic here
await Task.CompletedTask;
}
}
// Handler 2: Send a notification when an order is placed
public class NotificationHandler(ILogger<NotificationHandler> logger)
: IEventHandler<OrderPlacedEvent>
{
public async ValueTask HandleAsync(
OrderPlacedEvent message,
CancellationToken cancellationToken)
{
logger.LogInformation(
"Sending confirmation to customer {CustomerId} for order {OrderId}",
message.CustomerId,
message.OrderId);
// Send email/SMS logic here
await Task.CompletedTask;
}
}

Each handler implements IEventHandler<OrderPlacedEvent>. Both receive the same event independently. If one handler fails, the other still processes its copy.

Register and publish

C#
builder.Services
.AddMessageBus()
.AddEventHandler<BillingHandler>()
.AddEventHandler<NotificationHandler>()
.AddInMemory();

From an endpoint, publish the event through the injected bus:

C#
app.MapPost("/orders", async (IMessageBus bus) =>
{
await bus.PublishAsync(new OrderPlacedEvent
{
OrderId = Guid.NewGuid(),
CustomerId = "customer-42",
TotalAmount = 149.99m,
CreatedAt = DateTimeOffset.UtcNow
}, CancellationToken.None);
return Results.Ok();
});

Expected output:

info: MyApp.Handlers.BillingHandler[0]
Creating invoice for order 3f2504e0-4f89-11d3-9a0c-0305e82c3301, amount 149.99
info: MyApp.Handlers.NotificationHandler[0]
Sending confirmation to customer customer-42 for order 3f2504e0-4f89-11d3-9a0c-0305e82c3301

Both handlers execute. The order of execution is not guaranteed.

How to chain events across services

A common pattern is for a handler to publish a new event after completing its work. This creates an event chain that coordinates multiple services without coupling them.

C#
public class OrderPlacedEventHandler(
BillingDbContext db,
IMessageBus messageBus,
ILogger<OrderPlacedEventHandler> logger) : IEventHandler<OrderPlacedEvent>
{
public async ValueTask HandleAsync(
OrderPlacedEvent message,
CancellationToken cancellationToken)
{
logger.LogInformation(
"Order placed: {OrderId}, creating invoice",
message.OrderId);
// Create and process payment...
// Publish a downstream event
await messageBus.PublishAsync(
new PaymentCompletedEvent
{
PaymentId = Guid.NewGuid(),
OrderId = message.OrderId,
Amount = message.TotalAmount,
PaymentMethod = "CreditCard",
ProcessedAt = DateTimeOffset.UtcNow
},
cancellationToken);
}
}

The billing service handles OrderPlacedEvent and publishes PaymentCompletedEvent. A shipping service can subscribe to PaymentCompletedEvent without knowing about billing. Each service reacts to events it cares about.

Send (fire-and-forget)

A send represents an instruction directed at a specific handler. Unlike events, a send has exactly one handler. The sender knows what it wants done but does not wait for a typed response - the message either succeeds or faults.

This implements the Command Message pattern.

Naming convention: Name send messages using verb-noun present tense. ReserveInventory, ProcessPayment, ScheduleShipment. The imperative form signals intent - you are telling a specific service what to do.

Use send for fire-and-forget operations: reserving inventory, scheduling a job, triggering a side effect in another service.

Warning

Send in disguise. If your "event" expects exactly one handler to take action, it should be a send. Use SendAsync, not PublishAsync. Publishing a message that requires a single specific handler breaks the semantic contract of events and makes the system harder to reason about.

Send a message and handle it

By the end of this section, you will send a message to a single handler and verify it executes.

Define the message

C#
namespace MyApp.Messages;
public sealed record ReserveInventoryCommand
{
public required Guid OrderId { get; init; }
public required Guid ProductId { get; init; }
public required int Quantity { get; init; }
}

Implement the handler

C#
using Mocha;
namespace MyApp.Handlers;
public class ReserveInventoryCommandHandler(
AppDbContext db,
ILogger<ReserveInventoryCommandHandler> logger)
: IEventRequestHandler<ReserveInventoryCommand>
{
public async ValueTask HandleAsync(
ReserveInventoryCommand request,
CancellationToken cancellationToken)
{
logger.LogInformation(
"Reserving {Quantity} units of product {ProductId} for order {OrderId}",
request.Quantity,
request.ProductId,
request.OrderId);
var product = await db.Products
.FirstOrDefaultAsync(p => p.Id == request.ProductId, cancellationToken);
if (product is null)
{
throw new InvalidOperationException(
$"Product {request.ProductId} not found");
}
product.StockQuantity -= request.Quantity;
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Reserved {Quantity} units. Remaining stock: {Remaining}",
request.Quantity,
product.StockQuantity);
}
}

IEventRequestHandler<TRequest> (single type parameter) handles the command without returning a value. If the handler throws, the bus generates a fault.

Register and send

C#
builder.Services
.AddMessageBus()
.AddRequestHandler<ReserveInventoryCommandHandler>()
.AddInMemory();

Send the command:

C#
await bus.SendAsync(new ReserveInventoryCommand
{
OrderId = Guid.NewGuid(),
ProductId = productId,
Quantity = 3
}, cancellationToken);

Expected output:

info: MyApp.Handlers.ReserveInventoryCommandHandler[0]
Reserving 3 units of product a1b2c3d4-... for order e5f6a7b8-...
info: MyApp.Handlers.ReserveInventoryCommandHandler[0]
Reserved 3 units. Remaining stock: 97

SendAsync completes after the message is dispatched to the transport. It does not wait for the handler to finish processing.

How to wait for command acknowledgment

When you need confirmation that the handler processed the command, use RequestAsync instead of SendAsync. Mocha sends an automatic AcknowledgedEvent when the handler completes.

C#
// Fire-and-forget: returns after dispatch
await bus.SendAsync(command, cancellationToken);
// Wait for acknowledgment: returns after the handler completes
await bus.RequestAsync(command, cancellationToken);

The RequestAsync overload that accepts object (no IEventRequest<T> constraint) waits for the handler's acknowledgment without expecting a typed response. If the handler throws, RequestAsync throws a ResponseTimeoutException.

Request/Reply

Request/reply is for when the sender needs a typed response. The sender dispatches a request, Mocha routes it to a handler, the handler returns a response, and Mocha delivers the response back to the sender. The entire round trip completes within a single await.

This implements the Request-Reply pattern.

When you call RequestAsync, Mocha creates a temporary reply address and embeds it in the request envelope. The handler reads the reply address from the envelope and sends the response back to it. A correlation ID links the request and response across the transport. This is what makes ResponseTimeoutException possible - if the reply never arrives at the temporary address, the timeout fires.

Send a request and await a response

By the end of this section, you will send a request to a handler and use the typed response.

Define the request and response

The request record must implement IEventRequest<TResponse>. This marker interface tells Mocha the expected response type and enables compile-time correlation.

C#
using Mocha;
namespace MyApp.Messages;
public sealed record ProcessRefundCommand : IEventRequest<ProcessRefundResponse>
{
public required Guid OrderId { get; init; }
public required decimal Amount { get; init; }
public required string Reason { get; init; }
public required string CustomerId { get; init; }
}
public sealed record ProcessRefundResponse
{
public required Guid RefundId { get; init; }
public required Guid OrderId { get; init; }
public required decimal Amount { get; init; }
public required bool Success { get; init; }
public string? FailureReason { get; init; }
public required DateTimeOffset ProcessedAt { get; init; }
}

Implement the handler

C#
using Mocha;
namespace MyApp.Handlers;
public class ProcessRefundCommandHandler(
BillingDbContext db,
ILogger<ProcessRefundCommandHandler> logger)
: IEventRequestHandler<ProcessRefundCommand, ProcessRefundResponse>
{
public async ValueTask<ProcessRefundResponse> HandleAsync(
ProcessRefundCommand request,
CancellationToken cancellationToken)
{
logger.LogInformation(
"Processing refund of {Amount} for order {OrderId}",
request.Amount,
request.OrderId);
// Process refund logic...
return new ProcessRefundResponse
{
RefundId = Guid.NewGuid(),
OrderId = request.OrderId,
Amount = request.Amount,
Success = true,
ProcessedAt = DateTimeOffset.UtcNow
};
}
}

IEventRequestHandler<TRequest, TResponse> requires TRequest : IEventRequest<TResponse>. The return value is sent back to the caller's reply endpoint. The return value must not be null - if you return null, the consumer throws an InvalidOperationException.

Register and request

C#
builder.Services
.AddMessageBus()
.AddRequestHandler<ProcessRefundCommandHandler>()
.AddInMemory();

Send the request and use the response:

C#
var response = await bus.RequestAsync(
new ProcessRefundCommand
{
OrderId = orderId,
Amount = 49.99m,
Reason = "Defective product",
CustomerId = "customer-42"
},
cancellationToken);
Console.WriteLine($"Refund {response.RefundId}: {response.Amount:C}, success={response.Success}");

Expected output:

info: MyApp.Handlers.ProcessRefundCommandHandler[0]
Processing refund of 49.99 for order e5f6a7b8-...
Refund d4c3b2a1-...: $49.99, success=True

How to handle timeouts

If the handler does not respond within the configured timeout, RequestAsync throws a ResponseTimeoutException. You can catch this and handle it:

C#
try
{
var response = await bus.RequestAsync(
new ProcessRefundCommand
{
OrderId = orderId,
Amount = 49.99m,
Reason = "Defective product",
CustomerId = "customer-42"
},
cancellationToken);
// Use response...
}
catch (ResponseTimeoutException ex)
{
logger.LogWarning(
"Refund request timed out for order {OrderId}: {Message}",
orderId,
ex.Message);
// Retry, fall back, or notify the user
}

Common causes of timeouts: the handler service is not running, the handler registration is missing, or the handler is taking longer than the timeout window.

How to use IEventRequest<TResponse> correctly

The IEventRequest<TResponse> marker interface connects the request type to its response type at compile time. This enables two things:

  1. Type-safe RequestAsync. The compiler infers TResponse from the request parameter, so bus.RequestAsync(request) returns ValueTask<ProcessRefundResponse> without you specifying the type.
  2. Handler constraint. IEventRequestHandler<TRequest, TResponse> requires TRequest : IEventRequest<TResponse>, preventing mismatched request/response pairings at compile time.
C#
// The compiler infers TResponse = ProcessRefundResponse
// because ProcessRefundCommand : IEventRequest<ProcessRefundResponse>
var response = await bus.RequestAsync(refundCommand, cancellationToken);

If your request record does not implement IEventRequest<TResponse>, you cannot use the typed RequestAsync<TResponse> overload.

When to use which pattern

QuestionEvent (PublishAsync)Send (SendAsync)Request/Reply (RequestAsync)
How many handlers?Zero or moreExactly oneExactly one
Does the sender need a response?NoNoYes
Does the sender know who handles it?NoYes (by routing)Yes (by routing)
Does the sender wait for processing?NoNoYes
Handler interfaceIEventHandler<T>IEventRequestHandler<T>IEventRequestHandler<T, TRes>

Use events when multiple parts of the system need to react to something that happened. The publisher does not care who listens or what they do with the event. Examples: order placed, payment completed, user signed up.

Use send when you need to tell a specific service to do something but do not need a result back. The sender knows the operation should happen but does not need to wait for it. Examples: reserve inventory, send an email, schedule a cleanup job.

Use request/reply when the sender needs data back or needs to know whether the operation succeeded with details. The call blocks until the response arrives. Examples: process a refund and get the refund ID, look up product details, validate an address.

See also

Runnable examples: EventPubSub, SendFireAndForget, RequestReply

Full demo: The Demo application uses all three patterns: Demo.Catalog publishes OrderPlacedEvent (pub/sub), Demo.Billing handles ProcessRefundCommand (send), and sagas use RequestAsync for request/reply coordination.

Ready to implement these patterns? See Handlers and Consumers.

Last updated on April 13, 2026 by Michael Staib