Go’s combination of static types, goroutines, and explicit error handling makes it well-suited for microservices. The idioms matter: context propagation for cancellation, channels for coordination, and errors as values rather than exceptions. Claude Code writes idiomatic Go with proper HTTP handler structure, gRPC implementations, goroutine lifecycle management, and structured logging with the standard slog package.
CLAUDE.md for Go Projects
## Go Stack
- Go 1.22+
- HTTP: net/http with chi router for complex routing
- gRPC: google.golang.org/grpc + google.golang.org/protobuf
- Database: pgx/v5 directly (not GORM)
- Logging: log/slog (standard library, structured JSON)
- Config: environment variables via os.Getenv, validated at startup
- Testing: standard testing package + testify/assert
- Errors: return errors, wrap with fmt.Errorf("context: %w", err)
- Context: always first parameter, always propagate, never store in structs
- No panic in library code; panic only in main() for unrecoverable startup failures
HTTP Server with Graceful Shutdown
// cmd/api/main.go
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
db, err := connectDB(os.Getenv("DATABASE_URL"))
if err != nil {
slog.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer db.Close()
handlers := &Handlers{db: db, logger: logger}
router := buildRouter(handlers, logger)
server := &http.Server{
Addr: ":" + getEnvOrDefault("PORT", "8080"),
Handler: router,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
go func() {
slog.Info("server starting", "addr", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server failed", "error", err)
os.Exit(1)
}
}()
<-quit
slog.Info("shutting down server")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
slog.Error("server shutdown failed", "error", err)
}
slog.Info("server stopped")
}
func buildRouter(h *Handlers, logger *slog.Logger) *chi.Mux {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(structuredLogger(logger))
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(15 * time.Second))
r.Get("/healthz", h.healthCheck)
r.Get("/readyz", h.readinessCheck)
r.Route("/api/v1", func(r chi.Router) {
r.Use(authMiddleware)
r.Route("/orders", func(r chi.Router) {
r.Get("/", h.listOrders)
r.Post("/", h.createOrder)
r.Get("/{id}", h.getOrder)
})
})
return r
}
HTTP Handlers
// internal/handlers/orders.go
type Handlers struct {
db *pgxpool.Pool
logger *slog.Logger
}
func (h *Handlers) createOrder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := req.Validate(); err != nil {
writeError(w, http.StatusUnprocessableEntity, err.Error())
return
}
order, err := h.createOrderInDB(ctx, req)
if err != nil {
h.logger.ErrorContext(ctx, "failed to create order",
"error", err,
"customer_id", req.CustomerID,
)
writeError(w, http.StatusInternalServerError, "failed to create order")
return
}
writeJSON(w, http.StatusCreated, order)
}
func (h *Handlers) createOrderInDB(ctx context.Context, req CreateOrderRequest) (*Order, error) {
tx, err := h.db.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback(ctx)
var orderID string
err = tx.QueryRow(ctx,
`INSERT INTO orders (customer_id, total_cents, status)
VALUES ($1, $2, 'PENDING') RETURNING id`,
req.CustomerID, req.TotalCents,
).Scan(&orderID)
if err != nil {
return nil, fmt.Errorf("insert order: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return &Order{ID: orderID, Status: "PENDING"}, nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
Goroutine Patterns
// Worker pool: bounded concurrency
func processOrderBatch(ctx context.Context, orders []Order, workerCount int) error {
work := make(chan Order, len(orders))
errs := make(chan error, len(orders))
var wg sync.WaitGroup
for range workerCount {
wg.Add(1)
go func() {
defer wg.Done()
for order := range work {
if err := processOrder(ctx, order); err != nil {
errs <- fmt.Errorf("order %s: %w", order.ID, err)
}
}
}()
}
for _, order := range orders {
work <- order
}
close(work)
wg.Wait()
close(errs)
var allErrors []error
for err := range errs {
allErrors = append(allErrors, err)
}
return errors.Join(allErrors...)
}
// Context timeout on external call
func callExternalAPI(ctx context.Context, url string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("request timed out after 5s")
}
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
// Fan-out: launch N goroutines, collect first error
func enrichOrders(ctx context.Context, orders []*Order) error {
g, ctx := errgroup.WithContext(ctx)
for _, order := range orders {
order := order // Capture loop variable (Go < 1.22)
g.Go(func() error {
customer, err := fetchCustomer(ctx, order.CustomerID)
if err != nil {
return fmt.Errorf("fetch customer %s: %w", order.CustomerID, err)
}
order.CustomerName = customer.Name
return nil
})
}
return g.Wait()
}
gRPC Service
// internal/grpc/orders_server.go
type OrdersServer struct {
pb.UnimplementedOrdersServiceServer
db *pgxpool.Pool
logger *slog.Logger
}
func (s *OrdersServer) CreateOrder(
ctx context.Context,
req *pb.CreateOrderRequest,
) (*pb.CreateOrderResponse, error) {
if req.CustomerId == "" {
return nil, status.Error(codes.InvalidArgument, "customer_id is required")
}
order, err := createOrderInDB(ctx, s.db, req)
if err != nil {
s.logger.ErrorContext(ctx, "failed to create order", "error", err)
return nil, status.Error(codes.Internal, "internal error")
}
return &pb.CreateOrderResponse{OrderId: order.ID}, nil
}
func (s *OrdersServer) StreamOrders(
req *pb.StreamOrdersRequest,
stream pb.OrdersService_StreamOrdersServer,
) error {
ctx := stream.Context()
rows, err := s.db.Query(ctx,
`SELECT id, status, total_cents FROM orders WHERE customer_id = $1`,
req.CustomerId,
)
if err != nil {
return status.Errorf(codes.Internal, "query failed: %v", err)
}
defer rows.Close()
for rows.Next() {
var order pb.Order
if err := rows.Scan(&order.Id, &order.Status, &order.TotalCents); err != nil {
return status.Errorf(codes.Internal, "scan failed: %v", err)
}
if err := stream.Send(&order); err != nil {
return err
}
}
return rows.Err()
}
// Start gRPC server alongside HTTP
func startGRPCServer(logger *slog.Logger, db *pgxpool.Pool) {
lis, err := net.Listen("tcp", ":9090")
if err != nil {
logger.Error("gRPC listen failed", "error", err)
os.Exit(1)
}
s := grpc.NewServer(
grpc.ChainUnaryInterceptor(
loggingUnaryInterceptor(logger),
),
)
pb.RegisterOrdersServiceServer(s, &OrdersServer{db: db, logger: logger})
reflection.Register(s) // For grpcurl debugging
slog.Info("gRPC server starting", "addr", ":9090")
if err := s.Serve(lis); err != nil {
logger.Error("gRPC serve failed", "error", err)
}
}
Structured Logging Middleware
func structuredLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
logger.InfoContext(r.Context(), "request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"bytes", ww.BytesWritten(),
"duration_ms", time.Since(start).Milliseconds(),
"request_id", middleware.GetReqID(r.Context()),
)
})
}
}
Table-Driven Tests
// orders_test.go
func TestCreateOrder(t *testing.T) {
tests := []struct {
name string
req CreateOrderRequest
wantStatus int
wantErr string
}{
{
name: "valid order",
req: CreateOrderRequest{CustomerID: "cust-1", TotalCents: 1000},
wantStatus: http.StatusCreated,
},
{
name: "missing customer ID",
req: CreateOrderRequest{TotalCents: 1000},
wantStatus: http.StatusUnprocessableEntity,
wantErr: "customer_id is required",
},
{
name: "zero amount",
req: CreateOrderRequest{CustomerID: "cust-1", TotalCents: 0},
wantStatus: http.StatusUnprocessableEntity,
wantErr: "total_cents must be positive",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.req)
req := httptest.NewRequest(http.MethodPost, "/api/v1/orders", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.createOrder(w, req)
assert.Equal(t, tt.wantStatus, w.Code)
if tt.wantErr != "" {
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
assert.Contains(t, resp["error"], tt.wantErr)
}
})
}
}
For the Protocol Buffers definitions used by Go gRPC services, see the Protocol Buffers guide. For adding Dapr to Go microservices for pub/sub and state management, the Dapr guide shows the sidecar pattern. The Claude Skills 360 bundle includes Go skill sets covering HTTP/gRPC services, goroutine patterns, and structured logging. Start with the free tier to try Go microservice generation.