GolangMicroservicesCloud NativeDevOps

Go for Cloud-Native Applications: Building Scalable Microservices

·Hilmall Cloud

Go has become the language of cloud-native infrastructure. Docker, Kubernetes, and countless other tools are written in Go. This guide covers building production microservices that scale.

Why Go for Microservices?

Go’s strengths align perfectly with microservice requirements:

  • Fast compilation: Rapid development cycles
  • Static binaries: Easy deployment, no runtime dependencies
  • Built-in concurrency: Goroutines and channels for parallel processing
  • Small memory footprint: Cost-effective at scale

Service Structure

A well-organized Go service:

my-service/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── handler/
│   ├── service/
│   ├── repository/
│   └── model/
├── pkg/
│   └── client/
├── api/
│   └── proto/
├── configs/
└── go.mod

gRPC Services

Define your service with Protocol Buffers:

syntax = "proto3";

package user;

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
}

message GetUserRequest {
  string id = 1;
}

message GetUserResponse {
  string id = 1;
  string name = 2;
  string email = 3;
}

Generate Go code:

protoc --go_out=. --go-grpc_out=. api/proto/user.proto

Implement the service:

package service

import (
    "context"
    pb "my-service/api/proto"
)

type UserService struct {
    pb.UnimplementedUserServiceServer
    repo UserRepository
}

func (s *UserService) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
    user, err := s.repo.FindByID(ctx, req.Id)
    if err != nil {
        return nil, status.Error(codes.NotFound, "user not found")
    }
    
    return &pb.GetUserResponse{
        Id:    user.ID,
        Name:  user.Name,
        Email: user.Email,
    }, nil
}

HTTP API with Middleware

For REST APIs, use a lightweight framework:

package main

import (
    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
)

func main() {
    r := chi.NewRouter()
    
    // Middleware
    r.Use(middleware.RequestID)
    r.Use(middleware.RealIP)
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.Timeout(60 * time.Second))
    
    // Routes
    r.Route("/api/v1", func(r chi.Router) {
        r.Get("/users/{id}", getUserHandler)
        r.Post("/users", createUserHandler)
    })
    
    http.ListenAndServe(":8080", r)
}

Configuration Management

Use environment-based configuration:

package config

import (
    "github.com/spf13/viper"
)

type Config struct {
    Port         int    `mapstructure:"PORT"`
    DatabaseURL  string `mapstructure:"DATABASE_URL"`
    RedisURL     string `mapstructure:"REDIS_URL"`
    LogLevel     string `mapstructure:"LOG_LEVEL"`
}

func Load() (*Config, error) {
    viper.AutomaticEnv()
    viper.SetEnvPrefix("APP")
    
    var cfg Config
    if err := viper.Unmarshal(&cfg); err != nil {
        return nil, err
    }
    
    return &cfg, nil
}

Observability

Structured Logging

import "go.uber.org/zap"

logger, _ := zap.NewProduction()
defer logger.Sync()

logger.Info("user created",
    zap.String("user_id", user.ID),
    zap.String("email", user.Email),
    zap.Duration("duration", elapsed),
)

Metrics

import "github.com/prometheus/client_golang/prometheus"

var (
    requestsTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Total HTTP requests",
        },
        []string{"method", "endpoint", "status"},
    )
)

func init() {
    prometheus.MustRegister(requestsTotal)
}

func metricsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        requestsTotal.WithLabelValues(
            r.Method,
            r.URL.Path,
            "200",
        ).Inc()
    })
}

Distributed Tracing

import "go.opentelemetry.io/otel"

func tracedHandler(w http.ResponseWriter, r *http.Request) {
    ctx, span := otel.Tracer("my-service").Start(r.Context(), "handler")
    defer span.End()
    
    // Handler logic
}

Database Patterns

Repository Pattern

package repository

type UserRepository interface {
    FindByID(ctx context.Context, id string) (*User, error)
    Create(ctx context.Context, user *User) error
    Update(ctx context.Context, user *User) error
    Delete(ctx context.Context, id string) error
}

type postgresUserRepository struct {
    db *sql.DB
}

func (r *postgresUserRepository) FindByID(ctx context.Context, id string) (*User, error) {
    var user User
    err := r.db.QueryRowContext(ctx,
        "SELECT id, name, email FROM users WHERE id = $1",
        id,
    ).Scan(&user.ID, &user.Name, &user.Email)
    
    if err == sql.ErrNoRows {
        return nil, ErrNotFound
    }
    
    return &user, err
}

Connection Pooling

db, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
    log.Fatal(err)
}

// Configure pool
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)

Testing

Unit Tests

func TestUserService_GetUser(t *testing.T) {
    mockRepo := &MockUserRepository{
        FindByIDFunc: func(ctx context.Context, id string) (*User, error) {
            return &User{ID: id, Name: "Test"}, nil
        },
    }
    
    svc := NewUserService(mockRepo)
    user, err := svc.GetUser(context.Background(), "123")
    
    assert.NoError(t, err)
    assert.Equal(t, "Test", user.Name)
}

Integration Tests

// +build integration

func TestUserRepository_Integration(t *testing.T) {
    db := setupTestDB(t)
    defer db.Close()
    
    repo := NewUserRepository(db)
    
    user := &User{Name: "Test", Email: "test@example.com"}
    err := repo.Create(context.Background(), user)
    require.NoError(t, err)
    
    found, err := repo.FindByID(context.Background(), user.ID)
    require.NoError(t, err)
    assert.Equal(t, user.Name, found.Name)
}

Deployment

Docker

FROM golang:1.22-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 go build -o server ./cmd/server

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/

COPY --from=builder /app/server .
EXPOSE 8080

CMD ["./server"]

Kubernetes

apiVersion: apps/v1
kind: Deployment
metadata:
  name: user-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: user-service
  template:
    metadata:
      labels:
        app: user-service
    spec:
      containers:
      - name: server
        image: my-registry/user-service:v1.0.0
        ports:
        - containerPort: 8080
        resources:
          requests:
            memory: "64Mi"
            cpu: "100m"
          limits:
            memory: "128Mi"
            cpu: "200m"
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 10

Graceful Shutdown

A service that kills in-flight requests on deploy is a service users notice. Go’s http.Server supports graceful shutdown out of the box — you just have to wire it up:

func main() {
    srv := &http.Server{Addr: ":8080", Handler: router}

    // Run server in a goroutine
    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("listen: %v", err)
        }
    }()

    // Wait for interrupt signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    log.Println("shutting down...")

    // Give in-flight requests 30 seconds to finish
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("forced shutdown: %v", err)
    }
    log.Println("server exited cleanly")
}

In Kubernetes, this pairs with the preStop hook and a terminationGracePeriodSeconds long enough for your slowest request. The orchestrator sends SIGTERM, your server stops accepting new connections, drains the old ones, and exits — zero dropped requests during a rollout.

Error Handling That Scales

Go’s explicit error returns are verbose but powerful. The pattern that scales: wrap errors with context as they travel up the stack, and check the sentinel at the boundary:

var ErrNotFound = errors.New("not found")

func (r *repo) FindByID(ctx context.Context, id string) (*User, error) {
    // ...
    if err == sql.ErrNoRows {
        return nil, fmt.Errorf("user %s: %w", id, ErrNotFound)
    }
}

// At the HTTP boundary
func getUserHandler(w http.ResponseWriter, r *http.Request) {
    user, err := svc.GetUser(r.Context(), chi.URLParam(r, "id"))
    if errors.Is(err, ErrNotFound) {
        http.Error(w, "user not found", http.StatusNotFound)
        return
    }
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(user)
}

errors.Is unwraps the chain, so internal details stay internal while the handler makes the right HTTP decision. Log the full wrapped error server-side; return only safe messages to clients — the same principle we stress in the web security guide.

Context Propagation

context.Context carries deadlines, cancellation, and request-scoped values through your call stack. The rules are simple but frequently violated:

  • Always accept ctx as the first parameter and pass it down. Never store it in a struct.
  • Propagate cancellation: when a client disconnects, the context cancels — and your database query should stop too, freeing resources instead of finishing work nobody will read.
  • Set timeouts at the edges: inbound HTTP handlers and outbound RPC calls should both have deadlines.
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
user, err := repo.FindByID(ctx, id)  // Aborts if it takes >5s

Without context propagation, a slow database turns a single request into a resource leak that compounds under load. With it, timeouts cascade cleanly through every layer.

Production Checklist

Before shipping a Go service:

  • Graceful shutdown wired to SIGTERM
  • Health endpoints (/healthz liveness, /readyz readiness)
  • Timeouts on all inbound handlers and outbound calls
  • Connection pools sized (not left at defaults)
  • Structured logs with request IDs
  • Metrics exported (requests, latency, errors)
  • go vet and staticcheck clean in CI
  • Race detector run in tests (go test -race)

Conclusion

Go’s simplicity and performance make it ideal for cloud-native microservices. The patterns covered here — clean architecture, comprehensive observability, and proper testing — form the foundation of production-grade Go services.

Start with a simple service, add observability from day one, and iterate based on production metrics. Go’s ecosystem makes it easy to build services that scale from prototype to production.