When scaling backend architecture, the communication layer between services eventually
becomes a bottleneck. While REST over HTTP/1.1 with JSON payloads is the undisputed
standard for public-facing web APIs, it introduces significant serialization overhead
and lacks strict, enforceable contracts for internal, service-to-service communication.
This is where gRPC shines. By leveraging HTTP/2 for multiplexed transport and Protocol
Buffers (Protobuf) for efficient binary serialization, gRPC provides low-latency,
strictly-typed communication well-suited to internal microservice architectures.
However, gRPC is not a silver bullet. It introduces operational complexity — particularly
around Layer 7 load balancing and debugging binary traffic. This guide strips away the
hype and walks through building a production-ready gRPC client and server in Go, complete
with middleware (interceptors) and server-side streaming.
To make the examples concrete and representative of real-world systems, we will build
a three-tier Pharmacy Inventory service:
┌────────────┐ gRPC ┌──────────────┐ HTTP/JSON ┌──────────────────┐
│ gRPC Client│ ────────────► │ gRPC Server │ ────────────────► │ REST Inventory │
│ (consumer) │ │ :50051 │ │ API :8080 │
└────────────┘ └──────────────┘ └──────────────────┘
This architecture is intentionally realistic: the gRPC layer provides a strict,
typed contract to internal consumers, while delegating actual data retrieval to a
REST backend — exactly as you would find in a system that wraps an existing REST
service or third-party API with a high-performance internal interface.
Prerequisites and Workspace Setup
Ensure your development environment is on a current Go release (1.22 or later, as the
examples use the enhanced net/http routing patterns introduced in that version). You
will also need the Protocol Buffers compiler (protoc) and its Go code-generation
plugins.
Install the required Go plugins:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
Initialize your module:
mkdir grpc-inventory
cd grpc-inventory
go mod init github.com/murtuza/grpc-inventory
go get google.golang.org/grpc
go get google.golang.org/protobuf
Project Structure
Before writing any code, it helps to see the layout we are building toward:
grpc-inventory/
├── proto/
│ └── inventory.proto # Service contract (single source of truth)
├── proto/inventory/
│ ├── inventory.pb.go # Generated: Protobuf message types
│ └── inventory_grpc.pb.go # Generated: gRPC service interfaces
├── rest-api/
│ └── main.go # REST inventory data layer (:8080)
├── server/
│ └── main.go # gRPC server — calls the REST API (:50051)
├── client/
│ └── main.go # gRPC client — consumes the gRPC server
└── go.mod
Step 1: Defining the Strict Contract (Protobuf)
The foundation of any gRPC service is its interface definition. Protobuf acts as a
strict contract between client and server — if the implementations do not match the
.proto file, the code will not compile.
Create proto/inventory.proto:
syntax = "proto3";
package inventory;
option go_package = "github.com/murtuza/grpc-inventory/proto/inventory";
// The Pharmacy Inventory service definition.
service InventoryService {
// Unary RPC: Fetch a single product's stock level.
rpc CheckStock (StockRequest) returns (StockResponse);
// Server-Streaming RPC: Push all items currently running low on stock.
rpc StreamLowStock (EmptyRequest) returns (stream StockResponse);
}
message EmptyRequest {}
message StockRequest {
string sku = 1;
}
message StockResponse {
string sku = 1;
string medicine_name = 2;
int32 quantity = 3;
bool requires_prescription = 4;
}
Generate the Go code from the project root:
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
proto/inventory.proto
This produces two files:
proto/inventory/inventory.pb.go— the generated message structs
proto/inventory/inventory_grpc.pb.go— the generated client stub and server interface
> Tip: Commit generated.pb.gofiles to version control. This makes the schema
> auditable in code review and removes theprotoctoolchain from your CI build path.
Step 2: The REST Inventory API (Data Layer)
Before building the gRPC server, we need the data source it will call. This REST API
represents the inventory database layer — it could equally be an existing internal
service, a third-party API, or a microservice you do not own.
Create rest-api/main.go:
package main
import (
"encoding/json"
"log"
"net/http"
)
// StockItem mirrors the shape we will return from the REST API.
// The gRPC server will map these fields onto its Protobuf StockResponse.
type StockItem struct {
SKU string `json:"sku"`
MedicineName string `json:"medicine_name"`
Quantity int `json:"quantity"`
RequiresPrescription bool `json:"requires_prescription"`
}
// inventory is the in-memory data store for this service.
// In production this would be replaced by a database query.
var inventory = map[string]StockItem{
"MED-001": {SKU: "MED-001", MedicineName: "Amoxicillin 500mg", Quantity: 1450, RequiresPrescription: true},
"MED-045": {SKU: "MED-045", MedicineName: "Metformin 500mg", Quantity: 320, RequiresPrescription: true},
"MED-089": {SKU: "MED-089", MedicineName: "Ibuprofen 200mg", Quantity: 12, RequiresPrescription: false},
"MED-102": {SKU: "MED-102", MedicineName: "Lisinopril 10mg", Quantity: 5, RequiresPrescription: true},
"MED-201": {SKU: "MED-201", MedicineName: "Cetirizine 10mg", Quantity: 8, RequiresPrescription: false},
}
// lowStockThreshold defines what "low stock" means across the system.
const lowStockThreshold = 50
// writeJSON is a small helper that sets the Content-Type header and encodes
// the provided value as JSON. It writes a 500 if encoding fails.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
http.Error(w, `{"error":"encoding failed"}`, http.StatusInternalServerError)
}
}
// getStock handles GET /api/inventory/{sku}.
// It returns the stock record for a single SKU or 404 if not found.
func getStock(w http.ResponseWriter, r *http.Request) {
sku := r.PathValue("sku")
item, ok := inventory[sku]
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "SKU not found"})
return
}
writeJSON(w, http.StatusOK, item)
}
// getLowStock handles GET /api/inventory/low-stock.
// It returns all items whose quantity falls below lowStockThreshold.
func getLowStock(w http.ResponseWriter, r *http.Request) {
var items []StockItem
for _, item := range inventory {
if item.Quantity < lowStockThreshold {
items = append(items, item)
}
}
writeJSON(w, http.StatusOK, items)
}
func main() {
mux := http.NewServeMux()
// Register the specific /low-stock route BEFORE the wildcard {sku} route.
// Go 1.22+ ServeMux resolves conflicts by specificity, but explicit ordering
// makes the intent clear to future maintainers.
mux.HandleFunc("GET /api/inventory/low-stock", getLowStock)
mux.HandleFunc("GET /api/inventory/{sku}", getStock)
log.Println("REST Inventory API listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Testing the REST API with curl
Start the REST API in one terminal:
go run rest-api/main.go
Then verify each endpoint:
Look up a specific SKU:
curl -s http://localhost:8080/api/inventory/MED-001 | jq .
{
"sku": "MED-001",
"medicine_name": "Amoxicillin 500mg",
"quantity": 1450,
"requires_prescription": true
}
Fetch all low-stock items (quantity < 50):
curl -s http://localhost:8080/api/inventory/low-stock | jq .
[
{
"sku": "MED-089",
"medicine_name": "Ibuprofen 200mg",
"quantity": 12,
"requires_prescription": false
},
{
"sku": "MED-102",
"medicine_name": "Lisinopril 10mg",
"quantity": 5,
"requires_prescription": true
},
{
"sku": "MED-201",
"medicine_name": "Cetirizine 10mg",
"quantity": 8,
"requires_prescription": false
}
]
Confirm a 404 for an unknown SKU:
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/inventory/UNKNOWN
# 404
With the data layer verified independently, we can build the gRPC server with
confidence about the shape and behaviour of the API it will consume.
Step 3: The gRPC Server (Calls the REST API)
The gRPC server fulfils the InventoryService contract defined in Protobuf. Rather
than owning any data itself, it translates each incoming gRPC call into a corresponding
HTTP request to the REST API, then maps the JSON response onto a Protobuf message.
A shared http.Client with a connection timeout is created once and reused across all
requests. This is critical — constructing a new http.Client per request bypasses
Go's built-in connection pooling and introduces unnecessary overhead.
Create server/main.go:
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"os"
"time"
pb "github.com/murtuza/grpc-inventory/proto/inventory"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// restInventoryItem maps the JSON shape returned by the REST API.
type restInventoryItem struct {
SKU string `json:"sku"`
MedicineName string `json:"medicine_name"`
Quantity int `json:"quantity"`
RequiresPrescription bool `json:"requires_prescription"`
}
// server implements the InventoryServiceServer interface.
// Embedding UnimplementedInventoryServiceServer ensures forward compatibility —
// new RPC methods added to the .proto will not break existing server binaries.
type server struct {
pb.UnimplementedInventoryServiceServer
httpClient *http.Client
apiBaseURL string
}
// newServer constructs a server with a shared HTTP client.
// The REST API base URL is read from the REST_API_URL environment variable,
// defaulting to localhost for local development.
func newServer() *server {
baseURL := os.Getenv("REST_API_URL")
if baseURL == "" {
baseURL = "http://localhost:8080"
}
return &server{
httpClient: &http.Client{Timeout: 10 * time.Second},
apiBaseURL: baseURL,
}
}
// fetchItem calls GET /api/inventory/{sku} on the REST API and returns the parsed item.
// It maps HTTP 404 to a gRPC NotFound status so the gRPC client receives a
// well-typed error rather than a raw HTTP error code.
func (s *server) fetchItem(ctx context.Context, sku string) (*restInventoryItem, error) {
url := fmt.Sprintf("%s/api/inventory/%s", s.apiBaseURL, sku)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to build request: %v", err)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, status.Errorf(codes.Unavailable, "REST API unreachable: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, status.Errorf(codes.NotFound, "SKU %s not found in inventory", sku)
}
if resp.StatusCode != http.StatusOK {
return nil, status.Errorf(codes.Internal, "REST API returned unexpected status %d", resp.StatusCode)
}
var item restInventoryItem
if err := json.NewDecoder(resp.Body).Decode(&item); err != nil {
return nil, status.Errorf(codes.Internal, "failed to decode REST response: %v", err)
}
return &item, nil
}
// fetchLowStock calls GET /api/inventory/low-stock and returns all matching items.
func (s *server) fetchLowStock(ctx context.Context) ([]restInventoryItem, error) {
url := fmt.Sprintf("%s/api/inventory/low-stock", s.apiBaseURL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to build request: %v", err)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, status.Errorf(codes.Unavailable, "REST API unreachable: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, status.Errorf(codes.Internal, "REST API returned unexpected status %d", resp.StatusCode)
}
var items []restInventoryItem
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
return nil, status.Errorf(codes.Internal, "failed to decode REST response: %v", err)
}
return items, nil
}
// CheckStock is a Unary RPC. It fetches a single SKU from the REST API and
// returns the result as a typed Protobuf message.
func (s *server) CheckStock(ctx context.Context, req *pb.StockRequest) (*pb.StockResponse, error) {
item, err := s.fetchItem(ctx, req.GetSku())
if err != nil {
return nil, err // already a gRPC status error
}
return &pb.StockResponse{
Sku: item.SKU,
MedicineName: item.MedicineName,
Quantity: int32(item.Quantity),
RequiresPrescription: item.RequiresPrescription,
}, nil
}
// StreamLowStock is a Server-Streaming RPC. It fetches all low-stock items from
// the REST API in a single call, then streams each one to the client individually.
func (s *server) StreamLowStock(_ *pb.EmptyRequest, stream pb.InventoryService_StreamLowStockServer) error {
items, err := s.fetchLowStock(stream.Context())
if err != nil {
return err
}
for _, item := range items {
// Respect client cancellation between sends.
if err := stream.Context().Err(); err != nil {
return status.Errorf(codes.Canceled, "client disconnected: %v", err)
}
if err := stream.Send(&pb.StockResponse{
Sku: item.SKU,
MedicineName: item.MedicineName,
Quantity: int32(item.Quantity),
RequiresPrescription: item.RequiresPrescription,
}); err != nil {
return err
}
}
return nil
}
// loggingInterceptor is Unary middleware that records the method, duration,
// and outcome of every incoming RPC call.
func loggingInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
start := time.Now()
h, err := handler(ctx, req)
log.Printf("[RPC] method=%s duration=%s err=%v", info.FullMethod, time.Since(start), err)
return h, err
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
// ChainUnaryInterceptor composes multiple interceptors cleanly.
// Add authInterceptor, recoveryInterceptor, etc. here as the service grows.
s := grpc.NewServer(
grpc.ChainUnaryInterceptor(loggingInterceptor),
)
pb.RegisterInventoryServiceServer(s, newServer())
log.Printf("Inventory gRPC server listening at %v", lis.Addr())
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
Step 4: The gRPC Client
The client connects to the gRPC server and exercises both RPC patterns — a unary call
and a server-streaming call.
The traditional grpc.Dial is deprecated in modern versions of grpc-go. Its
replacement, grpc.NewClient, provides a more robust API and establishes the underlying
connection lazily on the first RPC call rather than at construction time.
Create client/main.go:
package main
import (
"context"
"io"
"log"
"time"
pb "github.com/murtuza/grpc-inventory/proto/inventory"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func main() {
// grpc.NewClient does not block — the connection is established lazily.
// For production, replace insecure.NewCredentials() with a TLS config.
conn, err := grpc.NewClient(
"localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatalf("could not create client: %v", err)
}
defer conn.Close()
c := pb.NewInventoryServiceClient(conn)
testUnaryCall(c)
testStreamingCall(c)
}
func testUnaryCall(c pb.InventoryServiceClient) {
// A deadline context is essential for unary calls. Without one, a slow or
// unresponsive server will block the caller indefinitely.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
log.Println("--- Executing Unary RPC ---")
r, err := c.CheckStock(ctx, &pb.StockRequest{Sku: "MED-001"})
if err != nil {
log.Fatalf("CheckStock failed: %v", err)
}
log.Printf("Stock check -> %s: %d units (prescription required: %t)",
r.GetMedicineName(), r.GetQuantity(), r.GetRequiresPrescription())
}
func testStreamingCall(c pb.InventoryServiceClient) {
log.Println("\n--- Executing Server-Streaming RPC ---")
// A deadline is equally important on streaming calls. Use context.WithCancel
// if you need to abort the stream from the client side based on your own logic.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
stream, err := c.StreamLowStock(ctx, &pb.EmptyRequest{})
if err != nil {
log.Fatalf("StreamLowStock failed: %v", err)
}
for {
item, err := stream.Recv()
if err == io.EOF {
// The server closed the stream normally.
break
}
if err != nil {
log.Fatalf("error receiving from stream: %v", err)
}
log.Printf("ALERT: Low stock -> %s (SKU: %s) has only %d units remaining.",
item.GetMedicineName(), item.GetSku(), item.GetQuantity())
}
log.Println("--- Stream complete ---")
}
Step 5: Running the Full Stack
The three services run independently. Open three terminals from the project root.
Terminal 1 — Start the REST API:
go run rest-api/main.go
# REST Inventory API listening on :8080
Terminal 2 — Start the gRPC server:
go run server/main.go
# Inventory gRPC server listening at [::]:50051
Terminal 3 — Run the gRPC client:
go run client/main.go
Expected client output:
--- Executing Unary RPC ---
Stock check -> Amoxicillin 500mg: 1450 units (prescription required: true)
--- Executing Server-Streaming RPC ---
ALERT: Low stock -> Ibuprofen 200mg (SKU: MED-089) has only 12 units remaining.
ALERT: Low stock -> Lisinopril 10mg (SKU: MED-102) has only 5 units remaining.
ALERT: Low stock -> Cetirizine 10mg (SKU: MED-201) has only 8 units remaining.
--- Stream complete ---
Meanwhile, Terminal 2 (the gRPC server) will emit interceptor logs for each call:
[RPC] method=/inventory.InventoryService/CheckStock duration=2.1ms err=<nil>
[RPC] method=/inventory.InventoryService/StreamLowStock duration=1.8ms err=<nil>
Configuring the REST API URL
The gRPC server reads its upstream address from the REST_API_URL environment
variable, which defaults to http://localhost:8080 if unset. To point it at a
different host (e.g. in a Docker Compose network):
REST_API_URL=http://inventory-api:8080 go run server/main.go
Step 6: Debugging Binary Traffic with grpcurl
One of gRPC's stated drawbacks is that binary Protobuf payloads are opaque to
standard HTTP tools like curl. The idiomatic solution is
.
Engineering is best when shared. Find more of my writing, projects, and work at -- murtuza.dev
SOCIAL SHARE CARD GENERATOR