background-shape
gRPC Basics in Go, From Proto to Production Server
March 2, 2023 · 6 min read · by Muhammad Amal programming

TL;DR — gRPC trades JSON readability for typed schemas, HTTP/2 multiplexing, and codegen across languages / In Go, the toolchain is protoc + protoc-gen-go + protoc-gen-go-grpc — pin versions or your CI will betray you / Start with unary RPCs; pick streaming only when the payload shape demands it.

I’ve spent the past few years untangling REST-over-JSON services that grew teeth — inconsistent field casing, optional-everything schemas, retries that double-charged customers. gRPC isn’t a silver bullet, but for internal service-to-service traffic it removes a class of bugs that REST tolerates by default. The schema is the contract, the wire format is binary, and the client stub is generated for you. That’s the pitch.

This post is the foundation for everything else I’ll publish this month. We’ll set up a minimal gRPC service in Go 1.20, generate code from a .proto file, and run a server that handles a unary call. I won’t talk about streaming, interceptors, or auth here — those each get their own post. The goal is a clean baseline you can extend.

If you’ve used gRPC before, skim to the “Common Pitfalls” section. If you haven’t, follow along — the tooling is fiddlier than the runtime.

Tooling You Actually Need

There are three binaries and two Go modules. People confuse them constantly.

  • protoc — the protobuf compiler. Install from the protobuf releases page, not via Homebrew if you care about reproducibility. Pin to v22.2 for March 2023.
  • protoc-gen-go — generates Go structs for messages. go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30.0.
  • protoc-gen-go-grpc — generates the gRPC service stubs. go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.3.0.

Then in your module:

go get google.golang.org/grpc@v1.53.0
go get google.golang.org/protobuf@v1.30.0

I keep a tools.go file under a // +build tools tag to track these as module dependencies. It saves arguments six months down the line when a new engineer regenerates code and gets a different output.

//go:build tools
// +build tools

package tools

import (
    _ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
    _ "google.golang.org/protobuf/cmd/protoc-gen-go"
)

The Proto File

A proto file describes messages and services. Both get compiled into Go. Start with a billing service — concrete, opinionated, and a domain that punishes loose typing.

syntax = "proto3";

package billing.v1;

option go_package = "github.com/muhammadamal/billing/gen/go/billing/v1;billingv1";

service InvoiceService {
  rpc GetInvoice(GetInvoiceRequest) returns (Invoice);
  rpc CreateInvoice(CreateInvoiceRequest) returns (Invoice);
}

message GetInvoiceRequest {
  string invoice_id = 1;
}

message CreateInvoiceRequest {
  string customer_id = 1;
  repeated LineItem items = 2;
  string currency = 3;
}

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

message Invoice {
  string invoice_id = 1;
  string customer_id = 2;
  int64 total_amount_cents = 3;
  string currency = 4;
  int64 created_at_unix = 5;
}

A few things I’m deliberate about:

  • Package name includes a version (billing.v1). You will break the schema someday; planning for v2 from day one is cheap.
  • go_package specifies both the import path and the local package alias. Skip the alias and the generated file uses billing_v1 which clashes with your handler package every time.
  • Amounts are int64 cents. Floats and money do not mix. Ever.
  • No optional fields here, but in proto3 you’ll want them for nullable semantics — optional was reintroduced in protoc 3.15 and is stable now.

Generate the code:

protoc \
  --go_out=. --go_opt=paths=source_relative \
  --go-grpc_out=. --go-grpc_opt=paths=source_relative \
  proto/billing/v1/invoice.proto

You’ll get invoice.pb.go (messages) and invoice_grpc.pb.go (service stub). I commit generated code. People disagree about this. My take: CI failures from regenerated code drift are a worse problem than a noisy diff. If you don’t commit it, at least have CI verify git diff --exit-code after regeneration.

A Minimal Server

The generated stub gives you an InvoiceServiceServer interface. Implement it, register it, serve over TCP. The whole thing in fifty lines:

package main

import (
    "context"
    "log"
    "net"
    "time"

    billingv1 "github.com/muhammadamal/billing/gen/go/billing/v1"
    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

type invoiceServer struct {
    billingv1.UnimplementedInvoiceServiceServer
}

func (s *invoiceServer) GetInvoice(
    ctx context.Context,
    req *billingv1.GetInvoiceRequest,
) (*billingv1.Invoice, error) {
    if req.GetInvoiceId() == "" {
        return nil, status.Error(codes.InvalidArgument, "invoice_id required")
    }
    return &billingv1.Invoice{
        InvoiceId:        req.GetInvoiceId(),
        CustomerId:       "cust_123",
        TotalAmountCents: 24999,
        Currency:         "USD",
        CreatedAtUnix:    time.Now().Unix(),
    }, nil
}

func main() {
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("listen: %v", err)
    }
    s := grpc.NewServer()
    billingv1.RegisterInvoiceServiceServer(s, &invoiceServer{})
    log.Println("invoice service listening on :50051")
    if err := s.Serve(lis); err != nil {
        log.Fatalf("serve: %v", err)
    }
}

Three things worth noting:

The embedded UnimplementedInvoiceServiceServer is the forward-compatibility shim. When you add an RPC to the proto file and regenerate, code that embedded the unimplemented type still compiles — the new method returns Unimplemented at runtime until you write it. Forgetting this embed is the most common cause of breakage when a teammate adds an RPC and your build snaps.

Return errors via the status package, not errors.New. The gRPC code is part of the wire contract — clients switch on codes.NotFound vs codes.InvalidArgument. A bare error becomes codes.Unknown, which tells the client nothing.

grpc.NewServer() accepts options. You’ll add interceptors, keepalive, and TLS later. For now, defaults are fine.

A Quick Client

For local testing, the same generated code gives you a client stub:

conn, err := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
    log.Fatal(err)
}
defer conn.Close()

client := billingv1.NewInvoiceServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

inv, err := client.GetInvoice(ctx, &billingv1.GetInvoiceRequest{InvoiceId: "inv_001"})
if err != nil {
    log.Fatalf("rpc: %v", err)
}
log.Printf("got invoice: %+v", inv)

Note: grpc.Dial is being deprecated in favor of grpc.NewClient in newer releases. As of v1.53, Dial is still the documented path. Watch the gRPC-Go release notes over the next few minors.

Always pass a context with a timeout. Always. A gRPC call with context.Background() will sit forever if the server hangs, and you’ll spend an afternoon staring at a goroutine dump wondering where the leak came from.

Common Pitfalls

A short list of things that have bitten me or someone on my team in the last year:

  • Mismatched protoc-gen-go and protobuf library versions. The generated code references runtime APIs. Skew the versions and you get cryptic interface mismatch errors at build time. Pin both in tools.go.
  • Forgetting to add paths=source_relative. Without it, generated files land in a Go-package-mirroring directory tree relative to --go_out. Hours of debugging directory structure that didn’t need to exist.
  • Using proto3 without thinking about defaults. Scalar fields default to zero values. An int32 status = 1 of 0 is indistinguishable from “not set.” If you need nullable, use optional or a wrapper type.
  • Reusing the same connection for unrelated services from different teams. A grpc.ClientConn is multiplexed over a single HTTP/2 connection. Fine for one service. Once you start sharing across services with different SLOs, head-of-line blocking issues will appear and they’re miserable to diagnose.
  • Not running grpcurl against your server. The reflection service plus grpcurl is the closest thing gRPC has to curl. Register reflection in development; it pays for itself the first time you need to poke at a service without writing a client.

Wrapping Up

That’s a working unary gRPC service in Go. The interesting parts — streaming, interceptors for auth and observability, deadlines that propagate across services, load balancing — all build on this. Next post in this series covers streaming RPCs and when bidirectional is actually the right shape for your problem. If you want to skip ahead to wiring up concurrency patterns on the server side, take a look at the post on goroutine patterns later this month.

The single most useful thing you can do today: take an existing internal REST endpoint, write the proto for it, generate the server, and run both side by side for a week. The exercise alone will surface schema assumptions you didn’t know you had.