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.
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 + JSON | gRPC | |
|---|---|---|
| Contract | OpenAPI, often written after the code | .proto file is the source of truth; code is generated from it |
| Encoding | JSON text | Protocol Buffers binary: smaller and faster to parse |
| Transport | HTTP/1.1 or HTTP/2 | HTTP/2 only (multiplexed, long-lived connections) |
| Streaming | Server-sent events or WebSockets, bolted on | Client, server and bidirectional streams built in |
| Browser support | Native | Needs a proxy or gRPC-Web/Connect |
| Debugging | curl and readable payloads | Needs 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:
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 = 0value for that reason. Mark a scalaroptionalwhen you need to tell them apart. - Put a version in the package (
orders.v1). A breaking change becomesorders.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:
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.protoThis 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:
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:
| Code | Use when | Rough HTTP equivalent |
|---|---|---|
InvalidArgument | The request is malformed | 400 |
NotFound | The resource doesn't exist | 404 |
AlreadyExists | A create conflicts | 409 |
PermissionDenied / Unauthenticated | Not allowed / no valid credentials | 403 / 401 |
FailedPrecondition | Valid request, wrong state (shipping an unpaid order) | 400 |
Aborted | A concurrency conflict, such as a failed optimistic lock; retry the whole operation | 409 |
Unavailable | Temporarily down; safe to retry | 503 |
DeadlineExceeded | Ran out of time | 504 |
Internal | A bug or unexpected failure | 500 |
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
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:
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:
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-gatewaygenerates 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
- Write the proto above, generate the code, and implement
GetOrderandCreateOrderwith an in-memory store.CreateOrdermust returnInvalidArgumentwhenitemsis empty or any quantity is below 1. - Add an auth interceptor that requires metadata
authorization: Bearer dev-tokenand returnsUnauthenticatedotherwise. - Write a client that creates an order and fetches it, with a 1-second deadline.
- Add
reservedhandling: removecustomer_idfromOrder, reserve its number and name, and regenerate.
Show solution
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))// 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_PENDINGmessage 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
.protocontract 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
NotFoundandInvalidArgument; never leak raw errors asUnknown. - Share one
ClientConnper 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.You delete a field from a proto message. What should you do with its number?
2.A gRPC handler returns errors.New("db timeout") directly. What does the client see?
3.Why should a client set a deadline on every gRPC call?
4.Which situation fits gRPC least well?
Finished reading?
Mark it complete to fill in your roadmap.