Message Handlers

Message handlers are the components that receive and process a single message. There should be exactly one message handler per topic your service handles. The topic (and the request/response types) form the front-facing contract for the service — they're what's used to generate OpenAPI / AsyncAPI documentation, client code, etc. (see Spec).

Handlers support constructor dependency injection, so keep the handler itself thin and push business logic into an injected service.

IMessageHandler<TRequest, TResponse> / IMessageHandler<TRequest>

Defined in Benzene.Abstractions.MessageHandlers:

public interface IMessageHandler<TRequest, TResponse>
    : IMessageHandlerBase<TRequest, TResponse>
{}

public interface IMessageHandlerBase<TRequest, TResponse>
{
    Task<IBenzeneResult<TResponse>> HandleAsync(TRequest request);
}

public interface IMessageHandler<TRequest>
{
    Task HandleAsync(TRequest request);
}

See Message Results for everything about IBenzeneResult<T> and the available status factories — this page doesn't repeat that detail.

Request / response example

[HttpEndpoint("POST", "/orders")]
[Message("order:create")]
public class CreateOrderMessageHandler : IMessageHandler<CreateOrderMessage, OrderDto>
{
    private readonly IOrderService _orderService;

    public CreateOrderMessageHandler(IOrderService orderService)
    {
        _orderService = orderService;
    }

    public async Task<IBenzeneResult<OrderDto>> HandleAsync(CreateOrderMessage request)
    {
        return await _orderService.SaveAsync(request);
    }
}

Fire-and-forget (no response) example

[Message("order:archive")]
public class ArchiveOrderMessageHandler : IMessageHandler<ArchiveOrderMessage>
{
    private readonly IOrderService _orderService;

    public ArchiveOrderMessageHandler(IOrderService orderService)
    {
        _orderService = orderService;
    }

    public async Task HandleAsync(ArchiveOrderMessage request)
    {
        await _orderService.ArchiveAsync(request);
    }
}

[Message("topic")]

Defined in Benzene.Core.MessageHandlers:

[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class MessageAttribute : Attribute
{
    public MessageAttribute(string topic, string version = "");

    public string Version { get; }
    public string Topic { get; }
}

Applied once per handler class. Topic is the routing key used to look up the handler at request time (see Handler discovery below); Version is optional and lets multiple versions of a handler coexist for the same topic — IVersionSelector picks which version answers a given request.

[HttpEndpoint("METHOD", "/path")]

Defined in Benzene.Http:

[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class HttpEndpointAttribute : Attribute
{
    public HttpEndpointAttribute(string method, string url);

    public string Method { get; }
    public string Url { get; }
}

Maps an HTTP method + URL pattern (route parameters like /orders/{id} are supported) onto a message handler, for any HTTP-shaped transport (ASP.NET Core, AWS API Gateway). Unlike [Message], it can be applied multiple times to the same handler class to expose it under more than one route/method. It's discovered separately from [Message] by IHttpEndpointFinder (ReflectionHttpEndpointFinder, CacheHttpEndpointFinder, CompositeHttpEndpointFinder, ... — all in Benzene.Http), which is what HTTP transports use to route an inbound HTTP request to the right topic before handing off to the same message-handler pipeline every other transport uses.

A handler commonly carries both attributes together, so the same handler answers both an HTTP route and a direct topic dispatch (e.g. from a queue or another service):

[HttpEndpoint("GET", "/orders/{id}")]
[Message("order:get")]
public class GetOrderMessageHandler : IMessageHandler<GetOrderMessage, OrderDto>
{
    // ...
}

Handler discovery (IMessageHandlersFinder)

public interface IMessageHandlersFinder : IMessageDefinitionFinder<IMessageHandlerDefinition>
{}

New handlers are found automatically — you don't register them individually. Discovery is layered:

IMessageHandlerDefinitionLookUp (MessageHandlerDefinitionLookUp) is what actually answers "which handler serves this topic at request time": it merges every registered finder's definitions, groups by (topic, version), and uses IVersionSelector to pick the best-matching version when more than one exists for the same topic.

UseMessageHandlers(...) wires the assemblies/types you pass (or, if you pass none, AppDomain.CurrentDomain.GetAssemblies()) into AddMessageHandlers(...), which registers MessageHandlersList, DependencyMessageHandlersFinder, and a CompositeMessageHandlersFinder combining them (plus a CacheMessageHandlersFinder wrapping a ReflectionMessageHandlersFinder when you pass explicit types/assemblies) — all as part of one call.

.UseMessageHandlers(...)

Adds the routing middleware to a pipeline (Benzene.Core.MessageHandlers.MiddlewarePipelineExtensions):

// Scan the current AppDomain's assemblies
app.UseMessageHandlers();

// Scan specific assemblies/types, optionally configuring handler-pipeline middleware
app.UseMessageHandlers(typeof(CreateOrderMessageHandler).Assembly);
app.UseMessageHandlers(router => router.UseFluentValidation());
app.UseMessageHandlers(typeof(CreateOrderMessageHandler).Assembly, router => router.UseFluentValidation());

Every overload ultimately registers a MessageRouter<TContext> as an IMiddleware<TContext> in the pipeline. MessageRouter<TContext>.HandleAsync:

  1. Extracts the topic via IMessageGetter<TContext>. If it's missing, sets a ValidationError result ("Topic is missing") and returns — no handler lookup happens.
  2. Looks up the handler definition for that topic via IMessageHandlerDefinitionLookUp. If none is found, sets a NotFound result and returns.
  3. Creates the handler instance via IMessageHandlerFactory (resolving it from DI, wrapping it per IMessageHandlerWrapper, and building its own per-handler middleware pipeline — see Response handling below).
  4. Invokes the handler through an IDeferredRequestMapper (defers request-body mapping until the handler actually needs it) and sets the resulting IMessageHandlerResult on the context via IMessageHandlerResultSetter<TContext>.

The overload that accepts Action<MessageRouterBuilder> router lets you add middleware that runs per handler invocation, wrapped around the actual call — this is how .UseFluentValidation() plugs in: it registers an IHandlerMiddlewareBuilder that runs before MessageHandlerMiddleware<TRequest, TResponse> and short-circuits with a validation-failure result if the request fails validation, without ever reaching your handler code.

Request mapping (IRequestMapper<TContext>)

public interface IRequestMapper<in TContext>
{
    TRequest? GetBody<TRequest>(TContext context) where TRequest : class;
}

RequestMapper<TContext> (the low-level implementation, given an explicit ISerializer) resolves the request body two ways:

The default registered via AddContextItems(), MultiSerializerOptionsRequestMapper<TContext>, picks which ISerializer to use per request by asking the scoped IMediaFormatNegotiator<TContext> which registered IMediaFormat<TContext> applies (typically decided from the request's content-type header — e.g. negotiating between JSON and XML bodies) instead of always using the one default serializer. EnrichingRequestMapper<TContext> layers on IRequestEnricher<TContext> to merge extra context-derived fields into the deserialized request object.

Content negotiation (IMediaFormat<TContext> / IMediaFormatNegotiator<TContext>)

A single IMediaFormat<TContext> registration describes one wire format for both directions:

public interface IMediaFormat<TContext>
{
    string ContentType { get; }
    bool CanRead(TContext context, IServiceResolver serviceResolver);
    bool CanWrite(TContext context, IServiceResolver serviceResolver);
    ISerializer GetSerializer(IServiceResolver serviceResolver);
}

The scoped, memoizing IMediaFormatNegotiator<TContext> (MediaFormatNegotiator<TContext>) picks the format to read the request with (SelectRead — first registered format whose CanRead matches, typically via content-type, falling back to the process default, JSON) and to write the response with (SelectWrite — first format whose CanWrite matches, typically via accept, falling back to whatever SelectRead picked). AcceptHeaderMediaFormatBase<TContext> (Benzene.Core.MessageHandlers) is the base class for this header-negotiated shape — Benzene.Xml's XmlMediaFormat<TContext> is the only built-in example beyond the JSON default. Every transport calls services.AddMediaFormatNegotiation<TContext>() to register the negotiator and the default JSON format for its context type.

Response handling

Once your handler returns an IBenzeneResult<TResponse>, a chain of IResponseHandler<TContext>s (registered via AddContextItems()/AddBenzeneMessage()) turns it into whatever the transport needs:

IMessageHandlerResultSetter<TContext> is the seam between MessageRouter<TContext> and all of this — it's what actually stores the IMessageHandlerResult on the context so the response handlers (and diagnostics, e.g. ActivityMiddlewareDecorator's benzene.handler tag — see Middleware) can read it afterwards.

See also