{
  "$type": "site.standard.document",
  "canonicalUrl": "https://rednafi.com/go/hoist-wire-plumb/",
  "description": "Four of the five steps in every unary RPC handler are wire plumbing. Pin the service function signature and they fit in one generic adapter per transport.",
  "path": "/go/hoist-wire-plumb/",
  "publishedAt": "2026-05-02T00:00:00.000Z",
  "site": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
  "tags": [
    "Go",
    "API",
    "Design Patterns"
  ],
  "textContent": "Consider an HTTP handler:\n\nAnd a gRPC handler for the same operation:\n\nBoth handlers run through the same five steps:\n\n- decode the input off the wire\n- validate it\n- cast it into a domain type\n- call the service function and collect the output\n- encode the output and return it\n\nFour of those steps are wire plumbing: decode, validate, cast, encode. Only the call to the\nservice function does anything domain-specific, and the body of that call is identical in\nboth handlers above.\n\nThe plumbing is per-transport, not per-endpoint. Every HTTP endpoint with a JSON body\ndecodes the same way, and every gRPC method unpacks its protobuf the same way. What changes\nendpoint to endpoint is only the input and output types of the service function.\n\nSo instead of writing the same wire plumbing in every handler, hoist it into one adapter per\ntransport:\n\n> [!Gist]\n>\n> - Every service function has the shape func(ctx context.Context, in In) (Out, error).\n>   In and Out are domain types. No transport type ever shows up in the signature.\n> - For each transport, write a generic adapter Wrap[In, Out]. It takes three things: a\n>   decode function that turns a wire request into In, the service function itself, and an\n>   encode function that turns Out into a wire response.\n> - Inside, Wrap decodes the request, runs Validate() on In if it has one, calls the\n>   service function, and encodes the result.\n> - Wrap returns the transport's handler shape. For HTTP that's http.Handler. For gRPC\n>   it's the function shape protoc-gen-go-grpc generates for server methods, so the\n>   wrapped function lives on the Server struct and the generated method forwards to it.\n> - Adding an endpoint costs one decode, one encode, and one router line per transport. The\n>   service function on the inside stays the same no matter which transport is calling it.\n\nThe same service-function shape feeds multiple transports:\n\nA few benefits of doing it this way are:\n\n> [!Important]\n>\n> - You write the four plumbing steps once per transport. A fix lands inside Wrap and\n>   applies to every endpoint at once.\n> - Every endpoint is the same shape: a service method, a decode, an encode, and a router\n>   line. Humans and LLMs pick that up from one example, and off-shape code won't compile.\n> - Tests split at two layers: the service function in unit tests with no transport, and\n>   Wrap plus its codecs once at the transport level. No per-handler plumbing tests.\n> - Middleware and interceptors compose unchanged. They sit outside Wrap, so auth,\n>   observability, and rate limiting go where they always did.\n> - Drift goes away. The same domain error returns the same status everywhere, and\n>   Validate runs on every input that has one.\n\nThis pattern isn't new.\n\n[go-kit] had it back in 2015 as Endpoint, a pre-generics adapter that every per-transport\nwrapper wrapped:\n\n[Connect-Go] uses the same shape as UnaryFunc over interface types, generating the\nwrappers from .proto files:\n\nMat Ryer's [How I write HTTP services after 13 years] arrives at the HTTP half from the\nother direction, with generic encode/decode helpers and a service that returns\n(Out, error).\n\nBelow I build a small greeter service from scratch and wrap it once for HTTP and once for\ngRPC. The full code is in the [wire-plumb] directory of the examples repo.\n\nWriting the service function\n\nThe service function holds the business logic. No transport types in its signature, so the\nsame function runs over both HTTP and gRPC.\n\nThe greeter takes a user store and a logger, loads the user, logs the call, and writes a\nmessage based on a formality flag:\n\nDrop the receiver from the highlighted line and what's left is\nfunc(ctx context.Context, in GreetIn) (GreetOut, error), the func(ctx, In) (Out, error)\nshape. Every service function in the project will match that line.\n\nYou could give the shape a name:\n\nI don't bother. I prefer to repeat func(ctx context.Context, in In) (Out, error) literally\nin Wrap's definition, so the shape is obvious wherever Wrap shows up, and every other\nfile stays plain non-generic Go.\n\nThis file imports neither net/http, google.golang.org/grpc, JSON, nor protobuf. New\ndependencies can land as fields on Service, but they stay there. The signature doesn't\nchange, so the wrappers don't need to know.\n\nGreetIn and GreetOut are plain Go structs with no transport-specific tags. In can\noptionally satisfy a Validate method, and the wrappers will run it between decode and\ncall:\n\n(NotFound and Invalid return a domain greet.Error carrying a Code enum that maps to\nHTTP statuses and gRPC codes. I covered the mapping in [Error translation in Go services].)\n\nWith the service function and its types pinned, the only thing left is the adapter that runs\ndecode, validate, call, and encode around them once per transport.\n\nWrapping it for HTTP\n\nWrap is the HTTP adapter. It's a generic function over [In, Out] that takes a\nper-endpoint decode(http.Request) (In, error), the service function in the middle, and a\nper-endpoint encode(http.ResponseWriter, Out) error. It returns an http.Handler ready to\nmount on a router.\n\nThe numbered comments mark the four plumbing steps:\n\nStructs without Validate skip step 2. The encode branch only logs on error: the response\nis already partially written by then. Every other branch goes through writeErr, which\nturns a domain greet.Error into an HTTP status and a JSON body.\n\nThat leaves decodeGreet and encodeGreet per endpoint. The http.Error calls, early\nreturns, and status codes from the original handler are gone. Decode parses the body into\nGreetIn. Encode writes the message back as JSON, wrapped in an anonymous struct so the\nwire field is message (lowercase) without putting a JSON tag on the domain type:\n\nThe UserID == 0 check sits on GreetIn.Validate and runs inside Wrap between decode and\ncall, so it doesn't reappear here. Both functions only do wire-to-domain mapping.\n\nRegister mounts every endpoint. The highlighted line is the wiring: Wrap chews the three\npieces (decode, service function, encode) and hands back an http.Handler that the mux can\nmount.\n\nmain ties it together. The highlighted line is the only place cmd/http knows about the\nHTTP package at all:\n\nsvc.Greet is a method value: the Service receiver gets bound into the function, so the\nwrapper sees a plain func(context.Context, GreetIn) (GreetOut, error). The store, the\nlogger, and anything else on Service ride along in the closure and never appear in the\nwrapper's signature.\n\nThe gRPC version uses that same svc.Greet. Only the wrapping around it changes.\n\nWrapping it for gRPC\n\nThe gRPC adapter has the same job: decode, validate, call, and encode around the service\nfunction. Two things change.\n\ngRPC's wire types are per-RPC. pb.GreetRequest and pb.GreetResponse belong to one\nspecific method, where http.Request and http.ResponseWriter were shared across every\nHTTP handler. So the gRPC Wrap carries two extra type parameters, WireIn and WireOut,\nsitting on either side of the domain In and Out.\n\nErrors come back as return values too, not bytes written to a stream, so every error branch\ncan return directly without a writeErr helper.\n\nThe function-typed arguments line up the same as before, with WireIn and WireOut\nstanding in for http.Request and http.ResponseWriter: decode(WireIn) (In, error), the\nsame service function, and encode(Out) (WireOut, error). The wrapper returns\nfunc(context.Context, WireIn) (WireOut, error), which is the signature\nprotoc-gen-go-grpc generates for every server method:\n\nSame four steps as the HTTP version, with the same svc.Greet doing the user lookup and\nlogging. statusErr turns a domain greet.Error into a status.Status carrying the\nmatching gRPC code.\n\nThe gRPC decodeGreet and encodeGreet are narrower than the HTTP versions because\nprotobuf has already parsed the bytes by the time Wrap sees the request. Decode copies\nfields from the generated struct into the domain type. Encode copies them back:\n\nUnlike the HTTP mux.Handle route, you can't hand the wrapped function straight to gRPC.\nprotoc-gen-go-grpc generates a server interface with concrete method signatures, so\nServer holds the wrapped functions as fields and the methods forward to them:\n\nThe gRPC package's Register follows the same shape, and so does main. The highlighted\nlines are the wiring points:\n\nEither way, svc.Greet runs unchanged. Only the decode and encode on either side of it\ndiffer between transports.\n\nAdding a second endpoint\n\nWith one endpoint wired through both transports, a second endpoint should be cheap. Adding a\nFarewell method costs three short pieces of code on the HTTP side and one extra line on\nthe router. The highlighted lines are the entire diff once Wrap exists:\n\nThe gRPC side follows the same shape: a decodeFarewell/encodeFarewell pair between\npb.FarewellRequest/pb.FarewellResponse and the domain types, plus one extra line in\nNewServer. The plumbing inside Wrap doesn't change.\n\nMiddleware and interceptors don't change\n\nMiddleware and interceptors don't see Wrap. They wrap the http.Handler or the gRPC\nserver method that Wrap returned, the same as any other handler. The HTTP signature stays\nfunc(http.Handler) http.Handler and the gRPC signature stays\ngrpc.UnaryServerInterceptor.\n\nA request logger as HTTP middleware. The highlighted line is where the middleware hands off\nto the wrapped mux:\n\nWired into main by wrapping the mux:\n\nThe gRPC version of the same logger. The highlighted line is the analogous hand-off into the\nwrapped server method:\n\nWired into the server constructor:\n\n\n\n\n[How I write HTTP services after 13 years]:\n    https://grafana.com/blog/how-i-write-http-services-in-go-after-13-years/\n\n[Error translation in Go services]:\n    /go/error-translation\n\n[go-kit]:\n    https://github.com/go-kit/kit/blob/78fbbceece7bbcf073bee814a7772f4397ea756c/endpoint/endpoint.go#L9\n\n[Connect-Go]:\n    https://github.com/connectrpc/connect-go/blob/c4aac92b87026cd709cfbccdaabe8c45abef705c/interceptor.go#L36\n\n[wire-plumb]:\n    https://github.com/rednafi/examples/tree/main/wire-plumb",
  "title": "Hoisting wire plumbing out of your Go handlers"
}