Hoisting wire plumbing out of your Go handlers
Table of contents
Consider an HTTP handler:
func handleGreet(w http.ResponseWriter, r *http.Request) {
var body struct {
UserID int64 `json:"user_id"`
Formality int `json:"formality"`
}
// 1. decode
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// 2. validate
if body.UserID == 0 {
http.Error(w, "user_id required", http.StatusBadRequest)
return
}
// 3. cast
in := greet.GreetIn{UserID: body.UserID, Formality: body.Formality}
// 4. call service function
out, err := svc.Greet(r.Context(), in)
if err != nil {
writeErr(w, err)
return
}
// 5. encode
json.NewEncoder(w).Encode(out)
}
And a gRPC handler for the same operation:
func (s *Server) Greet(
ctx context.Context, req *pb.GreetRequest,
) (*pb.GreetResponse, error) {
// 1. validate
if req.GetUserId() == 0 {
return nil, status.Error(codes.InvalidArgument, "user_id required")
}
// 2. cast
in := greet.GreetIn{
UserID: req.GetUserId(), Formality: int(req.GetFormality()),
}
// 3. call service function
out, err := s.svc.Greet(ctx, in)
if err != nil {
return nil, statusErr(err)
}
// 4. encode
return &pb.GreetResponse{Message: out.Message}, nil
}
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).InandOutare 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 intoIn, the service function itself, and an encode function that turnsOutinto a wire response. - Inside,
Wrapdecodes the request, runsValidate()onInif it has one, calls the service function, and encodes the result. Wrapreturns the transport’s handler shape. For HTTP that’shttp.Handler. For gRPC it’s the function shapeprotoc-gen-go-grpcgenerates for server methods, so the wrapped function lives on theServerstruct 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:
service function adapter transport handler
----------------------------------------------------------------
func(ctx, In) (Out, error) -> http Wrap -> http.Handler
func(ctx, In) (Out, error) -> grpc Wrap -> gRPC handler
A few benefits of doing it this way are:
Important
- You write the four plumbing steps once per transport. A fix lands inside
Wrapand 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
Wrapplus 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
Validateruns 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:
type Endpoint func(
ctx context.Context, request interface{},
) (response interface{}, err error)
Connect-Go
uses the same shape as UnaryFunc over interface types, generating the
wrappers from .proto files:
type UnaryFunc func(context.Context, AnyRequest) (AnyResponse, error)
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:
// greet/service.go
type Service struct {
users UserStore
logger *slog.Logger
}
func NewService(users UserStore, logger *slog.Logger) *Service {
return &Service{users: users, logger: logger}
}
func (s *Service) Greet(ctx context.Context, in GreetIn) (GreetOut, error) {
u, err := s.users.GetUser(ctx, in.UserID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return GreetOut{}, NotFound("user %d", in.UserID)
}
return GreetOut{}, fmt.Errorf("getting user: %w", err)
}
s.logger.Info("greeted", "user_id", u.ID, "formality", in.Formality)
msg := "hey " + u.Name + "!"
if in.Formality == 1 {
msg = "Good day, " + u.Name + "."
}
return GreetOut{Message: msg}, nil
}
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:
type SvcFunc[In, Out any] func(ctx context.Context, in In) (Out, error)
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:
// greet/service.go
type GreetIn struct {
UserID int64
Formality int
}
func (in GreetIn) Validate() error {
if in.UserID == 0 {
return Invalid("user_id is required")
}
if in.Formality < 0 || in.Formality > 1 {
return Invalid("formality must be 0 or 1")
}
return nil
}
type GreetOut struct{ Message string }
(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:
// http/http.go
func Wrap[In, Out any](
decode func(*http.Request) (In, error),
fn func(context.Context, In) (Out, error),
encode func(http.ResponseWriter, Out) error,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
in, err := decode(r) // (1)
if err != nil {
writeErr(w, err)
return
}
if v, ok := any(in).(validator); ok {
if err := v.Validate(); err != nil { // (2)
writeErr(w, err)
return
}
}
out, err := fn(r.Context(), in) // (3)
if err != nil {
writeErr(w, err)
return
}
if err := encode(w, out); err != nil { // (4)
log.Printf("encode response: %v", err)
}
})
}
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:
// http/http.go
func decodeGreet(r *http.Request) (greet.GreetIn, error) {
var body struct {
UserID int64 `json:"user_id"`
Formality int `json:"formality"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
return greet.GreetIn{}, greet.Invalid("malformed json")
}
return greet.GreetIn{UserID: body.UserID, Formality: body.Formality}, nil
}
func encodeGreet(w http.ResponseWriter, out greet.GreetOut) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
return json.NewEncoder(w).Encode(struct {
Message string `json:"message"`
}{out.Message})
}
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.
// http/http.go
func Register(mux *http.ServeMux, svc *greet.Service) {
mux.Handle("POST /greet", Wrap(decodeGreet, svc.Greet, encodeGreet))
}
main ties it together. The highlighted line is the only place cmd/http knows about the
HTTP package at all:
// cmd/http/main.go
func main() {
users := greet.NewMemoryStore(
greet.User{ID: 1, Name: "red"},
greet.User{ID: 2, Name: "blue"},
)
svc := greet.NewService(users, slog.Default())
mux := http.NewServeMux()
ehttp.Register(mux, svc)
log.Fatal(http.ListenAndServe(":8080", mux))
}
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:
// grpc/grpc.go
func Wrap[WireIn, In, Out, WireOut any](
decode func(WireIn) (In, error),
fn func(context.Context, In) (Out, error),
encode func(Out) (WireOut, error),
) func(context.Context, WireIn) (WireOut, error) {
return func(ctx context.Context, wireIn WireIn) (WireOut, error) {
var zero WireOut
in, err := decode(wireIn) // (1)
if err != nil {
return zero, statusErr(err)
}
if v, ok := any(in).(validator); ok {
if err := v.Validate(); err != nil { // (2)
return zero, statusErr(err)
}
}
out, err := fn(ctx, in) // (3)
if err != nil {
return zero, statusErr(err)
}
return encode(out) // (4)
}
}
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:
// grpc/grpc.go
func decodeGreet(req *pb.GreetRequest) (greet.GreetIn, error) {
return greet.GreetIn{
UserID: req.GetUserId(),
Formality: int(req.GetFormality()),
}, nil
}
func encodeGreet(out greet.GreetOut) (*pb.GreetResponse, error) {
return &pb.GreetResponse{Message: out.Message}, nil
}
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:
// grpc/grpc.go
type Server struct {
pb.UnimplementedGreeterServer
greet func(context.Context, *pb.GreetRequest) (*pb.GreetResponse, error)
}
func NewServer(svc *greet.Service) *Server {
return &Server{
greet: Wrap(decodeGreet, svc.Greet, encodeGreet),
}
}
func (s *Server) Greet(
ctx context.Context, req *pb.GreetRequest,
) (*pb.GreetResponse, error) {
return s.greet(ctx, req)
}
The gRPC package’s Register follows the same shape, and so does main. The highlighted
lines are the wiring points:
// grpc/grpc.go
func Register(srv *grpc.Server, svc *greet.Service) {
pb.RegisterGreeterServer(srv, NewServer(svc))
}
// cmd/grpc/main.go
func main() {
users := greet.NewMemoryStore(
greet.User{ID: 1, Name: "red"},
greet.User{ID: 2, Name: "blue"},
)
svc := greet.NewService(users, slog.Default())
srv := grpc.NewServer()
egrpc.Register(srv, svc)
lis, _ := net.Listen("tcp", ":9090")
log.Fatal(srv.Serve(lis))
}
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:
// greet/service.go
func (s *Service) Farewell(
ctx context.Context, in FarewellIn,
) (FarewellOut, error) {
// ...
}
// http/http.go
func decodeFarewell(r *http.Request) (greet.FarewellIn, error) {
// ...
}
func encodeFarewell(w http.ResponseWriter, out greet.FarewellOut) error {
// ...
}
func Register(mux *http.ServeMux, svc *greet.Service) {
mux.Handle("POST /greet",
Wrap(decodeGreet, svc.Greet, encodeGreet))
mux.Handle("POST /farewell",
Wrap(decodeFarewell, svc.Farewell, encodeFarewell))
}
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:
// cmd/http/main.go
func RequestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s took=%s", r.Method, r.URL.Path, time.Since(start))
})
}
Wired into main by wrapping the mux:
// cmd/http/main.go
func main() {
users := greet.NewMemoryStore(
greet.User{ID: 1, Name: "red"},
greet.User{ID: 2, Name: "blue"},
)
svc := greet.NewService(users, slog.Default())
mux := http.NewServeMux()
ehttp.Register(mux, svc)
log.Fatal(http.ListenAndServe(":8080", RequestLogger(mux)))
}
The gRPC version of the same logger. The highlighted line is the analogous hand-off into the wrapped server method:
// cmd/grpc/main.go
func LoggingInterceptor(
ctx context.Context, req any,
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler,
) (any, error) {
start := time.Now()
resp, err := handler(ctx, req)
log.Printf("%s took=%s err=%v", info.FullMethod, time.Since(start), err)
return resp, err
}
Wired into the server constructor:
// cmd/grpc/main.go
func main() {
users := greet.NewMemoryStore(
greet.User{ID: 1, Name: "red"},
greet.User{ID: 2, Name: "blue"},
)
svc := greet.NewService(users, slog.Default())
srv := grpc.NewServer(grpc.UnaryInterceptor(LoggingInterceptor))
egrpc.Register(srv, svc)
lis, _ := net.Listen("tcp", ":9090")
log.Fatal(srv.Serve(lis))
}