About go-grpc
A Go skill reference for building gRPC services and clients, intended for developers designing contract-first APIs. It emphasizes versioned protobuf contracts, meaningful status codes, deadline propagation, interceptors, streaming, and in-memory testing.
Key features
- 01Versioned protobuf contract design
- 02Semantic gRPC status codes
- 03Deadlines and context propagation
- 04Unary and stream interceptors
- 05Streaming and client configuration
- 06bufconn testing guidance
Use cases
- 01Designing versioned RPC interfaces
- 02Mapping domain errors to status codes
- 03Propagating deadlines across service calls
- 04Testing gRPC clients and servers without network
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ eduardo-sl/ go-agent-skills --skill go-grpcAfter installing, type/go-grpcin Claude Code to confirm it loaded.
Bundled filesreferences/clients.mdreferences/testing.md
Details
From the project READMEGo gRPC Services
gRPC's contract-first model only pays off if the contract is treated as an API: versioned packages, deliberate error codes, deadlines everywhere, and interceptors for everything cross-cutting.
Detailed reference material, loaded on demand:
references/clients.md—NewClient, service-config retries and load balancing, keepalive on both sides, reading status codes.references/testing.md— in-memorybufconnserver and client, asserting status codes.
Read a reference file only when the summary below is not enough.
1. Proto Design Rules
syntax = "proto3";
package payment.v1; // version IN the package
option go_package = "github.com/acme/payment-service/gen/payment/v1;paymentv1";
service PaymentService {
rpc CreatePayment(CreatePaymentRequest) returns (CreatePaymentResponse);
}
message CreatePaymentRequest { // one request/response pair
string order_id = 1; // per RPC, always — even if
int64 amount_cents = 2; // empty today
}
message CreatePaymentResponse {
Payment payment = 1;
}
- Version in the package (
payment.v1); breaking change =payment.v2. - Never reuse or renumber field tags;
reserved 3, 7;deleted ones. - Dedicated Request/Response messages per RPC — adding a field later is free; changing a shared message breaks every RPC using it.
- Generate with buf or a pinned protoc in
make generate; commit generated code so builds don't depend on toolchain drift. - In CI,
buf lintandbuf breaking --against '.git#branch=main'stop renumbered fields and removed RPCs before they reach a client. Field rules (lengths, ranges, formats) belong in protovalidate annotations, enforced by an interceptor.
2. Errors: Status Codes, Not Strings
Return status.Error, mapping domain errors in ONE place:
func (s *Server) CreatePayment(ctx context.Context, req *pb.CreatePaymentRequest) (*pb.CreatePaymentResponse, error) {
p, err := s.svc.Create(ctx, toDomain(req))
if err != nil {
return nil, toStatus(err)
}
return &pb.CreatePaymentResponse{Payment: fromDomain(p)}, nil
}
func toStatus(err error) error {
switch {
case errors.Is(err, domain.ErrNotFound):
return status.Error(codes.NotFound, "payment not found")
case errors.Is(err, domain.ErrDuplicate):
return status.Error(codes.AlreadyExists, "payment already exists")
case errors.Is(err, context.DeadlineExceeded):
return status.Error(codes.DeadlineExceeded, "timed out")
default:
return status.Error(codes.Internal, "internal error") // no details leak
}
}
Code semantics that matter: InvalidArgument (bad request regardless
of state), FailedPrecondition (bad state), NotFound, AlreadyExists,
Unauthenticated vs PermissionDenied, Unavailable (retryable),
Internal (bug). Clients read codes with status.FromError(err) —
never parse messages.
3. Deadlines Are Mandatory
// Client — every call gets a deadline
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
resp, err := client.CreatePayment(ctx, req)
// Server — check before expensive work
if err := ctx.Err(); err != nil {
return nil, status.FromContextError(err).Err()
}
The server inherits the client's deadline through the context. Pass
ctx into every downstream call (DB, other RPCs) so cancellation
propagates end to end.
4. Interceptors for Cross-Cutting Concerns
Handlers stay business-only; recovery, auth, logging, metrics live in interceptors:
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(
recoveryInterceptor, // outermost: panic → codes.Internal
loggingInterceptor,
authInterceptor,
),
)
func loggingInterceptor(ctx context.Context, req any,
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
start := time.Now()
resp, err := handler(ctx, req)
slog.InfoContext(ctx, "rpc",
slog.String("method", info.FullMethod),
slog.Duration("duration", time.Since(start)),
slog.String("code", status.Code(err).String()),
)
return resp, err
}
Order matters: recovery first (outermost), then observability, then
auth. Streaming RPCs need the parallel StreamInterceptor versions.
5. Streaming
- Server streaming for large result sets:
stream.Sendin a loop, return non-nil error to abort with a status. - Client/bidi streaming only when the protocol truly needs it — each open stream holds a goroutine and flow-control state.
- Always terminate on
ctx.Done():
func (s *Server) WatchPayments(req *pb.WatchRequest, stream pb.PaymentService_WatchPaymentsServer) error {
for {
select {
case <-stream.Context().Done():
return status.FromContextError(stream.Context().Err()).Err()
case ev := <-s.events:
if err := stream.Send(toProto(ev)); err != nil {
return err
}
}
}
}
6. Clients
grpc.NewClient(it replaces the deprecatedDial) once per target at startup; share the*grpc.ClientConn— it multiplexes and reconnects.- Retries, timeouts and load balancing in the service config: retry
UNAVAILABLEon idempotent methods only, and useround_robin(the defaultpick_firstsends everything to one backend). - Client keepalive pings must be allowed by the server's
EnforcementPolicy(defaultMinTime5 minutes), or the server closes the connection withtoo_many_pings. - Branch on
status.Code(err), never on the message.
Examples: references/clients.md.
7. Testing
Test through the generated client over bufconn: a real server with the
production interceptors, no network. Dial passthrough:///bufnet with
WithContextDialer — NewClient's default dns resolver would try to
resolve the name. Assert with status.Code(err). Example:
references/testing.md.
8. Production Server Setup
lis, err := net.Listen("tcp", cfg.Addr)
if err != nil {
return fmt.Errorf("listen: %w", err)
}
srv := grpc.NewServer(
grpc.Creds(creds), // TLS; mTLS for service-to-service
grpc.MaxRecvMsgSize(4<<20), // explicit; size it to the largest valid request
grpc.ChainUnaryInterceptor(...),
)
pb.RegisterPaymentServiceServer(srv, server)
healthSrv := health.NewServer() // grpc.health.v1 — load balancers need it
healthpb.RegisterHealthServer(srv, healthSrv)
reflection.Register(srv) // grpcurl/debugging; gate on non-prod if policy requires
go func() {
<-ctx.Done()
stopped := make(chan struct{})
go func() { srv.GracefulStop(); close(stopped) }()
select {
case <-stopped: // in-flight RPCs finished
case <-time.After(10 * time.Second):
srv.Stop() // force after grace period
}
}()
return srv.Serve(lis)
For mTLS, the server's tls.Config sets ClientCAs and
ClientAuth: tls.RequireAndVerifyClientCert; handlers read the caller's
verified identity from peer.FromContext(ctx) rather than trusting
metadata.
Verification Checklist
- Proto packages versioned (
*.v1); no tag reuse;reservedfor removals - Dedicated Request/Response message per RPC
- Generated code produced by a pinned tool (buf/protoc) and committed;
buf breakingruns in CI - All handler errors are
status.Errorwith semantically correct codes codes.Internalresponses never leak internal error text- Every client call has a deadline; ctx propagated through all layers
- Recovery, logging, auth implemented as chained interceptors (unary + stream)
- Streams select on
stream.Context().Done() - Health service registered; graceful stop with forced fallback; TLS (mTLS internally) and an explicit receive limit
grpcurlsmoke test (or generated client test) passes against the running server- One shared
ClientConnper target, built withgrpc.NewClient; retries limited toUNAVAILABLE