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—preferinternal.- Generated code: document
buf/protocoutput dirs and one-linemockgen/go:generatecommands—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 carryerrorand 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