Getting started
Build a small Benzene service in Go — from an empty folder to a running HTTP endpoint you can
curl — and see how a handler that knows nothing about the transport gets hosted over plain
net/http.
This guide walks through the same code as the runnable
examples/helloworld
example. If you'd rather read the finished program, start there; if you'd rather build it up a piece
at a time, read on.
The one idea
Benzene's promise is write your message handler once, host it anywhere. Everything below is one shape:
- A handler — your logic. It takes a typed request and returns a typed
benzene.Result[T]. It never importsnet/httpand never sees a status code. - A topic — a stable string (
"greet") that every transport routes by. A handler is bound to a topic in the registry. - A pipeline — an ordered onion of middleware, ending in the router that dispatches a topic to its handler.
- A transport binding — the only platform-specific part. Here it's
httpbindingovernet/http; on a cloud host it's a Lambda or Azure Functions binding, and the handler is byte-for-byte identical.
These four are the language-neutral Benzene concepts, defined once for every port on the website — see Core concepts for the full model and Wire contracts for the envelope and status vocabulary. This guide won't re-explain them; it shows the Go shape.
Prerequisites
- Go 1.24 or newer (this module targets
go 1.24).
1. Set up a project
mkdir hello-benzene && cd hello-benzene
go mod init example.com/hello-benzene
go get github.com/daniellepelley/benzene-go
Everything in this guide lives in the root benzene package plus the httpbinding and healthcheck
subpackages — all part of the one module you just added, no extra third-party dependencies.
2. Write a handler
A Benzene handler is a plain function from a request to a benzene.Result[T]. It's a
benzene.Handler[TReq, TRes] — a func(context.Context, TReq) benzene.Result[TRes]. Create
main.go:
package main
import (
"context"
benzene "github.com/daniellepelley/benzene-go"
)
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 + "!"})
}
The request and response are ordinary structs with JSON tags — the binding decodes the request body
into greetRequest and marshals the response payload back out. The handler returns values, not
errors: benzene.Ok(...) for success and benzene.BadRequest[greetResponse]("...") for a
client error. There's a constructor for each status in the framework vocabulary —
benzene.Ok, benzene.CreatedResult, benzene.NotFound, benzene.Conflict,
benzene.ValidationError, and so on — each mapping to a Benzene
status the transport translates into
its own native failure signal (an HTTP code, here).
3. Compose the app
The composition root wires three things together: the registry (topic → handler), the
container (dependency injection), and the pipeline (middleware, ending in the router). The
benzene.App type runs these as a three-phase lifecycle — GetConfiguration, then
ConfigureServices, then Configure — so the exact wiring that ships is the wiring your tests boot
from.
Add to main.go:
import (
// ...existing imports...
"log"
"github.com/daniellepelley/benzene-go/healthcheck"
)
func newApp() benzene.App[struct{}] {
return benzene.App[struct{}]{
GetConfiguration: func() struct{} { return struct{}{} },
ConfigureServices: func(registry *benzene.Registry, container *benzene.Container, _ struct{}) {
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{}) {
checks := []healthcheck.Check{
healthcheck.CheckFunc{CheckName: "memory", Fn: func(context.Context) healthcheck.CheckResult {
return healthcheck.CheckResult{Status: healthcheck.StatusOk, Type: "memory"}
}},
}
builder.UsePipeline(benzene.NewPipeline(
healthcheck.Middleware(checks),
benzene.RouterMiddleware(builder.Registry),
))
},
}
}
Two things to notice:
benzene.Registerbinds the"greet"topic to the handler. Registration is explicit — there's no reflection-based scanning; the registry is the complete, authoritative list of what this service serves. It returns an error if a topic is registered twice.- The pipeline order matters.
healthcheck.Middlewareruns first and short-circuits the reserved health-check topic before the router ever sees it;benzene.RouterMiddlewareis the terminal middleware that resolves the topic and dispatches to the handler, so it's registered last (see Core concepts §4 on the pipeline).
TConfig is struct{} here because this service has no configuration; a real service would make it
a config struct returned by GetConfiguration. ConfigureServices is also where you'd register
dependencies against the *benzene.Container (benzene.AddSingleton, benzene.AddScoped,
benzene.AddTransient) and resolve them inside a handler with benzene.ScopeFromContext(ctx) +
benzene.GetService[T] — the helloworld example
does exactly that with a shared counter.
4. Define the route table
httpbinding needs a table mapping each HTTP (method, path) to a topic. Keep it in one function so
the same table drives both main and your tests:
import (
// ...existing imports...
"net/http"
"github.com/daniellepelley/benzene-go/httpbinding"
)
func routes() []httpbinding.Route {
return []httpbinding.Route{
{Method: http.MethodPost, Path: "/greet", Topic: benzene.NewTopic("greet")},
{Method: http.MethodGet, Path: httpbinding.HealthPath, Topic: benzene.NewTopic(healthcheck.ReservedTopic)},
}
}
A Path can contain {name} segments to capture path parameters — each captured segment arrives at
the handler as a route-<name> wire header. httpbinding.HealthPath (/benzene/health) is the
well-known mount the default service standard reserves for the health check, so it reads as framework
infrastructure rather than a domain endpoint.
5. Serve it over HTTP
The transport binding is the last piece. httpbinding.Handler turns the builder and the route table
into an ordinary http.Handler, so you serve it with the standard library — nothing Benzene-specific
about running the server:
func main() {
builder := newApp().Run()
mux := http.NewServeMux()
mux.Handle(httpbinding.EnvelopePath, httpbinding.EnvelopeHandler(builder))
mux.Handle("/", httpbinding.Handler(builder, routes()))
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
newApp().Run() executes the three-phase lifecycle once and hands back the built
*benzene.ApplicationBuilder. Two entry points are mounted:
httpbinding.Handler— the native REST-style front door. It maps a matched route to a topic, dispatches through the pipeline, and maps the result back to a real HTTP status code and JSON body.httpbinding.EnvelopeHandlerathttpbinding.EnvelopePath(/benzene/invoke) — the raw wire envelope carried over HTTP (always HTTP 200; the real outcome is inside the envelope's ownstatusCode). It's for service-to-service calls where there's no route table to agree on. It's optional — drop it if you don't need it.
6. Run it
go run .
# listening on :8080
In another terminal:
# Native REST-style route, real HTTP status codes
curl -X POST localhost:8080/greet -d '{"name":"World"}'
# {"greeting":"Hello, World!"}
# A missing name is a validation failure -> HTTP 400
curl -i -X POST localhost:8080/greet -d '{"name":""}'
# HTTP/1.1 400 Bad Request
# The reserved health check
curl localhost:8080/benzene/health
# {"isHealthy":true,"healthChecks":{"memory":{"status":"ok","type":"memory"}}}
# The raw wire envelope, for service-to-service calls with no route table
curl -X POST localhost:8080/benzene/invoke \
-d '{"topic":"greet","headers":{},"body":"{\"name\":\"Envelope\"}"}'
# {"statusCode":"ok","headers":{"content-type":"application/json"},"body":"{\"greeting\":\"Hello, Envelope!\"}"}
The handler returned benzene.Ok(...) and benzene.BadRequest(...); the binding mapped those Benzene
statuses to HTTP 200 and 400 via the
wire-contracts status table. The
handler never named an HTTP code.
7. Test it without a real server
Because the app boots from one composition root (newApp), a test exercises exactly the wiring that
ships. The benzenetest package runs the same lifecycle in-process and pushes native events in the
front door — no net/http listener required:
package main
import (
"encoding/json"
"net/http"
"testing"
"github.com/daniellepelley/benzene-go/benzenetest"
)
func TestGreet(t *testing.T) {
host := benzenetest.NewHost(newApp(), benzenetest.WithRoutes(routes()...))
resp := benzenetest.SendHTTP(t, host, http.MethodPost, "/greet", greetRequest{Name: "World"}, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, resp.Body)
}
var got greetResponse
if err := json.Unmarshal([]byte(resp.Body), &got); err != nil {
t.Fatal(err)
}
if got.Greeting != "Hello, World!" {
t.Errorf("Greeting = %q, want %q", got.Greeting, "Hello, World!")
}
}
benzenetest.SendHTTP drives the native-HTTP front door; benzenetest.SendEnvelope drives the wire
envelope. To test these same handlers on a cloud host later, only the Send* call changes — the
host setup and assertions stay identical. The example's
main_test.go
covers the greet endpoint, the health check, the envelope round-trip, and a 404.
What just happened
You wrote a handler that knows nothing about HTTP, bound it to a topic, ran it through a middleware
pipeline, and hosted it over net/http with httpbinding — the four pieces from
The one idea. The handler code never mentions the transport, which is the whole
point of Benzene's ports-and-adapters design: the handler is the asset; the host is a detail.
That detail is the only thing that changes when you deploy to a cloud provider.
Next: host it in the cloud
The same newApp() composition root and the same handler run behind a different transport binding on
each cloud host. Each guide starts from this service and swaps only the host wiring:
- Getting started: AWS Lambda — one function behind API Gateway, SQS, SNS, and more.
- Getting started: Azure Functions — the custom-handler binding for HTTP and queue triggers.
- Getting started: Google Cloud — Cloud Run (plain
net/http, no new package) and Pub/Sub push subscriptions.
For the full set of runnable services — local HTTP, every cloud host, and the mesh demo — see the
examples/ directory.