Hoisting wire plumbing out of your Go handlers

Redowan Delowar May 2, 2026
Source
Consider an HTTP handler: And a gRPC handler for the same operation: Both handlers run through the same five steps: - decode the input off the wire - validate it - cast it into a domain type - call the service function and collect the output - encode the output and return it Four of those steps are wire plumbing: decode, validate, cast, encode. Only the call to the service function does anything domain-specific, and the body of that call is identical in both handlers above. The plumbing is per-transport, not per-endpoint. Every HTTP endpoint with a JSON body decodes the same way, and every gRPC method unpacks its protobuf the same way. What changes endpoint to endpoint is only the input and output types of the service function. So instead of writing the same wire plumbing in every handler, hoist it into one adapter per transport: > [!Gist] > > - Every service function has the shape func(ctx context.Context, in In) (Out, error). > In and Out are domain types. No transport type ever shows up in the signature. > - For each transport, write a generic adapter Wrap[In, Out]. It takes three things: a > decode function that turns a wire request into In, the service function itself, and an > encode function that turns Out into a wire response. > - Inside, Wrap decodes the request, runs Validate() on In if it has one, calls the > service function, and encodes the result. > - Wrap returns the transport's handler shape. For HTTP that's http.Handler. For gRPC > it's the function shape protoc-gen-go-grpc generates for server methods, so the > wrapped function lives on the Server struct and the generated method forwards to it. > - Adding an endpoint costs one decode, one encode, and one router line per transport. The > service function on the inside stays the same no matter which transport is calling it. The same service-function shape feeds multiple transports: A few benefits of doing it this way are: > [!Important] > > - You write the four plumbing steps once per transport. A fix lands inside Wrap and > applies to every endpoint at once. > - Every endpoint is the same shape: a service method, a decode, an encode, and a router > line. Humans and LLMs pick that up from one example, and off-shape code won't compile. > - Tests split at two layers: the service function in unit tests with no transport, and > Wrap plus its codecs once at the transport level. No per-handler plumbing tests. > - Middleware and interceptors compose unchanged. They sit outside Wrap, so auth, > observability, and rate limiting go where they always did. > - Drift goes away. The same domain error returns the same status everywhere, and > Validate runs on every input that has one. This pattern isn't new. [go-kit] had it back in 2015 as Endpoint, a pre-generics adapter that every per-transport wrapper wrapped: [Connect-Go] uses the same shape as UnaryFunc over interface types, generating the wrappers from .proto files: Mat Ryer's [How I write HTTP services after 13 years] arrives at the HTTP half from the other direction, with generic encode/decode helpers and a service that returns (Out, error). Below I build a small greeter service from scratch and wrap it once for HTTP and once for gRPC. The full code is in the [wire-plumb] directory of the examples repo. Writing the service function The service function holds the business logic. No transport types in its signature, so the same function runs over both HTTP and gRPC. The greeter takes a user store and a logger, loads the user, logs the call, and writes a message based on a formality flag: Drop the receiver from the highlighted line and what's left is func(ctx context.Context, in GreetIn) (GreetOut, error), the func(ctx, In) (Out, error) shape. Every service function in the project will match that line. You could give the shape a name: I don't bother. I prefer to repeat func(ctx context.Context, in In) (Out, error) literally in Wrap's definition, so the shape is obvious wherever Wrap shows up, and every other file stays plain non-generic Go. This file imports neither net/http, google.golang.org/grpc, JSON, nor protobuf. New dependencies can land as fields on Service, but they stay there. The signature doesn't change, so the wrappers don't need to know. GreetIn and GreetOut are plain Go structs with no transport-specific tags. In can optionally satisfy a Validate method, and the wrappers will run it between decode and call: (NotFound and Invalid return a domain greet.Error carrying a Code enum that maps to HTTP statuses and gRPC codes. I covered the mapping in [Error translation in Go services].) With the service function and its types pinned, the only thing left is the adapter that runs decode, validate, call, and encode around them once per transport. Wrapping it for HTTP Wrap is the HTTP adapter. It's a generic function over [In, Out] that takes a per-endpoint decode(http.Request) (In, error), the service function in the middle, and a per-endpoint encode(http.ResponseWriter, Out) error. It returns an http.Handler ready to mount on a router. The numbered comments mark the four plumbing steps: Structs without Validate skip step 2. The encode branch only logs on error: the response is already partially written by then. Every other branch goes through writeErr, which turns a domain greet.Error into an HTTP status and a JSON body. That leaves decodeGreet and encodeGreet per endpoint. The http.Error calls, early returns, and status codes from the original handler are gone. Decode parses the body into GreetIn. Encode writes the message back as JSON, wrapped in an anonymous struct so the wire field is message (lowercase) without putting a JSON tag on the domain type: The UserID == 0 check sits on GreetIn.Validate and runs inside Wrap between decode and call, so it doesn't reappear here. Both functions only do wire-to-domain mapping. Register mounts every endpoint. The highlighted line is the wiring: Wrap chews the three pieces (decode, service function, encode) and hands back an http.Handler that the mux can mount. main ties it together. The highlighted line is the only place cmd/http knows about the HTTP package at all: svc.Greet is a method value: the Service receiver gets bound into the function, so the wrapper sees a plain func(context.Context, GreetIn) (GreetOut, error). The store, the logger, and anything else on Service ride along in the closure and never appear in the wrapper's signature. The gRPC version uses that same svc.Greet. Only the wrapping around it changes. Wrapping it for gRPC The gRPC adapter has the same job: decode, validate, call, and encode around the service function. Two things change. gRPC's wire types are per-RPC. pb.GreetRequest and pb.GreetResponse belong to one specific method, where http.Request and http.ResponseWriter were shared across every HTTP handler. So the gRPC Wrap carries two extra type parameters, WireIn and WireOut, sitting on either side of the domain In and Out. Errors come back as return values too, not bytes written to a stream, so every error branch can return directly without a writeErr helper. The function-typed arguments line up the same as before, with WireIn and WireOut standing in for http.Request and http.ResponseWriter: decode(WireIn) (In, error), the same service function, and encode(Out) (WireOut, error). The wrapper returns func(context.Context, WireIn) (WireOut, error), which is the signature protoc-gen-go-grpc generates for every server method: Same four steps as the HTTP version, with the same svc.Greet doing the user lookup and logging. statusErr turns a domain greet.Error into a status.Status carrying the matching gRPC code. The gRPC decodeGreet and encodeGreet are narrower than the HTTP versions because protobuf has already parsed the bytes by the time Wrap sees the request. Decode copies fields from the generated struct into the domain type. Encode copies them back: Unlike the HTTP mux.Handle route, you can't hand the wrapped function straight to gRPC. protoc-gen-go-grpc generates a server interface with concrete method signatures, so Server holds the wrapped functions as fields and the methods forward to them: The gRPC package's Register follows the same shape, and so does main. The highlighted lines are the wiring points: Either way, svc.Greet runs unchanged. Only the decode and encode on either side of it differ between transports. Adding a second endpoint With one endpoint wired through both transports, a second endpoint should be cheap. Adding a Farewell method costs three short pieces of code on the HTTP side and one extra line on the router. The highlighted lines are the entire diff once Wrap exists: The gRPC side follows the same shape: a decodeFarewell/encodeFarewell pair between pb.FarewellRequest/pb.FarewellResponse and the domain types, plus one extra line in NewServer. The plumbing inside Wrap doesn't change. Middleware and interceptors don't change Middleware and interceptors don't see Wrap. They wrap the http.Handler or the gRPC server method that Wrap returned, the same as any other handler. The HTTP signature stays func(http.Handler) http.Handler and the gRPC signature stays grpc.UnaryServerInterceptor. A request logger as HTTP middleware. The highlighted line is where the middleware hands off to the wrapped mux: Wired into main by wrapping the mux: The gRPC version of the same logger. The highlighted line is the analogous hand-off into the wrapped server method: Wired into the server constructor: [How I write HTTP services after 13 years]: https://grafana.com/blog/how-i-write-http-services-in-go-after-13-years/ [Error translation in Go services]: /go/error-translation [go-kit]: https://github.com/go-kit/kit/blob/78fbbceece7bbcf073bee814a7772f4397ea756c/endpoint/endpoint.go#L9 [Connect-Go]: https://github.com/connectrpc/connect-go/blob/c4aac92b87026cd709cfbccdaabe8c45abef705c/interceptor.go#L36 [wire-plumb]: https://github.com/rednafi/examples/tree/main/wire-plumb

Discussion in the ATmosphere

Loading comments...