benzene-go
A Go port of Benzene, a middleware-based library for hexagonal (ports-and-adapters) architecture: a pipeline of middleware wraps calls to "ports" (interfaces representing external boundaries - DB, HTTP, queues, etc), dispatched by topic to a registered handler.
This repo is conformant with the main repo's language-neutral
specification -
see conformance/ for the fixtures this port runs against. The spec, not this README, is the
source of truth for cross-language behavior; when the two disagree, the spec wins and this
repo has a bug.
Quickstart
package main
import (
"context"
"net/http"
benzene "github.com/daniellepelley/benzene-go"
"github.com/daniellepelley/benzene-go/httpbinding"
)
type greetRequest struct {
Name string `json:"name"`
}
type greetResponse struct {
Greeting string `json:"greeting"`
}
func greetHandler(_ context.Context, req greetRequest) benzene.Result[greetResponse] {
return benzene.Ok(greetResponse{Greeting: "Hello, " + req.Name + "!"})
}
func main() {
registry := benzene.NewRegistry()
benzene.Register(registry, benzene.NewTopic("greet"), benzene.Handler[greetRequest, greetResponse](greetHandler))
builder := &benzene.ApplicationBuilder{
Registry: registry,
Container: benzene.NewContainer(),
Pipeline: benzene.NewPipeline(benzene.RouterMiddleware(registry)),
}
routes := []httpbinding.Route{{Method: http.MethodPost, Path: "/greet", Topic: benzene.NewTopic("greet")}}
http.ListenAndServe(":8080", httpbinding.Handler(builder, routes))
}
See examples/helloworld/ for a complete version of this with dependency injection, a health
check, and both HTTP entry points wired through the three-phase App lifecycle.
Packages
| Package | Coverage | What it is |
|---|---|---|
benzene (root) |
100% | Topic, Status, Result[T], Registry, Middleware/Pipeline, RouterMiddleware, the DI-lite Container/Scope, the three-phase App lifecycle |
wire |
100% | The transport-neutral message envelope (Request/Response/ErrorPayload) - no dependency on the rest of this module |
httpstatus |
100% | The Benzene<->HTTP status mapping tables |
grpcstatus |
100% | The Benzene<->gRPC status mapping tables (wire-contracts §4.2) - raw numeric gRPC status codes, so this stays zero-dependency like httpstatus; a gRPC binding wraps the result as codes.Code(...) |
envelope |
96%+ | Dispatches a wire.Request through a Pipeline and produces a wire.Response (merging any invocation-set response headers - see benzene.SetResponseHeader) - shared by httpbinding, httpclient, and conformance |
httpbinding |
97%+ | The HTTP transport binding: a native REST-style Handler (real HTTP status codes, explicit route table with {param} path templating - captured segments arrive as route-<name> wire headers) and an EnvelopeHandler (the wire envelope over HTTP); handler-set response headers come back as real HTTP headers |
httpclient |
97%+ | The HTTP outbound client - one Send(topic, headers, message) method, mapping transport failures to ServiceUnavailable |
healthcheck |
100% | Middleware that intercepts the reserved healthcheck topic and responds with the standard aggregate health response |
logging |
100% | Basic request logging/timing middleware, log/slog only (zero deps): one structured line per invocation - topic, status, duration - Info/Warn/Error by outcome. The dependency-free alternative to diagnostics |
mesh |
100% | Phases 1-2 of Benzene Mesh: the service Descriptor derived from the live Registry - topics, per-topic request/response JSON Schemas derived at startup from the registered handler types, and the contract descriptorHash - plus reserved-mesh-topic descriptor middleware and TraceMiddleware + LogExporter emitting semantic per-invocation trace events. Every feed is optional - a service with only some feeds provisioned runs a reduced mesh, never a broken one |
meshd |
100% | Phases 3-4 of Benzene Mesh: the collector - itself an ordinary Benzene service serving mesh:register/mesh:heartbeat/mesh:traces and the mesh:query:* read models over an in-memory store, plus the Mesh View (one embedded self-contained page, no JS framework). Accepts partial fleets: a service missing a feed renders as reduced, never breaks ingestion or queries |
awslambda |
93%+ | AWS Lambda binding: a hand-rolled Lambda Runtime API bootstrap loop (Start), plus HTTPHandler (Function URL / API Gateway v2.0, API Gateway REST/v1.0, and ALB target-group events, detected per invocation) and EnvelopeHandler (direct invoke) |
azurefunctions |
94%+ | Azure Functions custom-handler binding: Handler adapts the Data/Metadata JSON contract for HTTP-triggered functions; QueueHandler adapts queue-shaped triggers (Storage Queue, Service Bus), reporting a failed message via a non-2xx outer status so the platform's own redelivery/poison-queue machinery takes over |
client |
100% | Outbound-client decorators (CorrelationDecorator, RetryDecorator) over a transport-agnostic Sender interface |
cors |
100% | Portable CORS middleware for HTTP-fronted services (origin/scheme/port matching, header wildcard, preflight) |
benzenetest |
100% | In-process test host for your application's tests - Invoke[TReq, TRes] runs one pipeline invocation without real HTTP/Lambda/etc. |
cloudevents |
99%+ | CloudEvents 1.0 mapping (zero deps): type ↔ topic, data ↔ body, other attributes ↔ ce--prefixed wire headers; Handler accepts CloudEvents over HTTP in both content modes (binary ce-* headers and structured application/cloudevents+json) from Event Grid subscriptions, Knative triggers, EventBridge API destinations, etc.; FromRequest/MarshalJSON emit events for the outbound direction |
gcppubsub |
100% | Google Cloud Pub/Sub inbound binding (zero deps): an http.Handler for a push subscription's endpoint - decodes the push envelope, resolves the topic per wire-contracts §2 (topic attribute or envelope-in-body), acks with 204 / nacks with 500 so Pub/Sub's own redelivery/dead-letter machinery handles failures. Outbound publishing needs the Pub/Sub SDK - a pending dependency decision (see ROADMAP.md) |
awssqs (own module) |
100% | AWS SQS binding: inbound Handler for a Lambda triggered by an SQS event source mapping (zero deps), outbound Client publishing via SendMessage (needs aws-sdk-go-v2/service/sqs - this repo's first third-party dependency) |
diagnostics (own module) |
100% | OpenTelemetry diagnostics middleware - one server span per invocation (named by topic, joined to the caller's W3C traceparent, benzene.topic/benzene.status attributes) plus benzene.invocations/benzene.invocation.duration metrics. Depends on the OTel API only; your app owns the SDK/exporter, and with no SDK installed the no-op defaults make it free |
kafka (own module) |
100% | Kafka binding, matching the main repo's spec exactly (one Kafka topic = one Benzene topic, headers pass through verbatim, no envelope wrapping): a Consumer loop over a consumer group (one pipeline invocation + DI scope per record, explicit commits, an OnFailure hook since Kafka has no broker-side redelivery/DLQ to hand a failed message to) and an outbound Client satisfying client.Sender (needs segmentio/kafka-go - a broker wire protocol isn't hand-rollable) |
grpcbinding (own module) |
100% | gRPC binding, unary RPCs only: UnaryServerInterceptor claims specific registered Routes (full method path → topic, case-insensitive) on an ordinary *grpc.Server - unclaimed methods fall through to the native generated service untouched, per spec - with proto3-JSON body bridging, incoming/outgoing metadata as wire headers, and the mandatory benzene-status trailer; an outbound Client satisfying client.Sender recovers the precise status from that trailer. Needs google.golang.org/grpc + google.golang.org/protobuf |
awseventbridge (own module) |
96%+ | AWS EventBridge binding, matching the main repo's spec exactly: inbound Handler for a Lambda invoked by a rule (zero deps; topic is detail-type verbatim, body is the raw detail JSON, headers are eventbridge--prefixed envelope metadata plus any _benzeneHeaders object embedded inside detail; a failed event returns a Go error, triggering AWS's async-invoke retry) and an outbound Client publishing via PutEvents (embeds headers under _benzeneHeaders when the message is a JSON object; needs aws-sdk-go-v2/service/eventbridge) |
awssns (own module) |
100% | AWS SNS binding: inbound Handler for a Lambda subscribed directly to an SNS topic (zero deps; a failed notification returns a Go error, triggering AWS's own async-invoke retry, since SNS has no batch/partial-failure mechanism), outbound Client publishing via Publish (needs aws-sdk-go-v2/service/sns) |
conformance |
n/a (test-only) | Runs this port against the fixtures vendored from the main repo's docs/specification/conformance/ |
examples/helloworld |
- | A runnable example service - DI, health check, both HTTP entry points |
examples/aws-lambda-helloworld |
- | The same service, deployable to AWS Lambda (Dockerfile + SAM template) |
examples/azure-functions-helloworld |
- | The same service, deployable to Azure Functions (host.json/function.json) |
examples/gcp-cloudrun-helloworld |
- | The same service, deployable to Google Cloud Run (Dockerfile, no new package needed) |
examples/gcp-pubsub-helloworld |
- | A Cloud Run service consuming a Pub/Sub push subscription via gcppubsub.Handler - publish with gcloud pubsub topics publish, no publisher code needed |
examples/aws-sqs-helloworld (own module) |
- | A publisher Lambda (Function URL) forwarding to SQS + a consumer Lambda triggered by that queue |
examples/aws-sns-helloworld (own module) |
- | A publisher Lambda (Function URL) forwarding to SNS + a consumer Lambda subscribed to that topic |
examples/mesh-helloworld |
- | The whole mesh story in one process: a meshd collector + two meshed services with a cross-service traced call - open the Mesh View and watch the derived fleet |
Every non-test-only package sits at 100% coverage, or just under it where the gap is a
defensively-unreachable branch (documented at the call site - e.g. a json.Marshal failure on
a type that can't actually fail to marshal). Run go test ./... -cover to see current numbers.
Deploying to a cloud provider
| Provider | Path | New package needed? |
|---|---|---|
| AWS | Lambda (container image) + a Function URL | awslambda - Lambda has no HTTP-server contract, only the Runtime API |
| AWS | Lambda triggered by SQS + publish-to-SQS | awssqs - its own module (needs the AWS SDK) |
| AWS | Lambda subscribed to SNS + publish-to-SNS | awssns - its own module (needs the AWS SDK) |
| Azure | Azure Functions custom handler | azurefunctions - Azure has no native Go worker |
| Google Cloud | Cloud Run | None - Cloud Run's contract is "listen on $PORT", which httpbinding + net/http already satisfies |
| Google Cloud | Cloud Run consuming a Pub/Sub push subscription | gcppubsub (inbound only, zero deps) - the push envelope's base64/attributes/ack contract is the one GCP shape httpbinding can't cover |
Each examples/*-helloworld directory's README documents the concrete deploy steps and states
what was and wasn't verified in this repo's own CI sandbox. Each also has a matching GitHub
Actions workflow (deploy-*.yml, one per example) that runs that same deploy on every push to
main touching it - each is gated on its provider's credential secret being set, so the job
shows as skipped (not failed) until you add the secrets/variables listed in that example's
own README. None have been run for real from this repo (no live cloud credentials in this
sandbox) - only the code, cross-compilation, and unit tests have been verified here.
Modules
This is a multi-module repo - see RELEASING.md for the full explanation (and for how Go's
decentralized module distribution works at all, if you're coming from an ecosystem with a
central package registry like NuGet). Short version: everything is one module except awssqs,
awssns, awseventbridge, kafka, diagnostics, grpcbinding, examples/aws-sqs-helloworld,
and examples/aws-sns-helloworld, which have their own go.mod because they need real
third-party dependencies the rest of the repo shouldn't carry. go.work ties them together for
local development.
Scope
This port covers core-concepts.md and wire-contracts.md end to end (pipeline, DI, health
checks, HTTP binding + client, conformance, AWS/Azure/GCP deployment, SQS/SNS/Pub-Sub/Kafka/
gRPC bindings, CloudEvents) but does not yet have: gRPC's client-streaming/server-streaming/
duplex-streaming shapes (grpcbinding covers unary RPCs only - a documented scope decision,
not an oversight; see its package doc), or a source-generator/codegen equivalent to the C#
attribute-scanning sugar (per porting-guide.md, explicit registration is the framework
contract in every language; attribute scanning is .NET-specific idiom, not something every
port needs).
See ROADMAP.md for the fuller picture: what's next with zero new dependencies, what's next
pending a dependency decision, and what's deliberately not being ported at all (and why).
For how this project compares to the other ways of building cloud-portable services in Go
(Dapr, Go CDK, Watermill, Encore) and when you'd pick each, see
docs/comparison.md.
Benzene Mesh
Benzene Mesh - a fleet-wide, multi-cloud view of every service, its topics/schemas, health,
and live traffic stats, derived from running services rather than declared in a catalog - is
designed in docs/design/ (mesh.md, with a
static mockup of the Fleet Overview screen and the
research and positioning behind it). All phases of its
delivery plan are complete: the mesh and meshd packages and the examples/mesh-helloworld
demo above implement it, and the wire contracts are promoted and merged as the main repo's
docs/specification/mesh.md - now the normative text. The main repo's .NET implementation
(Benzene.Mesh.Wire + Benzene.Mesh.Collector) is the primary implementation of that
contract; this port is a fully conforming implementation - the contract was originally
extracted from it, the vendored mesh-*.json fixtures in conformance/ pin it and pass, and
the two implementations have hosted each other's services in live cross-language fleets.
Both implementations also follow the spec's default service standard (the main repo's
docs/specification/design-principles.md): framework-provided HTTP surfaces mount under a
well-known /benzene/ prefix - here httpbinding.EnvelopePath (/benzene/invoke),
httpbinding.HealthPath (/benzene/health), and meshd.ViewPath (/benzene/fleet-ui) - so
they read as infrastructure rather than domain endpoints, with every path overridable per
service. The same document records the wider "opinionated but optional" strategy the port
already embodies: message handlers, like everything else, are the steer, never a requirement.
Developing
go build ./...
go vet ./...
gofmt -l . # should print nothing
go test ./... -race -cover
CI (.github/workflows/ci.yml) runs all of the above on every push/PR to main.
License
MIT - see LICENSE.