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.
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.