gRPC Setup

The grpcbinding package lets an ordinary gRPC server's unary methods be served by Benzene message handlers instead of hand-written service method bodies. It is a grpc.UnaryServerInterceptor that claims specific registered methods for Benzene dispatch; every method it doesn't claim falls through to the native generated service untouched — the binding claims routes, it doesn't own the server.

Scope: unary RPCs only. Client-streaming, server-streaming, and duplex shapes are a deliberate, documented gap (see the grpcbinding package doc), not covered here.

Prerequisites

grpcbinding is its own Go module (it needs google.golang.org/grpc and google.golang.org/protobuf, which the zero-dependency root module doesn't carry):

go get github.com/daniellepelley/benzene-go/grpcbinding

1. Define your .proto and generate code

The binding sits behind ordinary generated gRPC code — you still write a real .proto and run protoc exactly as you normally would:

syntax = "proto3";

package greet;
option go_package = "example/greetpb";

service GreetService {
  rpc Greet (HelloRequest) returns (HelloReply);
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string greeting = 1;
}

Generate the Go types and service stubs with protoc-gen-go / protoc-gen-go-grpc as usual. That gives you greetpb.HelloRequest, greetpb.HelloReply, the GreetServiceServer interface, and greetpb.RegisterGreetServiceServer.

2. Write the handler

Business logic is an ordinary Benzene handler over plain Go structs — nothing gRPC-specific:

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 + "!"})
}

Field-name rule (important). The binding bridges the request/response bodies through proto3-JSON (protojson). The inbound generated message is marshalled to JSON and unmarshalled into your request struct; your result struct is marshalled to JSON and unmarshalled into a fresh generated response message. So your struct's json:"..." tags must match the protobuf message's proto3-JSON field names (the lowerCamel proto field names — here name and greeting). Mismatched names silently produce empty fields, exactly as a name-mismatched JSON bridge would.

3. Build the application

Register the handler and build the pipeline. This is the ordinary three-phase composition root (core-concepts.md §7); the returned *benzene.ApplicationBuilder is what the binding reads:

func newApp() benzene.App[struct{}] {
	return benzene.App[struct{}]{
		GetConfiguration: func() struct{} { return struct{}{} },
		ConfigureServices: func(registry *benzene.Registry, _ *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{}) {
			builder.UsePipeline(benzene.NewPipeline(benzene.RouterMiddleware(builder.Registry)))
		},
	}
}

4. Wire the gRPC server

grpcbinding.UnaryServerInterceptor(builder, routes) returns a grpc.UnaryServerInterceptor. Each grpcbinding.Route maps a full gRPC method path (matched case-insensitively against info.FullMethod) to a Benzene Topic, plus a NewResponse factory that constructs a fresh generated response message for the binding to fill in:

func main() {
	builder := newApp().Run()

	routes := []grpcbinding.Route{
		{
			Method:      "/greet.GreetService/Greet",
			Topic:       benzene.NewTopic("greet"),
			NewResponse: func() proto.Message { return &greetpb.HelloReply{} },
		},
	}

	server := grpc.NewServer(grpc.UnaryInterceptor(
		grpcbinding.UnaryServerInterceptor(builder, routes)))

	// You still register a real generated service — gRPC's own routing/reflection needs one to
	// exist. Methods a Route claims never reach it; unclaimed methods fall through to it as normal.
	greetpb.RegisterGreetServiceServer(server, &greetServer{})

	lis, err := net.Listen("tcp", ":8080")
	if err != nil {
		log.Fatalf("listen: %v", err)
	}
	log.Println("gRPC server listening on :8080")
	log.Fatal(server.Serve(lis))
}

// greetServer is the native service implementation. It embeds the generated Unimplemented base,
// so any method with no matching Route returns Unimplemented; a claimed method's body here is
// never invoked (the interceptor substitutes Benzene dispatch before it would run).
type greetServer struct {
	greetpb.UnimplementedGreetServiceServer
}

Chain it alongside other interceptors with grpc.ChainUnaryInterceptor(...) if you already have some — it's an ordinary interceptor.

What the binding does per claimed call

5. Status mapping

The grpcstatus package (wire-contracts.md §4.2) drives the forward mapping:

Benzene status gRPC code
ok, ignored, created, accepted, updated, deleted OK
bad-request, validation-error InvalidArgument
unauthorized Unauthenticated
forbidden PermissionDenied
not-found NotFound
conflict AlreadyExists
not-implemented Unimplemented
service-unavailable Unavailable
too-many-requests ResourceExhausted
timeout DeadlineExceeded
unexpected-error / anything unrecognized Internal

6. Calling another gRPC service

grpcbinding.NewClient(conn, routes) returns a *grpcbinding.Client that satisfies client.Sender, so it composes with client.CorrelationDecorator / client.RetryDecorator like any other sender. Each grpcbinding.ClientRoute is keyed by topic and carries the method path plus NewRequest / NewResponse factories:

conn, err := grpc.NewClient("localhost:8080",
	grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
	log.Fatalf("dial: %v", err)
}
defer conn.Close()

sender := grpcbinding.NewClient(conn, []grpcbinding.ClientRoute{
	{
		Topic:       benzene.NewTopic("greet"),
		Method:      "/greet.GreetService/Greet",
		NewRequest:  func() proto.Message { return &greetpb.HelloRequest{} },
		NewResponse: func() proto.Message { return &greetpb.HelloReply{} },
	},
})

result := sender.Send(ctx, benzene.NewTopic("greet"), nil, []byte(`{"name":"World"}`))
// result.Status is recovered from the benzene-status trailer verbatim when present, else from the
// coarse gRPC code via grpcstatus.FromGRPC. A missing route -> not-implemented; a marshalling or
// transport failure -> service-unavailable.

7. Testing

Test the handler through the pipeline with benzenetest.Invoke — transport-neutral, no gRPC server or generated code needed, and it exercises the real middleware/DI/router path:

func TestGreetHandler(t *testing.T) {
	builder := newApp().Run()

	result := benzenetest.Invoke[greetRequest, greetResponse](
		context.Background(),
		builder,
		benzene.NewTopic("greet"),
		nil,
		greetRequest{Name: "World"},
	)

	if result.Status != benzene.StatusOk {
		t.Fatalf("Status = %q, want %q", result.Status, benzene.StatusOk)
	}
	if result.Payload == nil || result.Payload.Greeting != "Hello, World!" {
		t.Errorf("Payload = %+v, want greeting %q", result.Payload, "Hello, World!")
	}
}

For a full-stack round trip through a live server, see grpcbinding's own server_test.go / client_test.go, which drive a real *grpc.Server over an in-memory bufconn listener.

Troubleshooting

See Also