Go for Cloud-Native Applications: Building Scalable Microservices
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 (
/healthzliveness,/readyzreadiness) - 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 vetandstaticcheckclean 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.