{
  "$type": "site.standard.document",
  "canonicalUrl": "https://rednafi.com/go/wrap-grpc-client/",
  "description": "How to wrap a generated gRPC client behind a clean Go API so users never have to touch protobuf types or connection management directly.",
  "path": "/go/wrap-grpc-client/",
  "publishedAt": "2026-03-15T00:00:00.000Z",
  "site": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
  "tags": [
    "Go",
    "gRPC",
    "API",
    "Distributed Systems"
  ],
  "textContent": "Yesterday I wrote a [shard on exploring the etcd codebase]. One of the things that stood out\nwas how the [clientv3 package] abstracts out the underlying gRPC machinery.\n\netcd is a distributed key-value store where the server and client communicate over gRPC. But\nif you've only ever used clientv3 and never peeked into the internals, you wouldn't know\nthat. You call resp, err := client.Put(ctx, \"key\", \"value\") and get back a PutResponse.\nIt feels like a regular Go library. The fact that gRPC and protobuf are involved is an\nimplementation detail that the client wrapper keeps away from you.\n\nI've been building a few gRPC services at work lately, and I keep running into the same\nquestion: what API do the users of my client library see? The server ships as a binary. The\nclient ships as a Go package that other teams go get. If I hand them the raw generated\ngRPC stubs, they have to import my protobuf types, manage gRPC connections, configure TLS,\nand parse codes.NotFound from google.golang.org/grpc/status. That's a lot of protocol\nplumbing for someone who just wants to consume my service.\n\nThis post walks through wrapping a generated gRPC client behind a higher level Go API,\nfollowing the same pattern etcd uses. The idea is to give the user a wrapper client that\nabstracts out the generated client.\n\nI'll use a small in-memory KV store as the running example.\n\nLayout\n\napi/ holds the proto and generated code. server/ is a binary you deploy. client/ is\nthe library you ship. Other teams add it to their go.mod and never touch proto types\ndirectly.\n\nDefining the service\n\nThe KV store has three RPCs: put, get, and delete.\n\nGetResponse uses optional bool found because proto3 normally can't distinguish \"field is\nzero\" from \"field was never set.\" The optional keyword generates a pointer in Go, which\nlets callers tell a missing key apart from an empty value.\n\nRunning protoc on this generates a client interface and a server stub. The client side\nlooks like this:\n\nEvery method takes a context.Context, a protobuf request struct, and variadic\ngrpc.CallOptions, and returns a protobuf response plus an error. Anyone calling the\nservice has to import protobuf types, construct request structs like &api.PutRequest{},\nand understand gRPC call options, even for a simple \"get this key\" call.\n\nThe server implements the other side with an in-memory map. What we care about for the\nwrapper is that it returns a gRPC NOT_FOUND status when a key doesn't exist. The wrapper\ntranslates that into a Go sentinel error. Here's the server code:\n\nThe server embeds UnimplementedKVServer, the standard gRPC pattern. It provides no-op\nimplementations for all RPCs so the code compiles even before you've written the real logic.\nThe Get method checks the map and returns codes.NotFound when the key isn't there. This\nis the status code the wrapper will catch and turn into a Go error. I've elided Put and\nDelete since they follow the same structure.\n\nUsing the generated client directly\n\nWithout a wrapper, callers use the generated KVClient directly. Pay attention to the\nimports:\n\nThree imports just to put a key. The caller manages the gRPC connection, constructs\n&api.PutRequest{} structs for every call, and has to parse gRPC status codes to check if a\nkey exists. For internal code where everyone knows gRPC, this is fine. For a library you\nship to other teams, it's a lot of ceremony.\n\nCalling the server with the wrapper\n\nThis is the API we actually want to give our users. Same sequence as before (put a key, get\nit back, handle a missing key) but without any gRPC or protobuf leaking through:\n\nOne import instead of three. No gRPC or protobuf packages in sight. Put takes a string and\na byte slice. Get returns []byte. Missing keys come back as client.ErrNotFound,\nchecked with errors.Is like any other Go error. The caller doesn't need to know that gRPC\nis involved at all.\n\n> [!NOTE]\n>\n> Callers never have to build an api.PutRequest, call grpc.NewClient, configure TLS, or\n> check codes.NotFound. They pass strings and byte slices, get Go errors back, and the\n> wrapper handles the rest.\n\nThe rest of this post builds the wrapper that turns the generated KVClient from the\nprevious section into this API.\n\nBuilding the wrapper\n\nThe client/ package is the only thing users import. It hides the generated api.KVClient\nbehind a struct and re-exposes the same operations using plain Go types. The whole wrapper\nlives in a single file (client/client.go).\n\nThe wrapper starts with a sentinel error and a testable interface:\n\nErrNotFound replaces the gRPC NOT_FOUND status code. Callers check it with errors.Is\nand never import google.golang.org/grpc/codes.\n\nClient implements KV, and KV uses only standard Go types instead of protobuf or gRPC\ntypes. This is intentionally a producer-side interface: we define it in the same package as\nClient because we know the full set of operations the service supports and we want to\noffer a ready-made contract for consumers. Other packages that depend on your client can\naccept a KV in their function signatures and swap in a simple in-memory fake during tests\nwithout spinning up a gRPC server or importing any gRPC packages.\n\n\n\n> [!IMPORTANT]\n>\n> KV is a producer-side interface. I wrote about when these make sense in [Revisiting\n> interface segregation in Go].\n\n\n\nThen the struct and constructor:\n\nClient holds the gRPC connection and the generated api.KVClient as unexported fields.\nNote that api.KVClient is an interface, not a concrete struct. The gRPC codegen doesn't\nexpose the actual client struct at all; you get back a KVClient interface from\napi.NewKVClient(conn). We store it as a regular field rather than embedding it. If you\nembedded the api.KVClient interface, all its methods like\nPut(ctx, PutRequest, ...CallOption) would be promoted onto Client directly, and callers\ncould bypass the wrapper to make raw gRPC calls.\n\n> [!WARNING]\n>\n> Don't embed the generated client interface. Keep it as a private field so the only way to\n> talk to the server is through the wrapper methods.\n\nNew creates the gRPC connection and builds the generated client from it. The variadic\ngrpc.DialOption lets callers pass custom TLS, keepalive, or interceptor config. If they\npass nothing, the default is insecure credentials for local dev. The retries section below\nshows what a production setup looks like.\n\nWith the types in place, we can look at the wrapper methods. Get shows the pattern all\nthree follow:\n\nEach wrapper method follows the same pattern: take the caller's Go arguments, build the\nprotobuf request internally, call the generated client, and return plain Go types.\n\nPay attention to the error handling. When the server returns NOT_FOUND, we catch that gRPC\nstatus and convert it to our own ErrNotFound sentinel so callers can check it with\nerrors.Is instead of parsing gRPC status codes themselves. For everything else, we wrap\nwith %v instead of %w. If we used %w, callers could unwrap the error with errors.As\nand reach the underlying gRPC status types, which would re-couple them to gRPC internals and\ndefeat the whole point of having a wrapper. I wrote about this tradeoff in [Go errors: to\nwrap or not to wrap?].\n\nPlugging in retries and metrics\n\nSince the wrapper owns the grpc.NewClient call, it can bake in retries and observability\nwithout the caller knowing. gRPC interceptors work like HTTP middleware. They wrap every RPC\nwith extra logic (logging, retries, metrics) without changing the handler code. You register\nthem as dial options when creating the connection:\n\n[grpc_retry] from [go-grpc-middleware] retries failed RPCs with exponential backoff.\n[grpcprom] records latency histograms and error rates. Same client.New, same c.Put, but\nnow with retries and metrics baked in. Callers who need to override the defaults can pass\ntheir own dial options. This is useful in tests where you might want insecure credentials or\nno retries.\n\nTry it yourself\n\nThe full code is on [GitHub]. Install the server and run the example:\n\nRunning the example will return:\n\nOr add the client library to your own project:\n\n\n\n\n[clientv3 package]:\n    https://github.com/etcd-io/etcd/tree/main/client/v3\n\n[Go errors: to wrap or not to wrap?]:\n    /go/to-wrap-or-not-to-wrap\n\n[GitHub]:\n    https://github.com/rednafi/examples/tree/main/wrapping-grpc-client\n\n[go-grpc-middleware]:\n    https://github.com/grpc-ecosystem/go-grpc-middleware\n\n[Revisiting interface segregation in Go]:\n    /go/interface-segregation\n\n[grpcprom]:\n    https://pkg.go.dev/github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus\n\n[grpc_retry]:\n    https://pkg.go.dev/github.com/grpc-ecosystem/go-grpc-middleware/retry\n\n[shard on exploring the etcd codebase]:\n    /shards/2026/03/etcd-codebase",
  "title": "Wrapping a gRPC client in Go"
}