Message handlers

A message handler is the one component in a Benzene service that holds your logic. It takes a typed request, returns a typed result, and knows nothing about the transport that delivered the request — no net/http, no status codes, no queue SDK. Everything else on this page exists to get a request to the right handler and its result back out again: topics name what a handler serves, the registry binds a topic to a handler, the App lifecycle wires it all together at startup, the container supplies a handler's dependencies, and the router resolves a topic to its handler at request time.

These are the language-neutral Benzene concepts, defined once for every port on the website — see Core concepts for the full model. This page won't re-explain them; it shows the Go shape and the exact symbols that implement each one. The getting-started guide walks the same pieces end-to-end as a runnable service; if you haven't read it, start there.

The handler shape

A Benzene handler is a plain function from a request to a benzene.Result[T]. It's a benzene.Handler[TReq, TRes] (registry.go):

type Handler[TReq, TRes any] func(ctx context.Context, req TReq) Result[TRes]

That's the whole contract. TReq and TRes are ordinary Go types — typically structs with JSON tags so a transport binding can decode a request body into TReq and marshal the TRes payload back out. The handler returns a value, not an error: benzene.Ok(...) for success and a status constructor like benzene.BadRequest[T](...) for a client failure. See Message results for the full status vocabulary and the benzene.Result[T] factories (benzene.Ok, benzene.CreatedResult, benzene.NotFound, benzene.Conflict, benzene.ValidationError, benzene.ServiceUnavailable, benzene.UnexpectedError, …) — this page doesn't repeat that detail.

type greetRequest struct {
	Name string `json:"name"`
}

type greetResponse struct {
	Greeting string `json:"greeting"`
}

func greetHandler(_ context.Context, req greetRequest) benzene.Result[greetResponse] {
	if req.Name == "" {
		return benzene.BadRequest[greetResponse]("name is required")
	}
	return benzene.Ok(greetResponse{Greeting: "Hello, " + req.Name + "!"})
}

Because a handler is just a function value of the right type, you can write it as a top-level function (as above) and convert it at the registration site with benzene.Handler[greetRequest, greetResponse](greetHandler). The context.Context parameter carries cancellation and deadlines, and — once the router has run — the invocation's DI scope, so a handler resolves scoped dependencies from it rather than taking them as extra parameters (see Dependency injection below).

Topics

A topic is the stable routing key a handler is bound to. Every transport — HTTP route, queue message, service-to-service envelope — resolves to a topic, and a topic resolves to exactly one handler. It's a small value type (topic.go):

type Topic struct {
	ID      string
	Version string
}

func NewTopic(id string) Topic            // unversioned topic
func (t Topic) WithVersion(v string) Topic // a copy with a version

benzene.NewTopic("greet") is an unversioned topic. A (ID, Version) pair maps to at most one handler: an unversioned topic and the same ID at WithVersion("v2") are two distinct topics that can carry two independent handlers, so multiple versions of a contract coexist without colliding. Topic is a comparable struct used directly as a map key, and its String() renders id or id@version.

Registration is explicit — you call benzene.Register for each handler. There is no reflection-based assembly or package scanning: Go has no attribute-scanning idiom like C#'s [Message("topic")], and the spec already requires explicit registration to be a first-class path in every language regardless (see Core concepts §9). The upshot is that the registry is the complete, authoritative list of what a service serves, and it can't drift from the running code.

The registry

The Registry holds the topic → handler bindings (registry.go). You create one with benzene.NewRegistry() — though in an ordinary app the App lifecycle creates it for you and hands it to ConfigureServices — and bind handlers with the generic benzene.Register function:

func Register[TReq, TRes any](r *Registry, topic Topic, handler Handler[TReq, TRes]) error
if err := benzene.Register(
	registry,
	benzene.NewTopic("greet"),
	benzene.Handler[greetRequest, greetResponse](greetHandler),
); err != nil {
	log.Fatalf("register greet handler: %v", err)
}

Register returns an error if a handler is already registered for that topic. Registering two handlers for the same (ID, Version) pair is a startup error, caught the moment you wire the service — not a runtime dispatch ambiguity. That's why the composition roots in the examples treat a Register error as fatal.

Register is generic over TReq/TRes, but the registry stores handlers in a type-erased form so a transport binding can dispatch by topic alone without knowing the request type at compile time. At the Register call site the concrete types are statically known, so the port captures them (via reflect.TypeOf) purely for startup-time introspection — the mesh package derives JSON Schemas from them for service self-description. Dispatch itself never touches reflection; it recovers the concrete result through the ResultInfo interface.

The Registry also exposes read-only introspection, all backed by the same authoritative map:

The App lifecycle

benzene.App[TConfig] is the application definition: a three-phase startup lifecycle, run once, in order (app.go). TConfig is your own configuration type — Benzene doesn't prescribe its shape; use struct{} for a service with no configuration.

type App[TConfig any] struct {
	GetConfiguration  func() TConfig
	ConfigureServices func(registry *Registry, container *Container, config TConfig)
	Configure         func(builder *ApplicationBuilder, config TConfig)
}

The three phases, and what belongs in each:

  1. GetConfiguration produces the configuration object. No service resolution is available yet — this is where you read environment variables, files, or flags into TConfig.
  2. ConfigureServices registers handlers on the *Registry (via benzene.Register) and their dependencies on the *Container (via benzene.AddSingleton and friends — see below). It receives the config from phase 1.
  3. Configure builds the middleware pipeline against a platform-neutral *ApplicationBuilder, ending in the router. This is where transport-neutral wiring lives; the transport-specific entry points are attached after Run returns, by calling a binding's own constructor against the builder.

ConfigureServices and Configure are optional — an app with nothing to register or nothing to configure beyond the defaults may leave either nil.

Run executes the three phases once and returns the built builder:

func (a App[TConfig]) Run() *ApplicationBuilder

Run calls GetConfiguration, creates a fresh Registry and Container, runs ConfigureServices against them, then constructs the *ApplicationBuilder and runs Configure against it. Because the whole service boots from one App value, a test exercises exactly the wiring that ships — the benzenetest package runs the same lifecycle in-process.

The ApplicationBuilder is what a transport binding reads to build its native entry point:

type ApplicationBuilder struct {
	Registry  *Registry
	Container *Container
	Pipeline  *Pipeline
	ReservedNames wire.ReservedNames
}

func (b *ApplicationBuilder) UsePipeline(pipeline *Pipeline) *ApplicationBuilder
func (b *ApplicationBuilder) UseReservedNames(names wire.ReservedNames) *ApplicationBuilder

Configure calls builder.UsePipeline(...) to set the pipeline the transport bindings will run every invocation through. A binding's Use<Transport>(builder, ...)-shaped constructor then reads Registry, Container, and Pipeline off the builder to produce an http.Handler, a Lambda handler function, and so on. UseReservedNames overrides the reserved wire header names (see Wire contracts §2) in one place for every inbound binding built off the builder.

func newApp() benzene.App[struct{}] {
	return benzene.App[struct{}]{
		GetConfiguration: func() struct{} { return struct{}{} },
		ConfigureServices: func(registry *benzene.Registry, container *benzene.Container, _ struct{}) {
			benzene.AddSingleton(container, greetingCounterKey, func(_ *benzene.Scope) GreetingCounter {
				return &inMemoryGreetingCounter{}
			})
			if err := benzene.Register(registry, benzene.NewTopic("greet"),
				benzene.Handler[greetRequest, greetResponse](greetHandler)); err != nil {
				log.Fatalf("register greet handler: %v", err)
			}
		},
		Configure: func(builder *benzene.ApplicationBuilder, _ struct{}) {
			builder.UsePipeline(benzene.NewPipeline(
				healthcheck.Middleware(checks),
				benzene.RouterMiddleware(builder.Registry),
			))
		},
	}
}

func main() {
	builder := newApp().Run()
	// hand builder to a transport binding, e.g. httpbinding.Handler(builder, routes())
}

Dependency injection

Handlers should stay thin — depend on a port interface and push the real work into an injected service. The benzene.Container is the registration set an application configures once at startup (scope.go). Languages without a DI culture, Go included, MAY implement the container concept as an explicit registry object rather than a full reflection framework, and this Container is exactly that — a small first-party object, not a general-purpose DI container. You get one from the App lifecycle (passed to ConfigureServices), or standalone with benzene.NewContainer().

Services are keyed by any comparable value (serviceKey = any). Use a package-level unexported type or a stable string constant as the key to avoid collisions — the helloworld example uses a const greetingCounterKey = "greeting-counter".

Registering services

Registration functions are generic over the service type T and take a factory func(s *benzene.Scope) T. Three lifetimes, each with an Add* and a TryAdd* variant:

func AddSingleton[T any](c *Container, key serviceKey, factory func(s *Scope) T)
func AddScoped[T any](c *Container, key serviceKey, factory func(s *Scope) T)
func AddTransient[T any](c *Container, key serviceKey, factory func(s *Scope) T)

func TryAddSingleton[T any](c *Container, key serviceKey, factory func(s *Scope) T)
func TryAddScoped[T any](c *Container, key serviceKey, factory func(s *Scope) T)
func TryAddTransient[T any](c *Container, key serviceKey, factory func(s *Scope) T)
benzene.AddSingleton(container, greetingCounterKey, func(_ *benzene.Scope) GreetingCounter {
	return &inMemoryGreetingCounter{}
})

A factory receives the *Scope it's being resolved in, so a factory may resolve other services from the same scope to build its own dependencies.

Resolving in a handler

Each pipeline invocation gets its own *benzene.Scope (created per invocation via container.NewScope()), and the router puts that scope on the handler's context.Context. Inside a handler you retrieve it with benzene.ScopeFromContext(ctx) and resolve a service with benzene.GetService[T]:

func ScopeFromContext(ctx context.Context) (*Scope, bool)

func GetService[T any](s *Scope, key serviceKey) T
func TryGetService[T any](s *Scope, key serviceKey) (T, bool)
func greetHandler(ctx context.Context, req greetRequest) benzene.Result[greetResponse] {
	if req.Name == "" {
		return benzene.BadRequest[greetResponse]("name is required")
	}

	scope, ok := benzene.ScopeFromContext(ctx)
	if !ok {
		return benzene.UnexpectedError[greetResponse]("no DI scope on context")
	}
	counter := benzene.GetService[GreetingCounter](scope, greetingCounterKey)

	return benzene.Ok(greetResponse{
		Greeting: "Hello, " + req.Name + "!",
		Count:    counter.Increment(),
	})
}

GetService[T] panics if the key has no registration — a missing required dependency is a programming error, not a recoverable runtime condition — while TryGetService[T] returns ok = false instead. ScopeFromContext returns ok = false when the context carries no scope, which happens when a unit test calls a handler directly rather than through the pipeline; handle that case as the example does.

Resolving from the scope on the context, rather than adding a *Scope parameter to the Handler signature, is what keeps the handler shape uniform across every transport. A singleton dependency can alternatively be captured in the handler's closure at registration time, avoiding the lookup entirely; scoped and transient services must be resolved per invocation. ContextWithScope(ctx, scope) is the lower-level accessor the router uses to attach the scope — you rarely call it directly.

Routing

benzene.RouterMiddleware is the terminal middleware that turns a resolved topic into a handler invocation (router.go):

func RouterMiddleware(registry *Registry) Middleware

A Pipeline is an ordered onion of Middleware, built with benzene.NewPipeline(...); the first registered is outermost, and the router is conventionally registered last so every other middleware wraps the dispatch (see Core concepts §4). A middleware that doesn't call next short-circuits the pipeline — that's how healthcheck.Middleware intercepts the reserved health topic before the router ever sees it.

builder.UsePipeline(benzene.NewPipeline(
	healthcheck.Middleware(checks),
	benzene.RouterMiddleware(builder.Registry),
))

When it runs, the router reads the topic off the invocation context, resolves it against the registry, and dispatches — writing the outcome to ic.Result. Crucially, it never returns a Go error for an application-level outcome; every case becomes a Result, so every caller reads ic.Result uniformly:

Before invoking the handler, the router attaches the invocation's *Scope to the handler's context (so ScopeFromContext works) and converts the raw request payload into the handler's declared TReq. Topic matching is exact — the ID and version travel as literal strings; any normalization a transport wants to apply happens before dispatch.

See also