← All Articles

Building Scalable Microservices with Golang

A deep dive into designing and implementing production-ready microservices using Go, covering patterns like circuit breakers, rate limiting, and graceful degradation.

When building distributed systems at scale, Go has become my go-to language. Its concurrency model, small memory footprint, and fast compilation make it ideal for microservices that need to handle thousands of requests per second.

Why Go for Microservices?

Go was designed at Google to solve real-world problems in large-scale distributed systems. Here's what makes it stand out:

  • Goroutines: Lightweight threads that cost ~2KB of stack space
  • Channels: First-class communication primitives for safe concurrency
  • Static binary: Single binary deployment with no runtime dependencies
  • Fast GC: Sub-millisecond garbage collection pauses

Architecture Overview

Here's the architecture I typically use for production microservices:

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

type Server struct {
    router     *http.ServeMux
    httpServer *http.Server
    logger     *log.Logger
}

func NewServer(addr string) *Server {
    s := &Server{
        router: http.NewServeMux(),
        logger: log.New(os.Stdout, "[service] ", log.LstdFlags),
    }

    s.httpServer = &http.Server{
        Addr:         addr,
        Handler:      s.middleware(s.router),
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    s.routes()
    return s
}

func (s *Server) middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        s.logger.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

Implementing Circuit Breakers

One of the most critical patterns in microservice architecture is the circuit breaker. It prevents cascading failures when downstream services are unhealthy.

type CircuitBreaker struct {
    mu          sync.Mutex
    state       State
    failures    int
    threshold   int
    timeout     time.Duration
    lastFailure time.Time
}

type State int

const (
    StateClosed State = iota
    StateOpen
    StateHalfOpen
)

func (cb *CircuitBreaker) Execute(fn func() error) error {
    cb.mu.Lock()
    
    if cb.state == StateOpen {
        if time.Since(cb.lastFailure) > cb.timeout {
            cb.state = StateHalfOpen
        } else {
            cb.mu.Unlock()
            return ErrCircuitOpen
        }
    }
    cb.mu.Unlock()

    err := fn()
    
    cb.mu.Lock()
    defer cb.mu.Unlock()
    
    if err != nil {
        cb.failures++
        cb.lastFailure = time.Now()
        if cb.failures >= cb.threshold {
            cb.state = StateOpen
        }
        return err
    }

    cb.failures = 0
    cb.state = StateClosed
    return nil
}

Rate Limiting with Token Bucket

For API rate limiting, I use a token bucket implementation that's both memory-efficient and fair:

type TokenBucket struct {
    rate       float64
    capacity   float64
    tokens     float64
    lastRefill time.Time
    mu         sync.Mutex
}

func NewTokenBucket(rate, capacity float64) *TokenBucket {
    return &TokenBucket{
        rate:       rate,
        capacity:   capacity,
        tokens:     capacity,
        lastRefill: time.Now(),
    }
}

func (tb *TokenBucket) Allow() bool {
    tb.mu.Lock()
    defer tb.mu.Unlock()

    now := time.Now()
    elapsed := now.Sub(tb.lastRefill).Seconds()
    tb.tokens = math.Min(tb.capacity, tb.tokens+elapsed*tb.rate)
    tb.lastRefill = now

    if tb.tokens >= 1 {
        tb.tokens--
        return true
    }
    return false
}

Graceful Shutdown

Always implement graceful shutdown. In-flight requests should complete before the service terminates:

func main() {
    srv := NewServer(":8080")

    go func() {
        if err := srv.httpServer.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("HTTP server error: %v", err)
        }
    }()

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

    // Give outstanding requests 30 seconds to complete
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.httpServer.Shutdown(ctx); err != nil {
        log.Fatalf("Forced shutdown: %v", err)
    }
    log.Println("Server stopped gracefully")
}

Key Takeaways

  1. Design for failure: Every network call can fail. Plan for it.
  2. Observe everything: Metrics, logs, and traces are non-negotiable.
  3. Keep services small: Each service should do one thing well.
  4. Automate deployment: CI/CD pipelines should handle the boring stuff.

The beauty of Go is that it forces you to handle errors explicitly. There's no hiding behind try-catch blocks. Every error is a conscious decision.


Building resilient systems is a journey, not a destination. Keep iterating.

Next ArticleAdvanced TypeScript Patterns for Large CodebasesTypeScript · 15 min read