_SH Log's
Back to Root
EST: 5 min read

Go Microservices: Patterns I Use in Production

Go microservices in production require consistent patterns for HTTP, middleware, errors, config, and testing. Here are the patterns I use across six products.

#go#microservices#patterns#backend

Every Go microservice I write follows the same structural patterns. Not because I'm rigid, but because consistency across codebases is a genuine productivity multiplier — especially when AI coding agents are writing most of the implementation. Here are the patterns, with code.

Project structure

Every service follows this layout:

service/
├── cmd/
│   └── api/
│       └── main.go          # wire dependencies, start server
├── internal/
│   ├── handler/             # HTTP handlers (thin)
│   ├── service/             # business logic
│   ├── repository/          # DB queries (sqlc-generated)
│   └── middleware/          # auth, logging, rate limiting
├── db/
│   ├── migrations/          # golang-migrate .sql files
│   └── queries/             # .sql files for sqlc
├── Dockerfile
├── go.mod
└── sqlc.yaml

internal/ prevents importing these packages from other modules. cmd/api/main.go is tiny — just dependency wiring.

Main: dependency injection without a framework

// cmd/api/main.go
func main() {
    cfg := config.Load()  // from env vars

    db, err := database.Connect(cfg.DatabaseURL)
    if err != nil {
        log.Fatalf("db: %v", err)
    }

    repo := repository.New(db)
    svc := service.New(repo, cfg)
    h := handler.New(svc)

    r := chi.NewRouter()
    r.Use(middleware.RequestID)
    r.Use(middleware.Recoverer)
    r.Use(middleware.Logger)
    r.Use(middleware.Auth(cfg.JWTSecret))

    r.Route("/v1", func(r chi.Router) {
        r.Get("/health", h.Health)
        r.Post("/users", h.CreateUser)
        r.Get("/users/{id}", h.GetUser)
    })

    log.Printf("listening on %s", cfg.Addr)
    http.ListenAndServe(cfg.Addr, r)
}

No DI framework. Explicit wiring is readable. 10 lines of main.go is better than 100 lines of container configuration.

Error handling: typed errors with HTTP mapping

// internal/apierr/errors.go
type APIError struct {
    Code    int    `json:"-"`
    Message string `json:"error"`
}

func (e APIError) Error() string { return e.Message }

var (
    ErrNotFound     = APIError{Code: 404, Message: "not found"}
    ErrUnauthorized = APIError{Code: 401, Message: "unauthorized"}
    ErrConflict     = APIError{Code: 409, Message: "conflict"}
    ErrInternal     = APIError{Code: 500, Message: "internal error"}
)

func New(code int, msg string) APIError {
    return APIError{Code: code, Message: msg}
}
// internal/handler/respond.go
func respond(w http.ResponseWriter, code int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(code)
    json.NewEncoder(w).Encode(data)
}

func handleErr(w http.ResponseWriter, err error) {
    var apiErr apierr.APIError
    if errors.As(err, &apiErr) {
        respond(w, apiErr.Code, apiErr)
        return
    }
    log.Printf("internal error: %v", err)
    respond(w, 500, apierr.ErrInternal)
}

Every handler uses handleErr(w, err). No switch statements on error types in handlers.

Config: from environment variables only

// internal/config/config.go
type Config struct {
    Addr        string
    DatabaseURL string
    JWTSecret   string
    LogLevel    string
}

func Load() Config {
    return Config{
        Addr:        mustEnv("ADDR", ":8080"),
        DatabaseURL: mustEnv("DATABASE_URL", ""),
        JWTSecret:   mustEnv("JWT_SECRET", ""),
        LogLevel:    mustEnv("LOG_LEVEL", "info"),
    }
}

func mustEnv(key, fallback string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    if fallback != "" {
        return fallback
    }
    log.Fatalf("required env var %s not set", key)
    return ""
}

No config files. Environment variables only. 12-factor app compliant. Works identically in Docker, ECS, and local development.

Database: sqlc for type-safe queries

sqlc generates type-safe Go from SQL. No ORM, no query builder — raw SQL that's verified at code generation time.

-- db/queries/users.sql
-- name: GetUser :one
SELECT id, email, created_at FROM users WHERE id = $1 AND tenant_id = $2;

-- name: CreateUser :one
INSERT INTO users (id, email, pass_hash, tenant_id)
VALUES (gen_random_uuid(), $1, $2, $3)
RETURNING id;
// generated by sqlc
func (q *Queries) GetUser(ctx context.Context, arg GetUserParams) (User, error) { ... }
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (uuid.UUID, error) { ... }

Type-safe. No runtime query errors. The SQL is readable and reviewable in code review.

Testing: table-driven with real DB

func TestCreateUser(t *testing.T) {
    db := testDB(t)  // opens real test DB, runs migrations, closes on cleanup
    repo := repository.New(db)
    svc := service.New(repo, testConfig())

    tests := []struct {
        name    string
        email   string
        wantErr bool
    }{
        {"valid user", "test@example.com", false},
        {"duplicate email", "test@example.com", true},
        {"invalid email", "not-an-email", true},
        {"empty email", "", true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            _, err := svc.CreateUser(context.Background(), tt.email, "password123")
            if (err != nil) != tt.wantErr {
                t.Errorf("CreateUser() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

func testDB(t *testing.T) *pgxpool.Pool {
    t.Helper()
    db, err := database.Connect(os.Getenv("TEST_DATABASE_URL"))
    if err != nil {
        t.Fatalf("test db: %v", err)
    }
    t.Cleanup(func() { db.Close() })
    return db
}

Real database in tests. No mocks for repository layer. The testDB helper opens a connection, cleans up after the test, and fails fast if the DB isn't available.

Middleware: request ID + structured logging

func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-ID")
        if id == "" {
            id = uuid.New().String()
        }
        ctx := context.WithValue(r.Context(), requestIDKey, id)
        w.Header().Set("X-Request-ID", id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func Logger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        ww := &responseWriter{ResponseWriter: w, status: 200}
        next.ServeHTTP(ww, r)
        slog.Info("request",
            slog.String("method", r.Method),
            slog.String("path", r.URL.Path),
            slog.Int("status", ww.status),
            slog.Duration("duration", time.Since(start)),
            slog.String("request_id", RequestID(r.Context())),
        )
    })
}

Structured JSON logs (slog) work with CloudWatch Logs Insights for querying across services.

FAQ

Why Go over other languages for microservices? Single binary deployment, low memory footprint, excellent concurrency (goroutines), and static typing that catches errors at compile time. Go microservices are simpler to operate than JVM or Python equivalents at comparable capability.

Why sqlc instead of an ORM like GORM? sqlc generates type-safe Go from real SQL — you write SQL, not an ORM DSL. SQL is more readable, more debuggable, and more performant than ORM-generated queries. sqlc catches query errors at code generation time, not runtime.

What's the right size for a Go microservice? A service should own one domain and one database schema. If you're writing SQL JOINs across services, split or merge differently. A good heuristic: one service = one team can own it without coordination with other teams.

How do you handle distributed transactions across services? Avoid them. Design services to be eventually consistent with events (outbox pattern) rather than requiring 2PC. For cases where strong consistency is required, keep the transaction within one service/database.


Written by Shihab Shahriar Antor — AI Engineer & Founder of Shahriar Labs. See also: Why I Use Go for AI Backends · Microservices as One Engineer.