go-expert
Idiomatic production Go code. Goroutines, channels, error handling patterns, interfaces, context propagation, HTTP servers, pgx database patterns, testing, and structured logging. What senior Go engineers write.
Installation
npx clawhub@latest install go-expertView the full skill documentation and source below.
Documentation
Go Production Expert
Go Philosophy
Go is explicit, not clever. Idiomatic Go is:
- Simple over clever — readable in 6 months
- Errors as values — handle them explicitly, every time
- Composition over inheritance — small interfaces, embedding
- Concurrency via CSP — communicate by sharing memory? No. Share memory by communicating.
Project Structure
myapp/
├── cmd/
│ ├── server/
│ │ └── main.go # Entry point — thin, delegates to internal
│ └── worker/
│ └── main.go
├── internal/ # Private code — not importable by external packages
│ ├── api/
│ │ ├── handler.go
│ │ ├── handler_test.go
│ │ └── middleware.go
│ ├── domain/
│ │ ├── user.go
│ │ └── user_test.go
│ ├── repository/
│ │ ├── postgres.go
│ │ └── postgres_test.go
│ └── service/
│ └── user_service.go
├── pkg/ # Public packages (reusable by external packages)
├── migrations/
├── go.mod
├── go.sum
└── Makefile
Error Handling
Rule 1: Always return errors, never ignore them.
Rule 2: Wrap errors with context at each layer.
Rule 3: Check errors.Is and errors.As, not string comparison.
// Define domain errors
type NotFoundError struct {
Resource string
ID string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s with id %s not found", e.Resource, e.ID)
}
// Use sentinel errors for expected cases
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrConflict = errors.New("conflict")
)
// Wrap with context — %w makes it unwrappable
func (r *UserRepo) GetByID(ctx context.Context, id string) (*User, error) {
var u User
err := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id=$1", id).Scan(&u)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user %s: %w", id, ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("query user %s: %w", id, err)
}
return &u, nil
}
// Check wrapped errors
user, err := repo.GetByID(ctx, userID)
if errors.Is(err, ErrNotFound) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
if err != nil {
log.Error("failed to get user", "error", err, "user_id", userID)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
Interfaces (Small is Powerful)
// Define interfaces at the point of USE, not definition
// Small interfaces > large interfaces
// io.Reader is the gold standard — 1 method
type Reader interface {
Read(p []byte) (n int, err error)
}
// Your service interfaces
type UserStore interface {
GetByID(ctx context.Context, id string) (*User, error)
Create(ctx context.Context, u *User) error
Update(ctx context.Context, u *User) error
Delete(ctx context.Context, id string) error
}
// Compose for flexibility
type UserReader interface {
GetByID(ctx context.Context, id string) (*User, error)
}
type UserWriter interface {
Create(ctx context.Context, u *User) error
Update(ctx context.Context, u *User) error
}
// Read-only service only needs UserReader
type ReadService struct {
store UserReader // NOT UserStore
}
// Test with a mock — no mocking library needed
type mockUserStore struct {
users map[string]*User
}
func (m *mockUserStore) GetByID(_ context.Context, id string) (*User, error) {
u, ok := m.users[id]
if !ok {
return nil, ErrNotFound
}
return u, nil
}
// Compile-time interface check
var _ UserStore = (*PostgresUserStore)(nil)
Context and Cancellation
// Always accept ctx as first parameter
func (s *Service) ProcessOrder(ctx context.Context, orderID string) error {
// Propagate cancellation to all operations
user, err := s.userStore.GetByID(ctx, orderID)
if err != nil {
return err
}
// Check for cancellation explicitly in long operations
select {
case <-ctx.Done():
return ctx.Err()
default:
}
return s.doExpensiveWork(ctx, user)
}
// Set deadlines — always
func handler(w http.ResponseWriter, r *http.Request) {
// Add timeout to request context
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel() // ALWAYS defer cancel to prevent context leak
result, err := service.Process(ctx, r.FormValue("id"))
// ...
}
// Context values — only for request-scoped data
type contextKey string
const (
RequestIDKey contextKey = "request_id"
UserIDKey contextKey = "user_id"
)
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, RequestIDKey, id)
}
func RequestIDFrom(ctx context.Context) string {
id, _ := ctx.Value(RequestIDKey).(string)
return id
}
Goroutines and Channels
// Goroutine lifecycle management with WaitGroup + errgroup
import "golang.org/x/sync/errgroup"
func ProcessBatch(ctx context.Context, items []Item) error {
g, ctx := errgroup.WithContext(ctx)
// Limit concurrency
sem := make(chan struct{}, 10) // 10 concurrent max
for _, item := range items {
item := item // Capture loop variable (pre-Go 1.22)
g.Go(func() error {
sem <- struct{}{}
defer func() { <-sem }()
return processItem(ctx, item)
})
}
return g.Wait() // Returns first error, cancels context
}
// Fan-out/Fan-in pattern
func fanOut(ctx context.Context, input <-chan Work) []<-chan Result {
numWorkers := 5
channels := make([]<-chan Result, numWorkers)
for i := range channels {
channels[i] = worker(ctx, input)
}
return channels
}
func fanIn(ctx context.Context, channels ...<-chan Result) <-chan Result {
var wg sync.WaitGroup
merged := make(chan Result)
output := func(c <-chan Result) {
defer wg.Done()
for r := range c {
select {
case merged <- r:
case <-ctx.Done():
return
}
}
}
wg.Add(len(channels))
for _, c := range channels {
go output(c)
}
go func() {
wg.Wait()
close(merged)
}()
return merged
}
// Channel direction typing (reduces bugs)
func producer(ch chan<- int) { // Send only
ch <- 42
}
func consumer(ch <-chan int) { // Receive only
v := <-ch
fmt.Println(v)
}
HTTP Server
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
mux := http.NewServeMux()
// Go 1.22+ pattern matching
mux.HandleFunc("GET /api/users/{id}", handleGetUser)
mux.HandleFunc("POST /api/users", handleCreateUser)
mux.HandleFunc("GET /healthz", handleHealth)
// Wrap with middleware chain
handler := chain(
mux,
requestIDMiddleware,
loggingMiddleware(logger),
recoveryMiddleware,
)
server := &http.Server{
Addr: ":8080",
Handler: handler,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
}
// Graceful shutdown
go func() {
logger.Info("starting server", "addr", server.Addr)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
logger.Error("server error", "error", err)
os.Exit(1)
}
}()
// Wait for shutdown signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
logger.Error("shutdown error", "error", err)
}
}
// Middleware chain helper
func chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
h = middlewares[i](h)
}
return h
}
// Request ID middleware
func requestIDMiddleware(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 := WithRequestID(r.Context(), id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Recovery middleware
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic", "recover", rec, "stack", debug.Stack())
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
Database Patterns (pgx)
import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5"
)
type DB struct {
pool *pgxpool.Pool
}
func NewDB(ctx context.Context, dsn string) (*DB, error) {
config, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parse db config: %w", err)
}
config.MaxConns = 25
config.MinConns = 5
config.MaxConnLifetime = time.Hour
config.MaxConnIdleTime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("ping db: %w", err)
}
return &DB{pool: pool}, nil
}
// Transaction helper
func (db *DB) WithTx(ctx context.Context, fn func(pgx.Tx) error) error {
tx, err := db.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
if err := fn(tx); err != nil {
_ = tx.Rollback(ctx)
return err
}
return tx.Commit(ctx)
}
// Usage
err = db.WithTx(ctx, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, "UPDATE accounts SET balance=$1 WHERE id=$2", newBalance, accountID)
if err != nil {
return fmt.Errorf("update balance: %w", err)
}
_, err = tx.Exec(ctx, "INSERT INTO transactions (...) VALUES (...)", ...)
return err
})
Testing
// table-driven tests
func TestGetUser(t *testing.T) {
tests := []struct{
name string
userID string
want *User
wantErr error
}{
{
name: "existing user",
userID: "user-1",
want: &User{ID: "user-1", Name: "Alice"},
},
{
name: "missing user",
userID: "missing",
wantErr: ErrNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// t.Parallel() // Add for independent tests
store := &mockUserStore{
users: map[string]*User{"user-1": {ID: "user-1", Name: "Alice"}},
}
svc := NewUserService(store)
got, err := svc.GetUser(context.Background(), tt.userID)
if !errors.Is(err, tt.wantErr) {
t.Errorf("GetUser() error = %v, wantErr %v", err, tt.wantErr)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("GetUser() = %v, want %v", got, tt.want)
}
})
}
}
// Benchmark
func BenchmarkProcessOrder(b *testing.B) {
svc := newTestService()
order := testOrder()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = svc.ProcessOrder(context.Background(), order)
}
}
// Integration test with testcontainers
func TestPostgresRepo(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
container, err := postgres.RunContainer(ctx,
testcontainers.WithImage("postgres:16"),
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections"),
),
)
require.NoError(t, err)
defer container.Terminate(ctx)
dsn, _ := container.ConnectionString(ctx)
db, err := NewDB(ctx, dsn)
require.NoError(t, err)
// Run migrations
require.NoError(t, runMigrations(ctx, db))
repo := NewUserRepo(db)
// test against real database...
}
slog Structured Logging (Go 1.21+)
import "log/slog"
// Setup once
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
AddSource: true,
}))
slog.SetDefault(logger)
// Use anywhere
slog.Info("user created", "user_id", userID, "email", email)
slog.Error("failed to process", "error", err, "order_id", orderID)
// With context
slog.InfoContext(ctx, "request processed",
"request_id", RequestIDFrom(ctx),
"duration_ms", time.Since(start).Milliseconds(),
"status", statusCode,
)