Response as Event

Turn a request/response handler's response payload into a follow-up event — e.g. an SQS-triggered order:create handler returns an OrderCreated payload, and the pipeline broadcasts it on order:created.

Problem Statement

On a queue transport there is no reply channel: whatever payload your handler returns is dropped after the message is acknowledged. But that payload is often exactly the event the rest of your system wants — "the order was created, here it is." Publishing it by hand from inside the handler works, but couples the handler to messaging concerns and repeats the same boilerplate in every handler.

UseResponseEvents (in Benzene.ResponseEvents) does this declaratively: the handler stays a pure request/response handler (reusable on HTTP, where the same payload is the response body), and the pipeline decides that on this transport, the response becomes an event.

Prerequisites

Installation

dotnet add package Benzene.ResponseEvents

Step-by-Step Implementation

1. Keep the handler a plain request/response handler

[Message("order:create")]
public class CreateOrderHandler : IMessageHandler<CreateOrderRequest, OrderCreated>
{
    private readonly IOrderRepository _orders;

    public CreateOrderHandler(IOrderRepository orders)
    {
        _orders = orders;
    }

    public async Task<IBenzeneResult<OrderCreated>> HandleAsync(CreateOrderRequest request)
    {
        var order = await _orders.CreateAsync(request);
        return BenzeneResult.Created(new OrderCreated(order.Id, order.Total));
    }
}

2. Route the event topic outbound

The published event goes through the normal outbound routing, so it gets startup validation, retry, and correlation/trace header stamping like any other send:

services.UsingBenzene(x => x
    .AddOutboundRouting(routing => routing
        .Route("order:created", pipeline => pipeline
            .UseCorrelationId()
            .UseSns(configuration["ORDER_EVENTS_TOPIC_ARN"]))));

3. Map the response to the event, per pipeline

app.UseSqs(sqs => sqs
    .UseMessageHandlers(router => router
        .UseResponseEvents(events => events
            .Map("order:create", "order:created"))));

That's it. After CreateOrderHandler returns a successful result with a payload, the payload is published on order:created. On any other pipeline hosting the same handler (say, HTTP) nothing changes — the mapping belongs to the SQS pipeline only.

Configuration Options

Conditional publishing

By default a mapping fires on any successful result that carries a payload. Pass when: to narrow it:

.UseResponseEvents(events => events
    .Map("order:create", "order:created",
         when: result => result.Status == BenzeneResultStatus.Created))

Declaring the payload type (specs) and reshaping the payload

Map<TPayload> declares the event's payload type — which also surfaces the event in generated AsyncAPI / event-service specs — and can project the response into a different event shape:

.UseResponseEvents(events => events
    .Map<OrderCreated>("order:create", "order:created",
         project: order => new OrderCreatedNotification(order.Id)))

Returning null from project skips the publish for that message.

CRUD naming convention

MapCrudConvention() adds one rule covering every CRUD topic on the pipeline: X:create/X:update/X:delete handled with status Created/Updated/Deleted publishes the payload on X:created/X:updated/X:deleted:

.UseResponseEvents(events => events.MapCrudConvention())

Custom rules

Anything the built-ins can't express is an IResponseEventMapping implementation added via events.Add(new MyMapping()) — a mapping receives the topic and the handler's full result and returns the publication (topic + payload) or null.

Publish failure behavior

By default (PublishFailureMode.FailMessage) a failed publish replaces the handler's response with an UnexpectedError, so the transport reports the message as failed and the queue redelivers it. This is at-least-once delivery: the handler re-runs on redelivery, so the handler and the event's consumers must be idempotent (see Idempotency). If the event is best-effort, opt out:

.UseResponseEvents(events => events
    .Map("order:create", "order:created")
    .OnPublishFailure(PublishFailureMode.LogAndContinue))

Introspection

Every mapping is plain data. Resolve IResponseEventCatalog to see exactly what the service republishes, across all pipelines:

var catalog = resolver.GetService<IResponseEventCatalog>();
foreach (var mapping in catalog.Mappings)
{
    Console.WriteLine(mapping.Description);   // e.g. "order:create -> order:created (OrderCreated)"
}

Mappings declared with Map<TPayload> also appear in generated specs: the catalog is registered as an IMessageDefinitionFinder<IMessageDefinition>, the seam spec builders read published-event declarations from.

Catching a forgotten mapping

A request/response handler that returns a payload but runs on a fire-and-forget transport with no mapping silently drops that payload — the classic "I wrote IMessageHandler<CreateOrder, OrderCreated>, deployed it on SQS, and my event vanished" mistake. An opt-in startup diagnostic surfaces it. Call it once after wiring, e.g. against the resolved container:

var gaps = serviceResolver.LogUnmappedResponseHandlers();   // logs a warning per gap, returns them
// or, to decide yourself:
foreach (var gap in serviceResolver.FindUnmappedResponseHandlers())
    Console.WriteLine(gap.Description);

Each ResponseEventGap names the handler, topic, and response type of a response-returning handler no mapping covers. It's advisory, never throws — because a Benzene handler is transport-agnostic, the same handler legitimately returns its response as the reply over HTTP and (maybe) drops it over SQS, and registration alone can't tell which you meant. Treat the list as "did I forget a mapping?": map the ones whose response should become an event, ignore any topic served only over HTTP/gRPC. To gate CI, fail when the (filtered) list is non-empty.

Swapping the publisher

The middleware publishes through the IResponseEventPublisher port; the default implementation sends via IBenzeneMessageSender. Replace the scoped registration to publish differently — a test fake, a custom fan-out, or an outbox relay:

services.AddScoped<IResponseEventPublisher, MyOutboxPublisher>();

Gotchas