Go microservices

Structure internal/pkg, thread context.Context, wrap with errors.Join / fmt.Errorf, and wire gRPC or HTTP JSON; document graceful shutdown and observability naming in the SKILL.

The SKILL defines service boundaries, interfaces, and mock generation; never log.Fatal in library code; graceful shutdown via signal.Notify and http.Server.Shutdown (or gRPC GracefulStop).

Default client timeouts, retries, and breaker behavior; if protobuf/buf exists, point to codegen scripts and go:generate lines.

For errgroup or worker pools, document cancellation propagation and upper bounds (goroutine leak prevention).

  • Config: viper/env with struct validation; twelve-factor aligned with container entrypoints.
  • Tests: table-driven, httptest, integration build tags.
  • Pair with observability skills: one trace/request id across logs and metrics.

Authoring overview

Layer transport (HTTP/gRPC server), use cases, and adapters (DB/external RPC). Put stable cross-repo APIs in pkg only when needed; default everything else to internal to avoid accidental exports.

Wrap errors up the stack with %w to preserve types; at boundaries (handlers) map to HTTP status or gRPC codes without leaking internals.

// Small interface composition: io.Reader + io.Writer
package storage

import "io"

// Reader exposes only read capability, easy to mock
type Reader interface {
    Get(ctx context.Context, key string) (io.ReadCloser, error)
}

// Writer exposes only write capability
type Writer interface {
    Put(ctx context.Context, key string, r io.Reader) error
}

// ReadWriter composes as needed; usecases only accept the interface they actually need
type ReadWriter interface {
    Reader
    Writer
}

// errors.Is / errors.As / %w correct usage
var ErrNotFound = errors.New("not found")

type StoreError struct {
    Key string
    Err error
}
func (e *StoreError) Error() string { return fmt.Sprintf("store: key=%s: %v", e.Key, e.Err) }
func (e *StoreError) Unwrap() error { return e.Err }

func GetItem(ctx context.Context, r Reader, key string) (io.ReadCloser, error) {
    rc, err := r.Get(ctx, key)
    if err != nil {
        // %w preserves original type; caller can errors.Is(err, ErrNotFound)
        return nil, fmt.Errorf("GetItem: %w", &StoreError{Key: key, Err: err})
    }
    return rc, nil
}

// Caller distinguishes error types
func handle(err error) {
    var se *StoreError
    if errors.As(err, &se) {
        log.Printf("key=%s failed", se.Key)
    }
    if errors.Is(err, ErrNotFound) {
        // return HTTP 404
    }
}

Request path (transport → usecase)

Typical order for one inbound call; middleware/interceptor order follows project registration. Inject context at the edge and pass deadlines/cancellation to clients and queries.

  [ Entry: HTTP / gRPC listener ]
        │
        ▼
  ┌─────────────┐     OTel/gRPC stats, auth, recover, optional rate limits
  │ Middleware  │
  └─────────────┘
        │
        ▼
  ┌─────────────┐     Decode; validate DTOs; inject deps (interfaces)
  │  handler    │
  └─────────────┘
        │
        ▼
  ┌─────────────┐     Pure business/orchestration; context + domain types
  │  usecase    │
  └─────────────┘
        │
        ▼
  ┌─────────────┐     DB / cache / downstream gRPC-HTTP; retries cancelable
  │  adapters   │
  └─────────────┘
        │
        ▼
  [ Encode response; record metrics/spans; return ]

Graceful shutdown: stop accepting → drain in-flight with timeout → close DB pools and background workers; document pairing with Kubernetes preStop sleep if used.

// goroutine + channel producer-consumer example (with context cancellation)
package worker

import (
    "context"
    "fmt"
    "golang.org/x/sync/errgroup"
)

type Job struct{ ID int }
type Result struct{ ID int; Out string }

func Run(ctx context.Context, jobs []Job, concurrency int) ([]Result, error) {
    jobCh := make(chan Job, len(jobs))
    resCh := make(chan Result, len(jobs))

    // Producer: fill then close immediately, no goroutine leak
    go func() {
        defer close(jobCh)
        for _, j := range jobs {
            select {
            case jobCh <- j:
            case <-ctx.Done():
                return
            }
        }
    }()

    // errgroup manages N consumer goroutines; any error cancels the rest
    g, gctx := errgroup.WithContext(ctx)
    for i := 0; i < concurrency; i++ {
        g.Go(func() error {
            for job := range jobCh {
                select {
                case <-gctx.Done():
                    return gctx.Err()
                default:
                }
                resCh <- Result{ID: job.ID, Out: fmt.Sprintf("done-%d", job.ID)}
            }
            return nil
        })
    }

    // Wait for all consumers, then close result channel
    go func() { g.Wait(); close(resCh) }()

    var results []Result
    for r := range resCh {
        results = append(results, r)
    }
    return results, g.Wait()
}
// Graceful shutdown: signal.Notify + http.Server.Shutdown
package main

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

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

    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            slog.Error("server error", "err", err)
            os.Exit(1)
        }
    }()

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

    slog.Info("shutting down", "timeout", "30s")
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        slog.Error("shutdown error", "err", err)
    }
    slog.Info("server exited cleanly")
}

Layout: internal & pkg

cmd/<binary>/main.go only wires: config, logger/tracer, dependencies, server start. Split internal/ by boundary (api, service, store) with small interfaces for test doubles.

  • internal: default home for business logic; the compiler blocks external imports—good for multi-binary monorepos or shared repos.
  • pkg: only when something must be imported outside the module; otherwise it becomes a junk drawer—prefer internal.
  • Generated code: document buf/protoc output dirs and one-line mockgen / go:generate commands—no manual copy/paste.
# Recommended directory layout
payments/
├── cmd/
│   └── server/
│       └── main.go          # wiring only: config + logger + wire + server.Start
├── internal/
│   ├── api/
│   │   └── handler.go       # HTTP/gRPC transport: decode/encode
│   ├── service/
│   │   └── payment.go       # usecase layer: accepts context + domain types only
│   ├── store/
│   │   └── postgres.go      # DB adapter: implements interfaces from service
│   └── config/
│       └── config.go        # viper/env read + struct validation
├── pkg/
│   └── money/               # only here if genuinely reused outside this module
│       └── amount.go
├── gen/                     # buf/protoc generated; never edit manually
│   └── proto/
├── go.mod
└── go.sum

# go.mod example
module github.com/acme/payments

go 1.22

require (
    golang.org/x/sync v0.7.0
    google.golang.org/grpc v1.63.2
)

# mockgen command (document in SKILL or Makefile)
# go:generate go run go.uber.org/mock/mockgen -source=internal/service/payment.go -destination=internal/service/mock_payment.go

Metrics, logs, traces

Document metric prefixes (e.g. <service>_ or Prometheus Subsystem), histogram buckets, and shared trace_id/span_id field names across traces and logs; align OpenTelemetry resource (service.name, service.version).

  • Prometheus: HTTP names like http_server_request_duration_seconds; avoid high-cardinality labels (e.g. raw user ids).
  • Structured logs: slog, zap, etc.—keys match your log stack (Loki/ELK); errors carry error and a stack policy (debug-only vs always).
  • Further reading: Logging & distributed tracing, Monitoring dashboards.
// slog structured logging (Go 1.21+) — aligned with trace fields
package main

import (
    "context"
    "log/slog"
    "os"
    "go.opentelemetry.io/otel/trace"
)

// newLogger creates a JSON handler writing to stdout
func newLogger(level slog.Level) *slog.Logger {
    opts := &slog.HandlerOptions{Level: level}
    return slog.New(slog.NewJSONHandler(os.Stdout, opts))
}

// withTrace injects trace_id / span_id from context, aligning with OTel
func withTrace(ctx context.Context, logger *slog.Logger) *slog.Logger {
    span := trace.SpanFromContext(ctx)
    if !span.SpanContext().IsValid() {
        return logger
    }
    sc := span.SpanContext()
    return logger.With(
        slog.String("trace_id", sc.TraceID().String()),
        slog.String("span_id", sc.SpanID().String()),
    )
}

// Usage: inject trace fields in handler
func ProcessPayment(ctx context.Context, logger *slog.Logger, amount float64) error {
    log := withTrace(ctx, logger)
    log.Info("processing payment", slog.Float64("amount", amount))

    if err := chargeCard(ctx, amount); err != nil {
        // "error" key aligned with Loki/ELK standard queries
        log.Error("charge failed", slog.String("error", err.Error()))
        return fmt.Errorf("ProcessPayment: %w", err)
    }
    log.Info("payment done", slog.Float64("amount", amount))
    return nil
}

go.mod module path preview

Normalize a draft path into one module line: backslashes to slashes, collapse repeats, trim ends, drop trailing slash. Version suffixes/submodules still follow team go.mod and GOPROXY policy.

Normalized output


              

---
name: go-microservice
description: Idiomatic Go microservice boundaries, concurrency, and observability over RPC/HTTP
tags: [go, microservice, concurrency, observability]
---
# Module Layout and Interfaces
- cmd/<binary>/main.go contains only wiring; no business logic
- internal/ organized by boundary (api / service / store)
- Minimize interfaces: expose only the method set the caller actually needs
- mockgen command documented in go:generate or Makefile

# Concurrency Patterns
- goroutine + channel producer-consumer; errgroup manages cancellation and fan-in
- Clearly define goroutine lifecycle: who owns closing the channel
- Worker pool caps concurrency to prevent unbounded memory growth

# Error Handling
- %w wrapping preserves distinguishable types; errors.Is / errors.As at boundaries
- Custom error types implement Unwrap(); handler layer maps to HTTP status codes
- Never log.Fatal in library code; propagate errors upward to entry point for unified handling

# Observability
- slog JSON handler; field names trace_id / span_id aligned with OTel
- Business metric prefix: payments_; histogram bucket convention documented in SKILL
- Graceful shutdown: signal.Notify SIGTERM -> Shutdown(30s ctx) -> close pools

All skills More skills