Step 12 of 24 · optional

gRPC services

Define a service in Protocol Buffers, generate Go code, implement server and client with deadlines, status codes, interceptors and streaming.

18 min 10 sections

REST with JSON is the right default for public APIs: every language and tool speaks it. Between your own services, many Go teams use gRPC instead. You describe the API once in a .proto file, generate typed client and server code for every language, and get compact binary messages over HTTP/2 with deadlines and streaming built in. This lesson builds an orders service with gRPC in Go, and ends with when REST is still the better choice.

What gRPC gives you

gRPC is a remote procedure call framework: the client calls a method on a generated stub as if it were local, and the framework handles encoding, transport and errors.

REST + JSONgRPC
ContractOpenAPI, often written after the code.proto file is the source of truth; code is generated from it
EncodingJSON textProtocol Buffers binary: smaller and faster to parse
TransportHTTP/1.1 or HTTP/2HTTP/2 only (multiplexed, long-lived connections)
StreamingServer-sent events or WebSockets, bolted onClient, server and bidirectional streams built in
Browser supportNativeNeeds a proxy or gRPC-Web/Connect
Debuggingcurl and readable payloadsNeeds tools such as grpcurl

The headline benefit isn't speed, though it is faster. It's that the contract is enforced by the compiler across every service and language that uses it.

Defining the service

Create proto/orders/v1/orders.proto:

proto
syntax = "proto3";

package orders.v1;

option go_package = "example.com/shop/gen/orders/v1;ordersv1";

import "google/protobuf/timestamp.proto";

service OrderService {
  rpc GetOrder(GetOrderRequest) returns (GetOrderResponse);
  rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse);
  rpc WatchOrder(WatchOrderRequest) returns (stream OrderEvent);
}

message Order {
  string id = 1;
  string customer_id = 2;
  repeated LineItem items = 3;
  int64 total_cents = 4;
  Status status = 5;
  google.protobuf.Timestamp created_at = 6;
}

message LineItem {
  string sku = 1;
  int32 quantity = 2;
}

enum Status {
  STATUS_UNSPECIFIED = 0;
  STATUS_PENDING = 1;
  STATUS_PAID = 2;
  STATUS_SHIPPED = 3;
}

message GetOrderRequest { string id = 1; }
message GetOrderResponse { Order order = 1; }

message CreateOrderRequest {
  string customer_id = 1;
  repeated LineItem items = 2;
}
message CreateOrderResponse { Order order = 1; }

message WatchOrderRequest { string id = 1; }
message OrderEvent {
  Status status = 1;
  google.protobuf.Timestamp at = 2;
}

The numbers are field tags, and they're what goes on the wire, not the names. That gives you the rules for evolving a schema without breaking deployed clients:

  • Never change or reuse a field's number. Removed a field? Add reserved 7; so nobody reuses it.
  • Adding fields is safe: old readers ignore unknown fields, and new readers see the zero value when an old writer omits it.
  • In proto3, every field has a zero value, so "not set" and "set to 0" look the same. Enums start with an _UNSPECIFIED = 0 value for that reason. Mark a scalar optional when you need to tell them apart.
  • Put a version in the package (orders.v1). A breaking change becomes orders.v2, served alongside v1 while clients migrate.
  • Give every RPC its own request and response message, even if one has a single field. You can add fields later without changing the method signature.

Generating Go code

Install the compiler plugins and generate:

bash
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

mkdir -p gen
protoc -I proto \
  --go_out=gen --go_opt=paths=source_relative \
  --go-grpc_out=gen --go-grpc_opt=paths=source_relative \
  proto/orders/v1/orders.proto

This needs protoc itself (from your package manager). Many teams use buf instead, which adds linting and a check that fails your build on breaking schema changes. Either way you get two files: orders.pb.go with the message types and orders_grpc.pb.go with a client and a server interface. Commit the generated code, or generate it in CI; just be consistent.

Implementing the server

Implement the generated OrderServiceServer interface. Embedding UnimplementedOrderServiceServer makes methods you haven't written yet return Unimplemented, so adding an RPC to the proto doesn't break compilation of older servers:

go
package main

import (
	"context"
	"errors"
	"log"
	"net"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/types/known/timestamppb"

	ordersv1 "example.com/shop/gen/orders/v1"
)

type orderServer struct {
	ordersv1.UnimplementedOrderServiceServer
	store *Store
}

func (s *orderServer) GetOrder(ctx context.Context, req *ordersv1.GetOrderRequest) (*ordersv1.GetOrderResponse, error) {
	if req.GetId() == "" {
		return nil, status.Error(codes.InvalidArgument, "id is required")
	}
	o, err := s.store.Get(ctx, req.GetId())
	if errors.Is(err, ErrNotFound) {
		return nil, status.Errorf(codes.NotFound, "order %s not found", req.GetId())
	}
	if err != nil {
		return nil, status.Error(codes.Internal, "could not load order")
	}
	return &ordersv1.GetOrderResponse{Order: toProto(o)}, nil
}

// toProto converts the domain type to the wire type in one place.
func toProto(o Order) *ordersv1.Order {
	items := make([]*ordersv1.LineItem, len(o.Items))
	for i, it := range o.Items {
		items[i] = &ordersv1.LineItem{Sku: it.SKU, Quantity: it.Quantity}
	}
	return &ordersv1.Order{
		Id:         o.ID,
		CustomerId: o.CustomerID,
		Items:      items,
		TotalCents: o.TotalCents,
		Status:     o.Status,
		CreatedAt:  timestamppb.New(o.CreatedAt),
	}
}

func main() {
	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatal(err)
	}
	srv := grpc.NewServer(grpc.ChainUnaryInterceptor(logUnary)) // logUnary is defined below
	ordersv1.RegisterOrderServiceServer(srv, &orderServer{store: NewStore()})
	log.Fatal(srv.Serve(lis))
}

Order and Store are your own domain types; toProto keeps the generated types at the edge of the service instead of leaking them into business logic. Use the generated GetX() getters rather than fields: they're nil-safe, so req.GetOrder().GetId() returns "" instead of panicking when Order is missing.

Status codes

gRPC errors carry a code, much like HTTP status codes. Return them deliberately; clients and retry policies depend on them:

CodeUse whenRough HTTP equivalent
InvalidArgumentThe request is malformed400
NotFoundThe resource doesn't exist404
AlreadyExistsA create conflicts409
PermissionDenied / UnauthenticatedNot allowed / no valid credentials403 / 401
FailedPreconditionValid request, wrong state (shipping an unpaid order)400
AbortedA concurrency conflict, such as a failed optimistic lock; retry the whole operation409
UnavailableTemporarily down; safe to retry503
DeadlineExceededRan out of time504
InternalA bug or unexpected failure500

The HTTP column follows Google's standard mapping, which tools such as grpc-gateway use. Never return a raw Go error: it becomes Unknown, and its message (database hostnames, SQL errors) is sent to the client verbatim. Map domain errors to codes in one place, as above.

The client and deadlines

go
conn, err := grpc.NewClient("localhost:50051",
	grpc.WithTransportCredentials(insecure.NewCredentials())) // TLS in production
if err != nil {
	log.Fatal(err)
}
defer conn.Close()

client := ordersv1.NewOrderServiceClient(conn)

ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()

resp, err := client.GetOrder(ctx, &ordersv1.GetOrderRequest{Id: "o_123"})
if err != nil {
	switch status.Code(err) {
	case codes.NotFound:
		fmt.Println("no such order")
	case codes.DeadlineExceeded:
		fmt.Println("orders service too slow")
	default:
		fmt.Println("error:", err)
	}
	return
}
fmt.Println(resp.GetOrder().GetTotalCents())

grpc.NewClient doesn't connect immediately; it connects on first use and reconnects automatically. Create one *grpc.ClientConn per target at startup and share it: it multiplexes all calls over HTTP/2 connections.

Always set a deadline. gRPC sends the deadline to the server in a header, and the server's ctx is cancelled when it passes. If the orders service then calls the inventory service with the same ctx, the remaining time propagates automatically. A request with no deadline can hang forever when a downstream service stalls.

Interceptors

Interceptors are gRPC's middleware. A unary server interceptor wraps every non-streaming call, which makes it the place for logging, metrics, authentication and panic recovery:

go
func logUnary(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",
		"method", info.FullMethod,
		"code", status.Code(err).String(),
		"duration", time.Since(start))
	return resp, err
}

Metadata (gRPC's headers) carries things like bearer tokens: read it with metadata.FromIncomingContext(ctx) in an auth interceptor, and return codes.Unauthenticated when it's missing. For tracing and metrics, use the OpenTelemetry gRPC instrumentation rather than writing your own.

Streaming

WatchOrder returns stream OrderEvent: the server sends any number of messages on one call. The generated handler gets a stream to Send on:

go
func (s *orderServer) WatchOrder(req *ordersv1.WatchOrderRequest, stream grpc.ServerStreamingServer[ordersv1.OrderEvent]) error {
	updates := s.store.Subscribe(req.GetId())
	defer s.store.Unsubscribe(req.GetId(), updates)
	for {
		select {
		case <-stream.Context().Done():
			return stream.Context().Err() // gRPC reports this as Canceled or DeadlineExceeded
		case st, ok := <-updates:
			if !ok {
				return nil // the store closed the subscription: end the stream cleanly
			}
			if err := stream.Send(&ordersv1.OrderEvent{Status: st, At: timestamppb.Now()}); err != nil {
				return err
			}
		}
	}
}

The client calls stream, err := client.WatchOrder(ctx, req) and loops on stream.Recv() until it returns io.EOF. Client streaming (rpc Upload(stream Chunk) returns (Summary)) and bidirectional streaming follow the same pattern in the other direction. Long-lived streams should still have a deadline or a way to be cancelled.

When to choose REST, gRPC or both

  • Public APIs and browser clients: REST with JSON. Everybody can call it, and caches, proxies and browsers understand it.
  • Service-to-service inside your system, especially across languages or with streaming: gRPC.
  • Both from one definition: grpc-gateway generates a REST/JSON proxy from annotations in the proto, and the Connect protocol (connectrpc.com/connect) serves gRPC, gRPC-Web and plain JSON-over-HTTP from the same Go handlers, which browsers can call directly.

Don't adopt gRPC for a single monolith with a React frontend; the generation step and tooling cost buys you nothing there.

Practice

  1. Write the proto above, generate the code, and implement GetOrder and CreateOrder with an in-memory store. CreateOrder must return InvalidArgument when items is empty or any quantity is below 1.
  2. Add an auth interceptor that requires metadata authorization: Bearer dev-token and returns Unauthenticated otherwise.
  3. Write a client that creates an order and fetches it, with a 1-second deadline.
  4. Add reserved handling: remove customer_id from Order, reserve its number and name, and regenerate.
Show solution
go
func (s *orderServer) CreateOrder(ctx context.Context, req *ordersv1.CreateOrderRequest) (*ordersv1.CreateOrderResponse, error) {
	if len(req.GetItems()) == 0 {
		return nil, status.Error(codes.InvalidArgument, "items must not be empty")
	}
	for i, it := range req.GetItems() {
		if it.GetQuantity() < 1 {
			return nil, status.Errorf(codes.InvalidArgument, "items[%d].quantity must be at least 1", i)
		}
	}
	items := make([]LineItem, len(req.GetItems()))
	for i, it := range req.GetItems() {
		items[i] = LineItem{SKU: it.GetSku(), Quantity: it.GetQuantity()}
	}
	o := s.store.Create(ctx, req.GetCustomerId(), items)
	return &ordersv1.CreateOrderResponse{Order: toProto(o)}, nil
}

func authUnary(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
	md, _ := metadata.FromIncomingContext(ctx)
	if v := md.Get("authorization"); len(v) == 0 || v[0] != "Bearer dev-token" {
		return nil, status.Error(codes.Unauthenticated, "missing or invalid token")
	}
	return handler(ctx, req)
}

// server: grpc.NewServer(grpc.ChainUnaryInterceptor(logUnary, authUnary))
go
// client
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer dev-token")

created, err := client.CreateOrder(ctx, &ordersv1.CreateOrderRequest{
	CustomerId: "c_1",
	Items:      []*ordersv1.LineItem{{Sku: "KB-01", Quantity: 1}},
})
if err != nil {
	log.Fatal(err)
}
got, err := client.GetOrder(ctx, &ordersv1.GetOrderRequest{Id: created.GetOrder().GetId()})
if err != nil {
	log.Fatal(err)
}
fmt.Println(got.GetOrder().GetId(), got.GetOrder().GetStatus()) // o_1 STATUS_PENDING
proto
message Order {
  reserved 2;
  reserved "customer_id";
  string id = 1;
  repeated LineItem items = 3;
  int64 total_cents = 4;
  Status status = 5;
  google.protobuf.Timestamp created_at = 6;
}

After regenerating, the Go code that set CustomerId on Order stops compiling, which is the point: the compiler finds every place that relied on the removed field. Reserving the number stops anyone from reusing 2 for a different field, which would make old clients misread new messages.

Key takeaways

  • gRPC generates typed clients and servers from a .proto contract and sends compact Protocol Buffers over HTTP/2.
  • Field numbers are the wire format: never reuse them, reserve removed ones, and version packages for breaking changes.
  • Return explicit status codes such as NotFound and InvalidArgument; never leak raw errors as Unknown.
  • Share one ClientConn per target, and set a deadline on every call so timeouts propagate through the call chain.
  • Use interceptors for cross-cutting concerns, and prefer REST (or Connect/grpc-gateway alongside gRPC) for browsers and public APIs.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.You delete a field from a proto message. What should you do with its number?

  2. 2.A gRPC handler returns errors.New("db timeout") directly. What does the client see?

  3. 3.Why should a client set a deadline on every gRPC call?

  4. 4.Which situation fits gRPC least well?

Finished reading?

Mark it complete to fill in your roadmap.