Microservices & gRPC

Updated

July 30, 2026

Microservices: Switching to gRPC (and Connect)

REST is fine for public APIs. But for internal communication between microservices, JSON over HTTP/1.1 is inefficient (text parsing, no types, high overhead).

gRPC (Google Remote Procedure Call) is the standard for high-performance internal traffic.

The Protocol Buffers (Protobuf)

You define your data and service in a .proto file.

syntax = "proto3";

service UserService {
  rpc GetUser (UserRequest) returns (UserResponse);
}

message UserRequest {
  string id = 1;
}

message UserResponse {
  string name = 1;
  int32 age = 2;
}

Then, you compile this using protoc to generate Go code. * Strict Types: The generated code guarantees the wire format matches the struct. * Binary: Messages are binary (smaller, faster to parse than JSON).

The gRPC Complexity (and the Solution: ConnectRPC)

Classic gRPC requires special load balancers (because it breaks HTTP/1.1 framing) and is hard to debug with curl.

In 2026, we prefer ConnectRPC (by Buf). * It’s just HTTP: It supports gRPC but also standard HTTP/JSON. * You can curl a Connect service with JSON, but talk to it via gRPC from other Go services.

Connect Example

package main

import (
    "context"
    "net/http"
    "connectrpc.com/connect"
    user "example/gen/user/v1" // Generated code
    "example/gen/user/v1/userv1connect"
)

type UserServer struct {}

func (s *UserServer) GetUser(
    ctx context.Context,
    req *connect.Request[user.UserRequest],
) (*connect.Response[user.UserResponse], error) {
    return connect.NewResponse(&user.UserResponse{
        Name: "Alice",
        Age:  30,
    }), nil
}

func main() {
    mux := http.NewServeMux()
    path, handler := userv1connect.NewUserServiceHandler(&UserServer{})
    mux.Handle(path, handler)
    http.ListenAndServe(":8080", mux)
}

When to use Microservices?

Default to Monolith. Microservices introduce: 1. Network Latency. 2. Distributed Tracing requirements. 3. Deployment complexity.

Only switch to Microservices (and gRPC) when: * You have distinct teams who need to deploy independently. * You have distinct scaling requirements (e.g., the Video Encoder needs 100 GPUs, but the Login server needs 1 CPU).

Worked example

Unary JSON-RPC client/server with typed request envelopes.

Save as main.go. Then:

go mod init example
go run .
package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
)

type Req[T any] struct {
    Payload T `json:"payload"`
}

type Resp[T any] struct {
    Payload T `json:"payload"`
}

type AddIn struct {
    A, B int `json:"a"`
}
type AddOut struct {
    Sum int `json:"sum"`
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("POST /calc.v1/Add", func(w http.ResponseWriter, r *http.Request) {
        var in Req[AddIn]
        if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
            http.Error(w, "bad json", 400)
            return
        }
        _ = json.NewEncoder(w).Encode(Resp[AddOut]{Payload: AddOut{Sum: in.Payload.A + in.Payload.B}})
    })
    srv := httptest.NewServer(mux)
    defer srv.Close()

    body, _ := json.Marshal(Req[AddIn]{Payload: AddIn{A: 2, B: 40}})
    res, err := http.Post(srv.URL+"/calc.v1/Add", "application/json", bytes.NewReader(body))
    if err != nil {
        panic(err)
    }
    defer res.Body.Close()
    var out Resp[AddOut]
    _ = json.NewDecoder(res.Body).Decode(&out)
    fmt.Println("sum:", out.Payload.Sum)
}

Expected output:

sum: 42

More examples

RPC error envelope + status mapping.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("POST /user.v1/Get", func(w http.ResponseWriter, r *http.Request) {
        var req struct {
            ID string `json:"id"`
        }
        _ = json.NewDecoder(r.Body).Decode(&req)
        if req.ID == "" {
            w.WriteHeader(http.StatusBadRequest)
            _ = json.NewEncoder(w).Encode(map[string]string{"error": "id required"})
            return
        }
        _ = json.NewEncoder(w).Encode(map[string]string{"name": "Ada"})
    })
    srv := httptest.NewServer(mux)
    defer srv.Close()

    res, _ := http.Post(srv.URL+"/user.v1/Get", "application/json", bytes.NewReader([]byte(`{}`)))
    fmt.Println("status:", res.StatusCode)
    res.Body.Close()
}

Expected output:

status: 400

Runnable example

Note: Production internal APIs often use gRPC or ConnectRPC with protobuf. This stdlib stand-in is a typed JSON “RPC over HTTP” service—same request/response shape idea, no codegen.

Save as main.go. Then:

go mod init example
go run .
package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
)

type UserRequest struct {
    ID string `json:"id"`
}

type UserResponse struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

type ErrorBody struct {
    Error string `json:"error"`
}

func getUser(id string) (UserResponse, bool) {
    db := map[string]UserResponse{
        "u1": {Name: "Alice", Age: 30},
        "u2": {Name: "Bob", Age: 25},
    }
    u, ok := db[id]
    return u, ok
}

func main() {
    mux := http.NewServeMux()
    // Connect/gRPC-style unary RPC as JSON POST
    mux.HandleFunc("POST /user.v1.UserService/GetUser", func(w http.ResponseWriter, r *http.Request) {
        var req UserRequest
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
            w.WriteHeader(http.StatusBadRequest)
            _ = json.NewEncoder(w).Encode(ErrorBody{Error: "invalid json"})
            return
        }
        user, ok := getUser(req.ID)
        if !ok {
            w.WriteHeader(http.StatusNotFound)
            _ = json.NewEncoder(w).Encode(ErrorBody{Error: "user not found"})
            return
        }
        w.Header().Set("Content-Type", "application/json")
        _ = json.NewEncoder(w).Encode(user)
    })

    srv := httptest.NewServer(mux)
    defer srv.Close()

    call := func(id string) {
        body, _ := json.Marshal(UserRequest{ID: id})
        resp, err := http.Post(srv.URL+"/user.v1.UserService/GetUser", "application/json", bytes.NewReader(body))
        if err != nil {
            panic(err)
        }
        defer resp.Body.Close()
        var raw map[string]any
        _ = json.NewDecoder(resp.Body).Decode(&raw)
        fmt.Printf("id=%s status=%d body=%v\n", id, resp.StatusCode, raw)
    }

    call("u1")
    call("missing")
}

Expected output:

id=u1 status=200 body=map[age:30 name:Alice]
id=missing status=404 body=map[error:user not found]

What to notice: Method path + typed request/response mirrors an RPC surface even over JSON/HTTP. httptest keeps the demo offline-friendly. Real gRPC adds binary protobuf, streaming, and code-generated stubs.

Try next: Add a ListUsers method. Wrap the handler with a middleware that logs RPC name and status code.