Message Results

Every message handler returns its outcome wrapped in an IBenzeneResult<T> (or IBenzeneResult for handlers with no payload) instead of throwing for expected failure cases. The result carries a status, a success flag, the payload (on success), and error messages (on failure). Build one with the static BenzeneResult factory (Benzene.Results) — you should not need to implement IBenzeneResult<T> yourself.

IBenzeneResult / IBenzeneResult<T>

Defined in Benzene.Abstractions.Results:

public interface IBenzeneResult
{
    string Status { get; }
    bool IsSuccessful { get; }
    object PayloadAsObject { get; }
    IReadOnlyList<BenzeneError> Errors { get; }
}

public interface IBenzeneResult<T> : IBenzeneResult
{
    T Payload { get; }
}

Status is a plain string (see BenzeneResultStatus below) — not a .NET enum — which is what lets transport-specific status mappers (HTTP status codes, SQS acknowledgement, ...) key off it without a hard dependency on Benzene.Results itself.

Errors (Benzene.Abstractions.Results) is a list of BenzeneError { Message, Field?, Code? }, not bare strings — Message is the human-readable text, Field/Code are populated by whichever producer built the error (a validation integration, or your own handler) when it has something structured to say; both are null for a plain BenzeneResult.NotFound("Order 123 not found")-style message. A success result's Errors is always an empty list, never null. BenzeneError.ToString() returns Message, so a pre-existing string.Join(", ", result.Errors) still compiles and produces the same text; result.ErrorMessages() (Benzene.Results) does the same projection as an explicit string[]. See Problem documents below for where Field/Code end up on the wire, and Fluent Validation / Data Annotations for which integration populates what.

BenzeneResult factory

Static factory methods on Benzene.Results.BenzeneResult, each with a generic <T> overload (for handlers with a payload) and a non-generic overload (for IMessageHandler<TRequest>/Void payloads):

BenzeneResult.Ok(new OrderDto());          // BenzeneResult.Ok<T>()  also available (default payload)
BenzeneResult.Created(new OrderDto());     // BenzeneResult.Created<T>()
BenzeneResult.Accepted(new OrderDto());    // BenzeneResult.Accepted<T>() / BenzeneResult.Accepted()
BenzeneResult.Updated(new OrderDto());     // BenzeneResult.Updated<T>()
BenzeneResult.Deleted(new OrderDto());     // BenzeneResult.Deleted<T>()
BenzeneResult.Ignored<OrderDto>();         // BenzeneResult.Ignored()

BenzeneResult.NotFound<OrderDto>("Order 123 not found");
BenzeneResult.BadRequest<OrderDto>("Invalid request");
BenzeneResult.ValidationError<OrderDto>("Name is required");
BenzeneResult.Forbidden<OrderDto>();
BenzeneResult.Unauthorized<OrderDto>();
BenzeneResult.Conflict<OrderDto>();
BenzeneResult.ServiceUnavailable<OrderDto>();
BenzeneResult.NotImplemented<OrderDto>();
BenzeneResult.UnexpectedError<OrderDto>("Something went wrong");

All the error-style factories (NotFound, BadRequest, ValidationError, Forbidden, Unauthorized, Conflict, ServiceUnavailable, NotImplemented, UnexpectedError) accept params string[] errors and produce IsSuccessful == false. There's also a lower-level escape hatch, BenzeneResult.Set(status, ...), for a custom status string that isn't one of the built-ins — used internally (e.g. MessageRouter<TContext> sets validation-error/not-found results this way when a topic is missing or unmatched). BenzeneResult.SetFailed<T>(status, errors) is the equivalent for a custom failure status (no payload) — see Using an application-defined status below before reaching for either.

BenzeneResultExtensions

Benzene.Results.BenzeneResultExtensions adds Is*() checks (IsOk, IsCreated, IsNotFound, IsValidationError, etc.) mirroring every status, plus .As<TOutput>(...) helpers for remapping a result's payload type while preserving its status/success/errors — handy when adapting one handler's result to another shape. It also has HttpStatusCode.Convert() / Convert<T>() extensions that go the other direction: turning a raw HttpStatusCode (e.g. from an outbound HTTP call inside a handler) into an IBenzeneResult/IBenzeneResult<T>.

Problem documents (RFC 9457)

Whenever a result is unsuccessful, the payload it would have carried is replaced on the wire by a problem document — an RFC 9457 "Problem Details" object, the same shape on every transport. Benzene.Results.ProblemDetails is the wire type:

public class ProblemDetails
{
    public string? Type { get; set; }
    public string? Title { get; set; }
    public int? Status { get; set; }
    public string? Detail { get; set; }
    public string? Instance { get; set; }
    public string? BenzeneStatus { get; set; }
    public BenzeneError[]? Errors { get; set; }
}

You never build this by hand for the ordinary case — BenzeneResult.NotFound(...), .ValidationError(...), and every other failure factory get one for free. Every optional member is omitted from the wire, not emitted as null, when it doesn't apply — most visibly Status, which only ever appears on an HTTP-bound response (an envelope over a queue or a direct invocation never carries a fabricated HTTP status number). A validation-error produced by a FluentValidation failure and served over HTTP looks like this on the wire:

{
  "type": "https://benzene.app/problems/validation-error",
  "title": "Validation failed",
  "status": 422,
  "detail": "Name must not be empty, Age must be greater than 0",
  "benzeneStatus": "validation-error",
  "errors": [
    { "message": "Name must not be empty", "field": "/name", "code": "NotEmptyValidator" },
    { "message": "Age must be greater than 0", "field": "/age", "code": "GreaterThanValidator" }
  ]
}

DefaultResponsePayloadMapper builds this document via ProblemTypes.From(result) for every failed result automatically; HTTP-facing transports additionally fill in status from the same mapper that decides the actual HTTP response code, so the two can never disagree (see Transport mapping below). SerializerResponseRenderer also rewrites the response content-type to application/problem+json (or application/problem+xml) on failure.

Returning a rich problem with BenzeneResult.Problem

The registry covers the ordinary case. For a handler that wants to return something the registry can't express — a custom type outside it, an instance URI identifying this specific occurrence, or extension members via a ProblemDetails subclass — build the document yourself and hand it to BenzeneResult.Problem(...):

public class OutOfStockProblem : ProblemDetails
{
    public int AvailableQuantity { get; set; }
}

public async Task<IBenzeneResult<OrderDto>> HandleAsync(CreateOrderMessage request)
{
    var available = await _stock.AvailableAsync(request.Sku);
    if (available < request.Quantity)
    {
        return BenzeneResult.Problem<OrderDto>(new OutOfStockProblem
        {
            Type = "https://example.com/problems/out-of-stock",
            Title = "Not enough stock",
            Detail = $"Only {available} of '{request.Sku}' left.",
            Instance = $"urn:order-sku:{request.Sku}",
            BenzeneStatus = BenzeneResultStatus.Conflict,
            AvailableQuantity = available,
        });
    }

    return await _orderService.SaveAsync(request);
}

problem.BenzeneStatus is required — it becomes the result's Status (and therefore drives every transport mapping below), so BenzeneResult.Problem(...) throws ArgumentException naming the fix if it's missing. The result this returns is always unsuccessful, and the document you passed in is attached to it verbatim (not re-derived from the registry) — retrieve it with GetProblem() below. There's also a non-generic BenzeneResult.Problem(problem) for IMessageHandler<TRequest> handlers.

Reading a problem document back — GetProblem()

GetProblem(this IBenzeneResult result) (Benzene.Results) is the one API you need on the reading side — whether the result came from your own handler's BenzeneResult.Problem(...), from a Benzene client's failure response, or from an ordinary BenzeneResult.NotFound(...) you never attached a document to:

ProblemDetails problem = result.GetProblem();

It never returns null — there's no TryGetProblem twin. If the result carries a deliberately-attached document (built via BenzeneResult.Problem(...), or received over the wire by a Benzene client, which attaches exactly what it deserialized) you get that document back verbatim; otherwise one is synthesized on the spot via ProblemTypes.From(result). Callers don't need to know, or care, which case they're in.

BenzeneResultStatus

A static class of string constants (Benzene.Results), not a .NET enum:

public static class BenzeneResultStatus
{
    public const string Accepted = "accepted";
    public const string Ok = "ok";
    public const string Created = "created";
    public const string Updated = "updated";
    public const string Deleted = "deleted";
    public const string Ignored = "ignored";
    public const string NotFound = "not-found";
    public const string BadRequest = "bad-request";
    public const string ValidationError = "validation-error";
    public const string ServiceUnavailable = "service-unavailable";
    public const string NotImplemented = "not-implemented";
    public const string UnexpectedError = "unexpected-error";
    public const string Conflict = "conflict";
    public const string Forbidden = "forbidden";
    public const string Unauthorized = "unauthorized";
}

Using an application-defined status

Status being a plain string means an application can use statuses beyond the built-ins — BenzeneResult.Set("quarantined", payload) builds one. Know what you're opting into:

Transport mapping

HTTP

Benzene.Http's DefaultHttpStatusCodeMapper (IHttpStatusCodeMapper) maps every BenzeneResultStatus value onto an HTTP status code; an unrecognized status defaults to 200 when the result's IsSuccessful is true, else 500 (a null status is always 500):

Status HTTP code
ok, ignored 200
created 201
accepted 202
updated, deleted 204
bad-request 400
unauthorized 401
forbidden 403
not-found 404
conflict 409
validation-error 422
unexpected-error, or unmapped + IsSuccessful == false 500
unmapped + IsSuccessful == true 200
not-implemented 501
service-unavailable 503

HttpStatusCodeResponseHandler<TContext> applies this mapping to the HTTP response via IBenzeneResponseAdapter<TContext>. On success, SerializerResponseRenderer<TContext> (see Message Handlers) serializes Payload; on failure, it serializes the problem document described above — so a BenzeneResult.NotFound<OrderDto>("Order 123 not found") becomes an HTTP 404 with an application/problem+json body describing the error, not the (empty) OrderDto payload. On every HTTP-facing context (Benzene.AspNet.Core, API Gateway v1/v2, the Lambda/Azure/Google Cloud HTTP hosts), the problem document's status member is filled in from this same mapping, so the body's status and the response line's HTTP code can never disagree.

Async/event transports — settlement (ack/nack/checkpoint)

For queues, streams, and event triggers there is no synchronous HTTP status to return to a caller; instead the result's IsSuccessful flag decides whether the message is settled (acked/completed/ checkpointed) or redelivered (nacked/abandoned/left for retry). Each transport's result-setter records the outcome on the context's IHasMessageResult.MessageResult, and the transport's application/worker reads that back to settle the message.

As of the 1.0 settlement contract, every queue-shaped transport is safe by default: a returned failure result (IsSuccessful == false, e.g. validation-error/not-found/service-unavailable) — or an unset/null result, e.g. an unroutable message no handler matched — is redelivered (at-least-once), exactly like an unhandled exception, rather than being silently settled. The two self-hosted stream workers (Benzene.Kafka.Core, Benzene.Azure.EventHub) are the deliberate exception and default to at-most-once. The full per-transport table — the default on a returned failure result and the exact opt-in/opt-out knob for each (SqsOptions.BatchFailureMode, SnsOptions.RaiseOnFailureStatus, ServiceBusOptions.AckMode, CommitOnlyOnSuccess, …) — is the single source of truth in the Capability Matrix. Because a redelivered message re-runs the handler, any handler on an at-least-once transport must be idempotent — see Idempotency.

Two representative examples:

See also