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:

  1. A handler — your logic. It takes a typed request and returns a typed benzene.Result[T]. It never imports net/http and never sees a status code.
  2. A topic — a stable string ("greet") that every transport routes by. A handler is bound to a topic in the registry.
  3. A pipeline — an ordered onion of middleware, ending in the router that dispatches a topic to its handler.
  4. A transport binding — the only platform-specific part. Here it's httpbinding over net/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

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:

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:

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:

For the full set of runnable services — local HTTP, every cloud host, and the mesh demo — see the examples/ directory.