| name | go |
| description | Use when writing or debugging non-trivial Go — error handling patterns, goroutine/channel design, interface composition, generics, context propagation, or Go-specific idioms like table-driven tests and functional options. |
Go — Advanced Patterns
Language-level patterns for writing correct, idiomatic, performant Go.
When to Activate
- Designing error types, wrapping, and sentinel errors
- Structuring goroutines, channels, and
sync primitives correctly
- Propagating
context.Context for cancellation and deadlines
- Composing interfaces and embedding types
- Using generics (
any, constraints, type parameters) appropriately
- Writing table-driven tests, benchmarks, or fuzz targets
- Applying functional options, builder patterns, or the options struct pattern
- Structuring packages, modules, and internal vs exported APIs
Error Handling
Wrapping and unwrapping
import "errors"
if err != nil {
return fmt.Errorf("fetch user %d: %w", id, err)
}
var ErrNotFound = errors.New("not found")
var ErrUnauthorized = errors.New("unauthorized")
if errors.Is(err, ErrNotFound) {
}
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
}
var ve *ValidationError
if errors.As(err, &ve) {
log.Printf("invalid field: %s", ve.Field)
}
Multiple return errors
data, _ := os.ReadFile("config.json")
data, err := os.ReadFile("config.json")
if err != nil {
return fmt.Errorf("read config: %w", err)
}
func process(id string) (*Result, error) {
user, err := db.GetUser(id)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
orders, err := db.GetOrders(id)
if err != nil {
return nil, fmt.Errorf("get orders: %w", err)
}
return &Result{User: user, Orders: orders}, nil
}
Goroutines and Channels
Goroutine lifecycle — always have an exit strategy
go func() {
for {
process()
}
}()
func worker(ctx context.Context, jobs <-chan Job) {
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
process(job)
}
}
}
Fan-out / fan-in
func fanOut(ctx context.Context, in <-chan int, workers int) <-chan Result {
out := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for v := range in {
select {
case out <- compute(v):
case <-ctx.Done():
return
}
}
}()
}
go func() {
wg.Wait()
close(out)
}()
return out
}
Channel patterns
done := make(chan struct{})
close(done)
sem := make(chan struct{}, 10)
sem <- struct{}{}
defer func() { <-sem }()
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
select {
case ch <- value:
default:
}
sync primitives
type SafeCounter struct {
mu sync.Mutex
count map[string]int
}
func (c *SafeCounter) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.count[key]++
}
type Cache struct {
mu sync.RWMutex
store map[string]string
}
func (c *Cache) Get(k string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.store[k]
return v, ok
}
var instance *DB
var once sync.Once
func GetDB() *DB {
once.Do(func() { instance = connect() })
return instance
}
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(i Item) {
defer wg.Done()
process(i)
}(item)
}
wg.Wait()
Context
func FetchUser(ctx context.Context, id string) (*User, error) { ... }
type Service struct{ db *DB }
func (s *Service) Get(ctx context.Context, id string) (*User, error) { ... }
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
ctx, cancel := context.WithCancel(context.Background())
go worker(ctx)
cancel()
type ctxKey string
const requestIDKey ctxKey = "requestID"
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func RequestID(ctx context.Context) string {
v, _ := ctx.Value(requestIDKey).(string)
return v
}
for _, item := range largeSlice {
if ctx.Err() != nil {
return ctx.Err()
}
process(item)
}
Interfaces and Embedding
Interface design — small, composable
type Storage interface {
Get(id string) (*User, error)
Save(u *User) error
Delete(id string) error
List(filter Filter) ([]*User, error)
Search(q string) ([]*User, error)
Count() (int, error)
}
type UserGetter interface {
GetUser(ctx context.Context, id string) (*User, error)
}
type UserSaver interface {
SaveUser(ctx context.Context, u *User) error
}
type UserStore interface {
UserGetter
UserSaver
}
func NewService(store UserGetter) *Service { ... }
func NewPostgresStore(db *sql.DB) *PostgresStore { ... }
Embedding
type Animal struct{ Name string }
func (a Animal) Speak() string { return a.Name }
type Dog struct {
Animal
Breed string
}
type ReadWriter interface {
io.Reader
io.Writer
}
type LoggedStore struct {
Store
log *slog.Logger
}
func (ls *LoggedStore) GetUser(ctx context.Context, id string) (*User, error) {
u, err := ls.Store.GetUser(ctx, id)
ls.log.Info("get user", "id", id, "err", err)
return u, err
}
Functional Options Pattern
Preferred over long constructor signatures or config structs that need zero values to be meaningful.
type Server struct {
host string
port int
timeout time.Duration
logger *slog.Logger
}
type Option func(*Server)
func WithPort(p int) Option { return func(s *Server) { s.port = p } }
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
func WithLogger(l *slog.Logger) Option { return func(s *Server) { s.logger = l } }
func NewServer(host string, opts ...Option) *Server {
s := &Server{host: host, port: 8080, timeout: 30 * time.Second}
for _, opt := range opts {
opt(s)
}
return s
}
srv := NewServer("localhost",
WithPort(9090),
WithTimeout(10*time.Second),
WithLogger(slog.Default()),
)
Generics
type Number interface {
~int | ~int32 | ~int64 | ~float32 | ~float64
}
func Sum[T Number](items []T) T {
var total T
for _, v := range items {
total += v
}
return total
}
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
v := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return v, true
}
func Map[T, U any](slice []T, fn func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
func Filter[T any](slice []T, fn func(T) bool) []T {
var result []T
for _, v := range slice {
if fn(v) {
result = append(result, v)
}
}
return result
}
HTTP Patterns (net/http)
type Handler struct {
svc *UserService
log *slog.Logger
}
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
user, err := h.svc.Get(r.Context(), id)
if err != nil {
if errors.Is(err, ErrNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
h.log.Error("get user", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", h.GetUser)
mux.HandleFunc("POST /users", h.CreateUser)
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
slog.Info("request", "method", r.Method, "path", r.URL.Path, "dur", time.Since(start))
})
}
srv := &http.Server{
Addr: ":8080",
Handler: logging(mux),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
Testing
Table-driven tests
func TestAdd(t *testing.T) {
cases := []struct {
name string
a, b int
want int
}{
{"positive", 1, 2, 3},
{"negative", -1, -2, -3},
{"zero", 0, 0, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Add(tc.a, tc.b)
if got != tc.want {
t.Errorf("Add(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want)
}
})
}
}
Test helpers and cleanup
func setupDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
Benchmarks and fuzz tests
func BenchmarkProcess(b *testing.B) {
data := generateData()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Process(data)
}
}
func FuzzParse(f *testing.F) {
f.Add("valid input")
f.Fuzz(func(t *testing.T, s string) {
_, err := Parse(s)
_ = err
})
}
Package and Module Conventions
| Pattern | Rule |
|---|
| Package names | Short, lowercase, no underscores: store, auth, httputil |
| Exported names | Self-documenting without package prefix: store.User not store.StoreUser |
internal/ | Enforces package privacy — only importable by parent module |
cmd/ | One sub-package per binary entry point |
| Error variables | Prefix with Err: var ErrNotFound = errors.New(...) |
| Interface location | Define interfaces in the consumer package, not the implementer |
init() | Avoid — prefer explicit initialization in main or constructors |
myapp/
├── cmd/
│ └── server/main.go # entry point
├── internal/
│ ├── auth/ # private to this module
│ └── store/
├── pkg/ # reusable, importable by others
│ └── httputil/
└── go.mod
Common Gotchas
for _, v := range items {
go func() { process(v) }()
go func(v Item) { process(v) }(v)
}
var p *MyType = nil
var i interface{} = p
i == nil
func getError() error { return nil }
a := []int{1, 2, 3}
b := a[:2]
b = append(b, 99)
b = a[:2:2]
var m map[string]int
m["x"] = 1
m = make(map[string]int)
m["x"] = 1
for _, f := range files {
f, _ := os.Open(f)
defer f.Close()
}
for _, name := range files {
func() {
f, _ := os.Open(name)
defer f.Close()
process(f)
}()
}
Performance Tips
| Technique | When to use |
|---|
| Pre-allocate slices | make([]T, 0, knownLen) — avoids repeated reallocation |
| Pre-allocate maps | make(map[K]V, knownLen) — reduces rehashing |
strings.Builder | Building strings in a loop — never += in a loop |
sync.Pool | Reuse short-lived allocations (buffers, scratch objects) |
| Avoid interface{} in hot paths | Boxing/unboxing costs allocation; use generics or concrete types |
//go:noescape / unsafe | Only after profiling with pprof |
| Benchmark first | go test -bench=. -benchmem -cpuprofile=cpu.out |
Red Flags
- Goroutine without a stop condition — every goroutine must have a way to exit (context cancellation, channel close, or done signal); goroutine leaks accumulate and crash servers under load
- Storing
context.Context in a struct — context is request-scoped and must be passed as the first function parameter; storing it bypasses cancellation and makes the struct un-testable
errors.New compared with == — sentinel errors must use errors.Is because wrapping breaks ==; define var ErrX = errors.New(...) and always check with errors.Is
- Shadowing
err with := in nested scope — if err := ...; err != nil inside a block creates a new err that shadows the outer one; outer error is silently unchanged
- Nil pointer returned as non-nil interface — returning
(*ConcreteType)(nil) as an error or interface{} produces a non-nil interface; always return untyped nil
defer inside a loop — deferred calls run at function return, not loop end; open file handles accumulate; wrap the loop body in a closure or helper function
- Fat interfaces defined in the implementer package — interfaces belong in the consumer package; large interfaces make mocking painful and couple packages unnecessarily
- Accessing a nil map — reading is safe (returns zero value), writing panics; always initialize maps with
make
Checklist