Files
go-patterns/patterns/package-design.md
T

15 KiB

Go Package Design Patterns

Patterns extracted from the Go standard library source code.


1. Package-Level Documentation

Source: src/io/io.go:5-13, src/sync/mutex.go:5-11, src/context/context.go:5-57

// src/io/io.go:5-13
// Package io provides basic interfaces to I/O primitives.
// Its primary job is to wrap existing implementations of such primitives,
// such as those in package os, into shared public interfaces that
// abstract the functionality, plus some other related primitives.
//
// Because these interfaces and primitives wrap lower-level operations with
// various implementations, unless otherwise informed clients should not
// assume they are safe for parallel execution.
package io
// src/sync/mutex.go:5-11
// Package sync provides basic synchronization primitives such as mutual
// exclusion locks. Other than the Once and WaitGroup types, most are intended
// for use by low-level library routines. Higher-level synchronization is
// better done via channels and communication.
//
// Values containing the types defined in this package should not be copied.
package sync

Why

The package comment:

  1. States the purpose in one sentence
  2. Establishes contracts (not safe for parallel execution, values must not be copied)
  3. Guides users toward correct usage (prefer channels over mutexes)
  4. Appears before package keyword — becomes go doc output

Convention

  • First sentence: "Package X does Y." or "Package X provides Y."
  • Subsequent paragraphs: contracts, caveats, links to deeper docs
  • For multi-file packages, put the package comment in doc.go or the primary file

Anti-pattern

// DON'T: No package comment
package myutil

// DON'T: Restate the obvious
// Package http provides HTTP stuff.
package http

// DON'T: Put implementation details in the package comment
// Package auth uses bcrypt with cost 12 and stores hashes in PostgreSQL.
package auth

2. Package Naming

Source: All stdlib packages follow these conventions

Stdlib examples:

  • io — not ioutil, not ioutils
  • fmt — not format, not formatting
  • sync — not synchronization
  • net/http — not net/httpserver
  • encoding/json — not encoding/jsonparser
  • context — not ctx or contexts
  • errors — not errs or errorhandling

Why

Go package names are:

  • Short — one word, lowercase, no underscores or mixedCaps
  • Clear — the name is the context for everything inside it
  • Singular (usually) — context not contexts, error exception (errors has functions)

The package name is part of every qualified identifier: http.Handler, json.Marshal, context.Context. Redundancy in naming is wasted keystrokes:

// Good: package name provides context
http.Server     // not http.HTTPServer
json.Encoder    // not json.JSONEncoder
context.Context // the type IS the context

Anti-pattern

// DON'T: Stutter (repeat package name in exported identifiers)
package http
type HTTPServer struct{}  // http.HTTPServer — redundant
func NewHTTPClient()      // http.NewHTTPClient — say "http" twice

// DON'T: Use utility/helper package names
package utils    // what does it DO?
package helpers  // grab bag, no cohesion
package common   // everything ends up here

// DON'T: Use plural when singular works
package requests  // should be: package request

3. internal/ Packages — Restricting Visibility

Source: src/net/http/internal/, src/encoding/json/internal.go

src/net/http/internal/
├── ascii/
├── chunked.go
├── common.go
├── http2/
├── httpcommon/
├── httpsfv/
├── sniff.go
└── testcert/

Why

Packages under internal/ can only be imported by code rooted at the parent of internal. For example:

  • net/http/internal/ascii can be imported by net/http and net/http/...
  • It cannot be imported by net/url or any other package

This lets you share code between sub-packages without making it part of the public API.

Usage Guidelines

myproject/
├── internal/          # shared across the project, but not importable externally
│   ├── auth/
│   └── metrics/
├── cmd/
│   └── server/
└── pkg/               # actually public API (if you use this convention)
    └── client/

Anti-pattern

// DON'T: Export implementation details that should be internal
package mylib
func HelperThatOnlyIUse() {}  // pollutes API surface

// DON'T: Put everything in internal/ (nothing is reusable)
// Balance: internal/ for implementation; exported packages for contracts

4. Export Rules — The Capital Letter Boundary

Source: Throughout stdlib — the convention is the language itself

// src/io/io.go
var EOF = errors.New("EOF")              // exported: uppercase
var errInvalidWrite = errors.New(...)    // unexported: lowercase

// src/io/io.go:622-625
type teeReader struct {  // unexported type
    r Reader
    w Writer
}

// src/io/io.go:618
func TeeReader(r Reader, w Writer) Reader {  // exported constructor
    return &teeReader{r, w}
}

Why

The exported/unexported boundary is Go's encapsulation mechanism. teeReader is unexported because:

  1. Users don't need to know its implementation
  2. The return type is Reader (the interface) — maximum flexibility
  3. The struct's fields can change without breaking anyone

Pattern: Exported Function, Unexported Type

// Export the constructor, not the type
func NewParser(r io.Reader) *parser { ... }  // WRONG: can't return unexported type

// Correct: return via interface or exported type
func TeeReader(r Reader, w Writer) Reader { return &teeReader{r, w} }

Anti-pattern

// DON'T: Export everything "just in case"
type Parser struct {
    Input string        // should this be settable? probably not
    buffer []byte       // internal state — definitely not
    pos    int
}

// DON'T: Make internal state accessible
type DB struct {
    Pool []*Conn  // callers shouldn't manipulate the pool directly
}

5. init() Functions — Use Sparingly

Source: src/net/http/http2.go:37, src/net/http/servemux121.go:31

// src/net/http/http2.go:37
func init() {
    // register HTTP/2 protocol implementation
}

Why

init() runs automatically at program start, in dependency order. The stdlib uses it for:

  • Driver registration (database drivers register via init)
  • Protocol negotiation (HTTP/2 registers its handler)
  • Configuration from build tags (servemux121.go — compatibility shim)

Rules

  1. init() should have no side effects beyond registration
  2. No errors should be possible (can't return error from init)
  3. Keep them short — they block program startup
  4. Prefer explicit initialization in main() when possible

Anti-pattern

// DON'T: Do heavy work in init
func init() {
    db = connectToDatabase()     // fails silently, crashes later
    cache = loadGigabyteFile()   // blocks startup
}

// DON'T: Use init for configuration
func init() {
    port = os.Getenv("PORT")  // harder to test, implicit dependency
}

// DO: Prefer explicit setup
func main() {
    db, err := connectToDatabase()
    if err != nil {
        log.Fatal(err)  // clear failure point
    }
}

6. Functional Options Pattern

Source: Not directly in stdlib, but net/http.Server and database/sql.DB demonstrate the problem it solves

The stdlib uses struct-based configuration (Server, Transport, DB config via setters). The functional options pattern emerged from the community to solve the "many optional parameters" problem:

// The pattern (not in stdlib, but idiom from Rob Pike/Dave Cheney):
type Option func(*Server)

func WithTimeout(d time.Duration) Option {
    return func(s *Server) {
        s.timeout = d
    }
}

func WithLogger(l *log.Logger) Option {
    return func(s *Server) {
        s.logger = l
    }
}

func NewServer(addr string, opts ...Option) *Server {
    s := &Server{addr: addr, timeout: 30 * time.Second}
    for _, opt := range opts {
        opt(s)
    }
    return s
}

What the stdlib uses instead: Config structs

// src/net/http/server.go (Server struct acts as config)
srv := &http.Server{
    Addr:         ":8080",
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
    Handler:      mux,
}

When to use which

Approach When
Config struct Few options, all are data (stdlib preference)
Functional options Many options, some involve behavior, public API stability matters
Builder pattern Rare in Go — usually overkill

Anti-pattern

// DON'T: Long parameter lists
func NewServer(addr string, timeout time.Duration, maxConns int, 
    logger *log.Logger, tls *tls.Config, handler Handler) *Server

// DON'T: Use functional options when a simple struct suffices
// (Over-engineering for 2-3 fields)

7. Constructor Pattern — NewX Functions

Source: src/net/http/server.go:2639, src/database/sql/sql.go:836

// src/net/http/server.go:2639
func NewServeMux() *ServeMux {
    return new(ServeMux)
}

// src/database/sql/sql.go:836-843
func OpenDB(c driver.Connector) *DB {
    ctx, cancel := context.WithCancel(context.Background())
    db := &DB{
        connector: c,
        openerCh:  make(chan struct{}, connectionRequestQueueSize),
        lastPut:   make(map[*driverConn]string),
        stop:      cancel,
    }
    go db.connectionOpener(ctx)
    return db
}

Why

  • NewX() when construction is trivial (just allocate)
  • OpenX() or NewXWithConfig() when construction involves resources, validation, or can fail
  • Return *T (pointer to concrete type), not an interface

The zero value should be usable where possible (sync.Mutex, bytes.Buffer), making constructors unnecessary.

Anti-pattern

// DON'T: Constructor that returns interface (hides useful methods)
func NewWriter() io.Writer { return &myWriter{} }

// DON'T: Require constructor when zero value works
type Buffer struct {
    buf []byte
    // ...
}
// var b bytes.Buffer   ← just works, no New needed

8. Package Organization — One Concern Per Package

Source: Standard library structure

src/
├── io/          # I/O interfaces + helpers
├── os/          # OS operations
├── net/         # network primitives
│   ├── http/    # HTTP protocol
│   └── url/     # URL parsing
├── encoding/    # encoding interfaces
│   ├── json/    # JSON codec
│   └── xml/     # XML codec
├── database/
│   └── sql/     # SQL database abstraction
│       └── driver/  # SPI for database drivers
└── context/     # cancellation propagation

Why

Each package has a single, clear responsibility:

  • io defines interfaces; os implements them for files
  • encoding/json handles JSON; encoding/xml handles XML
  • database/sql is the user-facing API; database/sql/driver is the implementor-facing SPI

Anti-pattern

// DON'T: Package per type
package user      // just has User struct
package order     // just has Order struct
package payment   // just has Payment struct
// 50 packages with 1 file each — Go prefers fewer, larger packages

// DON'T: Circular dependencies
package a imports package b
package b imports package a  // compile error

// FIX: Extract shared types into a third package, or merge

9. API Design — database/sql Separation of Concerns

Source: src/database/sql/sql.go vs src/database/sql/driver/driver.go

Two distinct APIs in one subsystem:

User-facing (database/sql):

db, _ := sql.Open("postgres", connStr)
rows, _ := db.QueryContext(ctx, "SELECT ...")
defer rows.Close()
for rows.Next() {
    rows.Scan(&id, &name)
}

Driver-facing (database/sql/driver):

type Driver interface {
    Open(name string) (Conn, error)
}
type Conn interface {
    Prepare(query string) (Stmt, error)
    Close() error
    Begin() (Tx, error)
}

Why

The user never sees driver.Conn. The driver never sees sql.DB's pool logic. Clean separation:

  • Users get a high-level, safe API with pooling and retry
  • Drivers implement a low-level, minimal interface
  • The sql package mediates between them

Anti-pattern

// DON'T: Expose implementation to users
type DB struct {
    driver driver.Conn  // users shouldn't touch this
}

// DON'T: Mix user and implementor APIs in one interface
type Database interface {
    Query(sql string) Rows      // user method
    Open(dsn string) Conn       // driver method — different audiences
}

10. Context Key Pattern — Type-Safe Context Values

Source: src/context/context.go:132-164, src/net/http/server.go:244-252

// src/context/context.go:132-164 (from doc comment)
// Package user defines a User type that's stored in Contexts.
// package user
//
// import "context"
//
// type key int
//
// var userKey key
//
// func NewContext(ctx context.Context, u *User) context.Context {
//     return context.WithValue(ctx, userKey, u)
// }
//
// func FromContext(ctx context.Context) (*User, bool) {
//     u, ok := ctx.Value(userKey).(*User)
//     return u, ok
// }
// src/net/http/server.go:244-252
var (
    ServerContextKey = &contextKey{"http-server"}
    LocalAddrContextKey = &contextKey{"local-addr"}
)

type contextKey struct {
    name string
}

Why

  • Unexported key type prevents other packages from accessing or overwriting your values
  • Type-safe accessors (FromContext) avoid type assertions at every call site
  • Pointer-based keys (&contextKey{...}) guarantee uniqueness even with same string names

Anti-pattern

// DON'T: Use string keys (any package can collide)
ctx = context.WithValue(ctx, "user", user)

// DON'T: Use exported key types (anyone can access)
type Key string
const UserKey Key = "user"  // other packages can use this key

// DON'T: Store optional parameters in context
ctx = context.WithValue(ctx, "timeout", 5*time.Second)  // use function params!

Summary: Package Design Principles

Principle Rule
Package comment "Package X does Y." before package keyword
Naming Short, lowercase, no stutter (http.Server not http.HTTPServer)
Encapsulation internal/ for shared-but-private code
Exports Minimum viable surface; unexported by default
init() Only for registration; keep trivial
Constructors NewX()*T; prefer usable zero values
Organization One concern per package; no circular deps
API layers Separate user-facing from implementor-facing (SPI)
Context values Unexported key type + typed accessors
Configuration Struct literals (stdlib) or functional options (community)