DEV Community

Cover image for Beyond REST: Architecting High-Performance Microservices with gRPC in Go
Md Murtuza Hussain
Md Murtuza Hussain

Posted on

Beyond REST: Architecting High-Performance Microservices with gRPC in Go

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       │
└────────────┘               └──────────────┘                   └──────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.go files to version control. This makes the schema > auditable in code review and removes the protoc toolchain 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))
}
Enter fullscreen mode Exit fullscreen mode

Testing the REST API with curl

Start the REST API in one terminal:

go run rest-api/main.go
Enter fullscreen mode Exit fullscreen mode

Then verify each endpoint:

Look up a specific SKU:

curl -s http://localhost:8080/api/inventory/MED-001 | jq .
Enter fullscreen mode Exit fullscreen mode
{
  "sku": "MED-001",
  "medicine_name": "Amoxicillin 500mg",
  "quantity": 1450,
  "requires_prescription": true
}
Enter fullscreen mode Exit fullscreen mode

Fetch all low-stock items (quantity < 50):

curl -s http://localhost:8080/api/inventory/low-stock | jq .
Enter fullscreen mode Exit fullscreen mode
[
  {
    "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
  }
]
Enter fullscreen mode Exit fullscreen mode

Confirm a 404 for an unknown SKU:

curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/inventory/UNKNOWN
# 404
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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 ---")
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Terminal 2 — Start the gRPC server:

go run server/main.go
# Inventory gRPC server listening at [::]:50051
Enter fullscreen mode Exit fullscreen mode

Terminal 3 — Run the gRPC client:

go run client/main.go
Enter fullscreen mode Exit fullscreen mode

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 ---
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
grpcurl — a command-line tool that
speaks gRPC natively, playing the same role curl plays for REST APIs.

Install it:

go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
Enter fullscreen mode Exit fullscreen mode

To use grpcurl without a .proto file, enable server reflection by adding two
lines to server/main.go:

import "google.golang.org/grpc/reflection"

// Inside main(), after pb.RegisterInventoryServiceServer:
reflection.Register(s)
Enter fullscreen mode Exit fullscreen mode

Security note: Only enable reflection in development and staging environments.
Exposing your full service schema in production is an unnecessary attack surface.

With reflection running, you can introspect and invoke your service from the terminal,
in exactly the same workflow as curl + a REST API:

# List all available services
grpcurl -plaintext localhost:50051 list

# Describe the full InventoryService schema
grpcurl -plaintext localhost:50051 describe inventory.InventoryService

# Call CheckStock (unary)
grpcurl -plaintext \
  -d '{"sku": "MED-001"}' \
  localhost:50051 inventory.InventoryService/CheckStock

# Stream low-stock alerts (server-streaming)
grpcurl -plaintext \
  -d '{}' \
  localhost:50051 inventory.InventoryService/StreamLowStock
Enter fullscreen mode Exit fullscreen mode

Production Considerations

TLS

The examples above use insecure.NewCredentials(), transmitting data in plaintext.
For any non-local deployment, use mutual TLS (mTLS):

// Server-side
creds, err := credentials.NewServerTLSFromFile("server.crt", "server.key")
s := grpc.NewServer(grpc.Creds(creds), ...)

// Client-side
creds, err := credentials.NewClientTLSFromFile("ca.crt", "")
conn, err := grpc.NewClient("host:50051", grpc.WithTransportCredentials(creds))
Enter fullscreen mode Exit fullscreen mode

Chaining Multiple Interceptors

Real services need more than logging. Authentication, rate limiting, and panic recovery
are all common interceptor concerns. grpc.ChainUnaryInterceptor composes them
without nesting:

s := grpc.NewServer(
    grpc.ChainUnaryInterceptor(
        loggingInterceptor,
        authInterceptor,
        recoveryInterceptor,
    ),
    grpc.ChainStreamInterceptor(
        streamLoggingInterceptor,
    ),
)
Enter fullscreen mode Exit fullscreen mode

grpc.UnaryInterceptor only accepts a single function and panics if called more
than once — always prefer ChainUnaryInterceptor in production code.

HTTP Client Timeouts

The gRPC server's http.Client is configured with a 10-second timeout, but you
may want to tune this per-call using the incoming gRPC context directly, so that
a client's deadline propagates end-to-end:

// Pass the gRPC call context into the downstream HTTP request.
// If the gRPC client cancels, the REST API call is cancelled too.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
Enter fullscreen mode Exit fullscreen mode

This pattern is already used in the server code above and prevents a situation where
a gRPC client times out but the REST API call continues to consume resources on the
server.


Architectural Realities: When (and When Not) to Use gRPC

While gRPC's performance characteristics are compelling, engineering is about
trade-offs.

Where gRPC Excels

Polyglot microservices. If your backend spans Go, Rust, Python, and Node.js,
Protobuf generates idiomatic client libraries for all of them from a single .proto
source of truth — with no per-language schema drift.

High-throughput internal APIs. Binary serialization is measurably faster to encode
and decode than JSON, and Protobuf payloads are significantly smaller over the wire —
particularly important at high request volumes.

Strict typing as a contract. Runtime errors caused by a misspelled JSON field
become a compile-time failure. The contract is enforced at build time, not discovered
in production.

Bidirectional streaming. gRPC supports client streaming, server streaming, and
full-duplex bidirectional streaming out of the box — capabilities that require
WebSockets or SSE to replicate in a REST architecture.

The Pragmatic Drawbacks

Layer 7 load balancing. Because gRPC multiplexes requests over long-lived HTTP/2
TCP connections, standard L4 load balancers (such as AWS NLB or a default Kubernetes
Service) will funnel all traffic from a single client pod to one server pod,
defeating horizontal scaling. You must front gRPC services with an L7 proxy — Envoy,
NGINX, or a full service mesh like Istio — to distribute load at the request level.

Browser incompatibility. Web browsers cannot natively initiate the HTTP/2
connections gRPC requires. Calling a gRPC service from a browser requires grpc-web
and an Envoy proxy as a translation layer. If your primary consumers are web or mobile
frontends, REST or GraphQL is architecturally simpler.

Operational tooling curve. Teams accustomed to curl, Postman, and browser
DevTools will need to adopt grpcurl or Postman's gRPC mode. Binary traffic is
invisible without the right tools in place — which is precisely why the grpcurl
section above is not optional reading.


Summary

This guide built a realistic three-tier system: a REST data layer verified directly
with curl, a gRPC server that calls it, and a typed gRPC client that consumes the
result. Each tier can be developed, tested, and debugged in isolation — the REST API
with curl, and the gRPC surface with grpcurl.

gRPC in Go provides a robust foundation for enterprise-grade internal services, but it
should be adopted rationally. Use it where strict contracts, low latency, and efficient
serialization are genuine architectural requirements — not merely because it is the
current industry trend.

The full source code for this tutorial is available at
github.com/mmurtuza/grpc-inventory.


Engineering is best when shared. Find more of my writing, projects, and work at -- murtuza.dev

Top comments (0)