Composing a service from the core

Almost every Benzene service — in any language — is built from the same handful of shapes. None of them is a framework feature you switch on; each is a direct consequence of the core model: topics, message handlers, the middleware pipeline, results, and per-invocation scopes. This guide names those shapes so you can recognise them, and points at where each one is defined normatively.

Everything below is language-neutral. How you express a pattern — the attribute, the registration call, the package name — is documented in each port's own docs; the shape is the same everywhere.

A handler per topic

The unit of application logic is a message handler: a function from a request to a result, identified by a topic.

handle : TRequest -> Result<TResponse>

One topic maps to at most one handler, so a service is naturally a set of small, independent handlers rather than one branching entry point. A handler never sees the transport — the same order:create handler serves an HTTP request, a queue message, or a function invocation unchanged. Keep handlers this way and the transport becomes a deployment decision, not a rewrite.

When you need a second implementation of the same message — a breaking change to how order:create behaves — give it a topic version rather than a new topic id or an if inside the handler. Routing selects the implementation; the caller's topic id is unchanged. (A payload's schema version is a different concept — it is upcast/downcast transparently without a second handler. See versioning.md.)

Cross-cutting concerns as middleware

Anything that wraps every handler — authentication, logging, tracing, validation, error mapping — is a middleware component, not code copied into each handler. Middleware runs in registration order, outermost first, and each component chooses whether to call next.

That single choice is the whole mechanism behind two everyday patterns:

Because the middleware signature carries no transport and no cancellation parameter (those ride on the context), the same middleware composes identically on every transport.

Results, not exceptions

A handler returns a result — a value with a status, an isSuccessful flag, an optional payload, and errors — it does not throw for domain failures. "Order not found" is a returned not-found result, not an exception.

This keeps failure in the type you already return, so every caller and every middleware handles it the same way, and the transport adapter maps the status to the transport's native failure signal (an HTTP code, a gRPC status) by the tables in wire-contracts.md. Exceptions are reserved for the genuinely unexpected: an uncaught one is caught by the framework, turned into an unexpected-error-class result, and MUST NOT crash the adapter.

Per-invocation scope for request-scoped state

A fresh DI scope is created per pipeline invocation, and scoped services live and die with it. That gives you a clean home for anything that belongs to one message — a unit of work, a correlation value, an accumulator a middleware fills and the handler reads — without threading it through every call or reaching for a static.

The scope boundary is the message, precisely: a batch delivery (say ten queue messages at once) is one invocation, and one scope, per message, so request-scoped state never leaks from one message to the next.

A transport-neutral core behind a thin edge

The patterns above add up to one arrangement: handlers, middleware, and their dependencies know nothing about the transport, and a transport adapter at the edge converts a native event into a context, runs the pipeline exactly once, and converts the result back. The adapter is the only part that knows whether it is HTTP, gRPC, a queue, or a function.

Two conventions keep this honest:

The pay-off is the reason to hold the line on all of the above: a service composed this way moves between transports and vendors by changing the adapter at the edge, not the logic in the middle.