How to wrap a generated gRPC client behind a clean Go API so users never have to touch protobuf types or connection management directly.
Yesterday I wrote a shard on exploring the etcd codebase. One of the things that stood out was how the clientv3 package abstracts out the underlying gRPC machinery.
etcd is a distributed key-value store where the server and client communicate over gRPC. But
if you’ve only ever used clientv3 and never peeked into the internals, you wouldn’t know
that. You call resp, err := client.Put(ctx, "key", "value") and get back a *PutResponse.
It feels like a regular Go library. The fact that gRPC and protobuf are involved is an
implementation detail that the client wrapper keeps away from you.
I’ve been building a few gRPC services at work lately, and I keep running into the same
question: what API do the users of my client library see? The server ships as a binary. The
client ships as a Go package that other teams go get. If I hand them the raw generated
gRPC stubs, they have to import my protobuf types, manage gRPC connections, configure TLS,
and parse codes.NotFound from google.golang.org/grpc/status. That’s a lot of protocol
plumbing for someone who just wants to consume my service.
This post walks through wrapping a generated gRPC client behind a higher level Go API, following the same pattern etcd uses. The idea is to give the user a wrapper client that abstracts out the generated client.
I’ll use a small in-memory KV store as the running example.
Layout
kv/
├── api/
│ ├── kv.proto # service definition
│ ├── kv.pb.go # generated message types
│ └── kv_grpc.pb.go # generated client and server stubs
├── client/
│ └── client.go # the wrapper (what users import)
├── server/
│ └── main.go # the server binary
└── go.mod
api/ holds the proto and generated code. server/ is a binary you deploy. client/ is
the library you ship. Other teams add it to their go.mod and never touch proto types
directly.
Defining the service
The KV store has three RPCs: put, get, and delete.
// api/kv.proto
syntax = "proto3";
package kvpb;
option go_package = "example.com/kv/api";
service KV {
rpc Put(PutRequest) returns (PutResponse);
rpc Get(GetRequest) returns (GetResponse);
rpc Delete(DeleteRequest) returns (DeleteResponse);
}
message PutRequest { string key = 1; bytes value = 2; }
message PutResponse {}
message GetRequest { string key = 1; }
message GetResponse { bytes value = 1; optional bool found = 2; }
message DeleteRequest { string key = 1; }
message DeleteResponse {}
GetResponse uses optional bool found because proto3 normally can’t distinguish “field is
zero” from “field was never set.” The optional keyword generates a pointer in Go, which
lets callers tell a missing key apart from an empty value.
Running protoc on this generates a client interface and a server stub. The client side
looks like this:
// api/kv_grpc.pb.go (generated)
type KVClient interface {
Put(ctx context.Context, in *PutRequest,
opts ...grpc.CallOption) (*PutResponse, error)
Get(ctx context.Context, in *GetRequest,
opts ...grpc.CallOption) (*GetResponse, error)
Delete(ctx context.Context, in *DeleteRequest,
opts ...grpc.CallOption) (*DeleteResponse, error)
}
Every method takes a context.Context, a protobuf request struct, and variadic
grpc.CallOptions, and returns a protobuf response plus an error. Anyone calling the
service has to import protobuf types, construct request structs like &api.PutRequest{},
and understand gRPC call options, even for a simple “get this key” call.
The server implements the other side with an in-memory map. What we care about for the
wrapper is that it returns a gRPC NOT_FOUND status when a key doesn’t exist. The wrapper
translates that into a Go sentinel error. Here’s the server code:
// server/main.go
type server struct {
kvpb.UnimplementedKVServer
data map[string][]byte
}
func (s *server) Get(
ctx context.Context, r *kvpb.GetRequest,
) (*kvpb.GetResponse, error) {
v, ok := s.data[r.Key]
if !ok {
return nil, status.Errorf(
codes.NotFound, "key %q", r.Key)
}
return &kvpb.GetResponse{
Value: v, Found: proto.Bool(true),
}, nil
}
// Put and Delete follow the same shape.
The server embeds UnimplementedKVServer, the standard gRPC pattern. It provides no-op
implementations for all RPCs so the code compiles even before you’ve written the real logic.
The Get method checks the map and returns codes.NotFound when the key isn’t there. This
is the status code the wrapper will catch and turn into a Go error. I’ve elided Put and
Delete since they follow the same structure.
Using the generated client directly
Without a wrapper, callers use the generated KVClient directly. Pay attention to the
imports:
// example/main.go (raw usage without wrapper)
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"example.com/kv/api"
)
// ...
conn, err := grpc.NewClient("localhost:9090",
grpc.WithTransportCredentials(insecure.NewCredentials()))
// ...
kv := api.NewKVClient(conn)
_, err = kv.Put(ctx, &api.PutRequest{
Key: "greeting", Value: []byte("hello"),
})
Three imports just to put a key. The caller manages the gRPC connection, constructs
&api.PutRequest{} structs for every call, and has to parse gRPC status codes to check if a
key exists. For internal code where everyone knows gRPC, this is fine. For a library you
ship to other teams, it’s a lot of ceremony.
Calling the server with the wrapper
This is the API we actually want to give our users. Same sequence as before (put a key, get it back, handle a missing key) but without any gRPC or protobuf leaking through:
// example/main.go (with the wrapper)
import "example.com/kv/client"
// ...
c, err := client.New("localhost:9090")
// ...
defer c.Close()
err = c.Put(ctx, "greeting", []byte("hello"))
val, err := c.Get(ctx, "greeting")
_, err = c.Get(ctx, "missing")
if errors.Is(err, client.ErrNotFound) { ... }
One import instead of three. No gRPC or protobuf packages in sight. Put takes a string and
a byte slice. Get returns []byte. Missing keys come back as client.ErrNotFound,
checked with errors.Is like any other Go error. The caller doesn’t need to know that gRPC
is involved at all.
Note
Callers never have to build an api.PutRequest, call grpc.NewClient, configure TLS, or
check codes.NotFound. They pass strings and byte slices, get Go errors back, and the
wrapper handles the rest.
The rest of this post builds the wrapper that turns the generated KVClient from the
previous section into this API.
Building the wrapper
The client/ package is the only thing users import. It hides the generated api.KVClient
behind a struct and re-exposes the same operations using plain Go types. The whole wrapper
lives in a single file (client/client.go).
The wrapper starts with a sentinel error and a testable interface:
// client/client.go
var ErrNotFound = errors.New("key not found")
type KV interface {
Put(ctx context.Context, key string, value []byte) error
Get(ctx context.Context, key string) ([]byte, error)
Delete(ctx context.Context, key string) error
}
ErrNotFound replaces the gRPC NOT_FOUND status code. Callers check it with errors.Is
and never import google.golang.org/grpc/codes.
Client implements KV, and KV uses only standard Go types instead of protobuf or gRPC
types. This is intentionally a producer-side interface: we define it in the same package as
Client because we know the full set of operations the service supports and we want to
offer a ready-made contract for consumers. Other packages that depend on your client can
accept a KV in their function signatures and swap in a simple in-memory fake during tests
without spinning up a gRPC server or importing any gRPC packages.
Important
KV is a producer-side interface. I wrote about when these make sense in Revisiting
interface segregation in Go.
Then the struct and constructor:
type Client struct {
conn *grpc.ClientConn
kv api.KVClient
}
func New(addr string, opts ...grpc.DialOption) (*Client, error) {
if len(opts) == 0 {
opts = []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
}
}
conn, err := grpc.NewClient(addr, opts...)
if err != nil {
return nil, fmt.Errorf("connecting to %s: %v", addr, err)
}
return &Client{conn: conn, kv: api.NewKVClient(conn)}, nil
}
func (c *Client) Close() error { return c.conn.Close() }
Client holds the gRPC connection and the generated api.KVClient as unexported fields.
Note that api.KVClient is an interface, not a concrete struct. The gRPC codegen doesn’t
expose the actual client struct at all; you get back a KVClient interface from
api.NewKVClient(conn). We store it as a regular field rather than embedding it. If you
embedded the api.KVClient interface, all its methods like
Put(ctx, *PutRequest, ...CallOption) would be promoted onto Client directly, and callers
could bypass the wrapper to make raw gRPC calls.
Warning
Don’t embed the generated client interface. Keep it as a private field so the only way to talk to the server is through the wrapper methods.
New creates the gRPC connection and builds the generated client from it. The variadic
grpc.DialOption lets callers pass custom TLS, keepalive, or interceptor config. If they
pass nothing, the default is insecure credentials for local dev. The retries section below
shows what a production setup looks like.
With the types in place, we can look at the wrapper methods. Get shows the pattern all
three follow:
func (c *Client) Get(ctx context.Context, key string) ([]byte, error) {
resp, err := c.kv.Get(ctx, &api.GetRequest{Key: key})
if err != nil {
if s, ok := status.FromError(err); ok &&
s.Code() == codes.NotFound {
return nil, ErrNotFound
}
return nil, fmt.Errorf(
"getting key %s: %v", key, err)
}
return resp.Value, nil
}
// Put and Delete follow the same shape.
Each wrapper method follows the same pattern: take the caller’s Go arguments, build the protobuf request internally, call the generated client, and return plain Go types.
Pay attention to the error handling. When the server returns NOT_FOUND, we catch that gRPC
status and convert it to our own ErrNotFound sentinel so callers can check it with
errors.Is instead of parsing gRPC status codes themselves. For everything else, we wrap
with %v instead of %w. If we used %w, callers could unwrap the error with errors.As
and reach the underlying gRPC status types, which would re-couple them to gRPC internals and
defeat the whole point of having a wrapper. I wrote about this tradeoff in Go errors: to
wrap or not to wrap?.
Plugging in retries and metrics
Since the wrapper owns the grpc.NewClient call, it can bake in retries and observability
without the caller knowing. gRPC interceptors work like HTTP middleware. They wrap every RPC
with extra logic (logging, retries, metrics) without changing the handler code. You register
them as dial options when creating the connection:
// client/client.go (production version of New)
func New(addr string, opts ...grpc.DialOption) (*Client, error) {
defaults := []grpc.DialOption{
grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})),
grpc.WithChainUnaryInterceptor(
grpc_retry.UnaryClientInterceptor(
grpc_retry.WithMax(3),
grpc_retry.WithBackoff(
grpc_retry.BackoffExponential(100*time.Millisecond),
),
),
grpcprom.UnaryClientInterceptor,
),
}
opts = append(defaults, opts...)
// ... rest is the same
}
grpc_retry from go-grpc-middleware retries failed RPCs with exponential backoff.
grpcprom records latency histograms and error rates. Same client.New, same c.Put, but
now with retries and metrics baked in. Callers who need to override the defaults can pass
their own dial options. This is useful in tests where you might want insecure credentials or
no retries.
Try it yourself
The full code is on GitHub. Install the server and run the example:
go install github.com/rednafi/examples/wrapping-grpc-client/server@latest
server &
go install github.com/rednafi/examples/wrapping-grpc-client/example@latest
example
Running the example will return:
put greeting=hello
get greeting=hello
get missing: not found (expected)
deleted greeting
get greeting after delete: not found (expected)
Or add the client library to your own project:
go get github.com/rednafi/examples/wrapping-grpc-client/client@latest