feat: initial Go patterns guide from stdlib + Kubernetes source study

9 pattern files covering stdlib (structs, interfaces, API conventions, docs, style),
Kubernetes (controller/reconciler, informer/cache, leader election, code generation),
comparison (stdlib vs K8s approaches), and anti-patterns.

All patterns cite exact source files and line numbers.
This commit is contained in:
Rodin
2026-04-30 06:34:02 +00:00
commit 0f1d7e4c06
9 changed files with 3335 additions and 0 deletions
+404
View File
@@ -0,0 +1,404 @@
# Struct Design Patterns in the Go Standard Library
## 1. Zero-Value Usability
**Pattern name:** Zero Value Ready
**Source citation:** `net/http/client.go` lines 3135, `strings/builder.go` lines 1416
**What it does:** Structs are designed so their zero value is immediately useful without
explicit initialization. Nil fields fall back to sensible defaults at method call time.
**Why:** Eliminates mandatory constructors, reduces boilerplate, makes the type
self-documenting about its defaults. Users can write `var c http.Client` and start
making requests.
**Anti-pattern:** Requiring a constructor for basic use; panicking on zero-value use;
requiring all fields be set before the type is functional.
**Code examples from source:**
```go
// net/http/client.go:31-35
// A Client is an HTTP client. Its zero value ([DefaultClient]) is a
// usable client that uses [DefaultTransport].
type Client struct {
Transport RoundTripper // If nil, DefaultTransport is used.
// ...
}
// net/http/client.go:109
var DefaultClient = &Client{}
```
```go
// strings/builder.go:14-16
// A Builder is used to efficiently build a string using [Builder.Write] methods.
// It minimizes memory copying. The zero value is ready to use.
// Do not copy a non-zero Builder.
type Builder struct {
addr *Builder
buf []byte
}
```
```go
// bytes/buffer.go:19-20
// A Buffer is a variable-sized buffer of bytes with [Buffer.Read] and [Buffer.Write] methods.
// The zero value for Buffer is an empty buffer ready to use.
type Buffer struct {
buf []byte
off int
lastRead readOp
}
```
---
## 2. Unexported Struct with Exported Wrapper
**Pattern name:** Indirection via Unexported Impl
**Source citation:** `os/types.go` lines 1620, `os/file_unix.go` lines 5971
**What it does:** The exported type (`File`) embeds a pointer to an unexported type
(`*file`) that holds the real implementation state. Users interact only with the
exported wrapper.
**Why:** Prevents users from directly constructing or copying the implementation struct.
Allows platform-specific implementations behind a uniform exported API. The extra
indirection ensures finalizers close the correct descriptor.
**Anti-pattern:** Exporting all implementation fields; allowing users to construct
the struct via a literal (bypassing invariants); needing platform #ifdefs in the
public API.
**Code example from source:**
```go
// os/types.go:16-20
// File represents an open file descriptor.
//
// The methods of File are safe for concurrent use.
type File struct {
*file // os specific
}
// os/file_unix.go:59-71
// file is the real representation of *File.
// The extra level of indirection ensures that no clients of os
// can overwrite this data, which could cause the finalizer
// to close the wrong file descriptor.
type file struct {
pfd poll.FD
name string
dirinfo atomic.Pointer[dirInfo]
nonblock bool
stdoutOrErr bool
appendMode bool
inRoot bool
}
```
---
## 3. Constructor Functions (NewXxx)
**Pattern name:** NewXxx Constructor
**Source citation:** `bufio/scan.go` lines 8996, `bufio/bufio.go` lines 5060
**What it does:** A package-level function `NewXxx(deps) *Xxx` constructs the type
with required dependencies and internal defaults that can't be expressed via zero
value alone.
**Why:** When a type has mandatory dependencies (e.g., an `io.Reader`), a constructor
clearly communicates what's required. The constructor can set internal invariants
(buffer sizes, split functions) that users shouldn't need to know about.
**Anti-pattern:** Forcing users to manually set unexported fields; having a constructor
that takes 10 optional parameters (use config struct instead); requiring New when
zero value would suffice.
**Code examples from source:**
```go
// bufio/scan.go:89-96
func NewScanner(r io.Reader) *Scanner {
return &Scanner{
r: r,
split: ScanLines,
maxTokenSize: MaxScanTokenSize,
}
}
```
```go
// bufio/bufio.go:50-62
func NewReaderSize(rd io.Reader, size int) *Reader {
// Is it already a Reader?
b, ok := rd.(*Reader)
if ok && len(b.buf) >= size {
return b
}
r := new(Reader)
r.reset(make([]byte, max(size, minReadBufferSize)), rd)
return r
}
// NewReader returns a new [Reader] whose buffer has the default size.
func NewReader(rd io.Reader) *Reader {
return NewReaderSize(rd, defaultBufSize)
}
```
```go
// net/http/request.go:867-869
func NewRequest(method, url string, body io.Reader) (*Request, error) {
return NewRequestWithContext(context.Background(), method, url, body)
}
```
---
## 4. NewXxx with Size/Options Variant
**Pattern name:** NewXxx / NewXxxSize Pair
**Source citation:** `bufio/bufio.go` lines 50, 62, 589, 607
**What it does:** Provides two constructors — one with defaults (`NewReader`) and one
with explicit configuration (`NewReaderSize`). The default version calls the
configurable one.
**Why:** Most users want the default; power users need control. Layering avoids a
proliferation of constructor parameters for the common case.
**Anti-pattern:** Having only the complex constructor; making users guess the right
buffer size; inconsistent naming (e.g., `NewReaderWithSize`).
**Code example from source:**
```go
// bufio/bufio.go:589-607
func NewWriterSize(w io.Writer, size int) *Writer {
// ...
}
func NewWriter(w io.Writer) *Writer {
return NewWriterSize(w, defaultBufSize)
}
```
---
## 5. Config Struct Pattern
**Pattern name:** Configuration Struct (Exported Fields, Nil-Means-Default)
**Source citation:** `net/http/server.go` lines 30203120, `crypto/tls/common.go` lines 566+, `log/slog/handler.go` lines 135175
**What it does:** A struct with exported, documented fields provides all
configuration knobs. Nil/zero values always mean "use the default".
**Why:** Self-documenting via godoc; no need for a setter method per option; easy to
construct partially; serializable; the zero value works. This is Go's primary
configuration pattern (preferred over functional options in the stdlib).
**Anti-pattern:** Undocumented fields; requiring all fields set; using sentinel values
other than zero/nil for defaults; providing setters when direct assignment works.
**Code example from source:**
```go
// net/http/server.go:3020-3075 (abbreviated)
type Server struct {
Addr string // ":http" if empty
Handler Handler // http.DefaultServeMux if nil
TLSConfig *tls.Config // optional
ReadTimeout time.Duration // zero means no timeout
WriteTimeout time.Duration // zero means no timeout
MaxHeaderBytes int // DefaultMaxHeaderBytes if zero
ErrorLog *log.Logger // log.Default() if nil
// ...
}
```
```go
// log/slog/handler.go:135-175
type HandlerOptions struct {
AddSource bool
Level Leveler // LevelInfo if nil
ReplaceAttr func(groups []string, a Attr) Attr
}
// Usage: If opts is nil, the default options are used.
func NewTextHandler(w io.Writer, opts *HandlerOptions) *TextHandler {
if opts == nil {
opts = &HandlerOptions{}
}
// ...
}
```
---
## 6. Interface-Based Pluggability
**Pattern name:** Interface Abstraction for Pluggable Implementations
**Source citation:** `crypto/crypto.go` lines 180200, `net/http/transport.go` lines 6682
**What it does:** Core behavior is defined via an interface. The package provides
a default concrete implementation, but any user type satisfying the interface
can be substituted.
**Why:** Decouples high-level logic from low-level implementation. Enables testing
(mock transports), hardware integration (HSM-backed signers), and third-party
extensions without forking the package.
**Anti-pattern:** Concrete-type coupling everywhere; interfaces with too many methods
(hard to implement); accepting an interface but only ever using one implementation.
**Code example from source:**
```go
// crypto/crypto.go:180-200
// Signer is an interface for an opaque private key that can be used for
// signing operations. For example, an RSA key kept in a hardware module.
type Signer interface {
Public() PublicKey
Sign(rand io.Reader, digest []byte, opts SignerOpts) (signature []byte, err error)
}
```
```go
// net/http/transport.go (line 66+)
// Transport is an implementation of [RoundTripper] that supports HTTP,
// HTTPS, and HTTP proxies...
// Transports should be reused instead of created as needed.
// Transports are safe for concurrent use by multiple goroutines.
// net/http/client.go:59-60
type Client struct {
Transport RoundTripper // If nil, DefaultTransport is used.
// ...
}
```
---
## 7. Copy Protection via Dynamic Check
**Pattern name:** copyCheck (Runtime Copy Detection)
**Source citation:** `strings/builder.go` lines 2540
**What it does:** On first mutation, the Builder records its own address. Subsequent
mutations compare the current receiver address against the recorded one. If they
differ, the struct was copied — it panics.
**Why:** Go has no language-level move semantics. For types where copying after first
use would cause data corruption or unsafe behavior (e.g., sharing an unsafe string
buffer), a runtime check is the pragmatic solution.
**Anti-pattern:** Silently allowing copies that corrupt state; using `sync.Mutex`-style
`noCopy` (vet catches it but it doesn't work for zero vs non-zero discrimination).
**Code example from source:**
```go
// strings/builder.go:25-40
func (b *Builder) copyCheck() {
if b.addr == nil {
b.addr = (*Builder)(abi.NoEscape(unsafe.Pointer(b)))
} else if b.addr != b {
panic("strings: illegal use of non-zero Builder copied by value")
}
}
```
---
## 8. DefaultXxx Singleton
**Pattern name:** Package-Level Default Instance
**Source citation:** `net/http/client.go` line 109, `net/http/transport.go` lines 4758
**What it does:** The package provides a pre-configured, ready-to-use instance as
a package-level variable. Package-level convenience functions delegate to it.
**Why:** Makes the simple case trivial (`http.Get(url)`) while allowing custom
instances for advanced use. Users never need to touch the defaults unless they
have specific requirements.
**Anti-pattern:** Forcing construction for basic use; not providing convenience
functions; making the default mutable in ways that affect all users.
**Code example from source:**
```go
// net/http/client.go:108-109
// DefaultClient is the default [Client] and is used by [Get], [Head], and [Post].
var DefaultClient = &Client{}
// net/http/transport.go:47-58
var DefaultTransport RoundTripper = &Transport{
Proxy: ProxyFromEnvironment,
DialContext: defaultTransportDialContext(&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}),
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
```
---
## 9. Functional Configuration via Method Chaining (Scanner Pattern)
**Pattern name:** Post-Construction Configuration via Methods
**Source citation:** `bufio/scan.go` lines 275293
**What it does:** After construction with `NewScanner`, optional configuration is
applied via methods (`Split`, `Buffer`) before the first call to `Scan`.
**Why:** Keeps the constructor minimal (only the required `io.Reader`). Optional
configuration is discoverable via methods. Panics if called after scanning starts
(enforcing a construction → configure → use lifecycle).
**Anti-pattern:** Trying to pass all options into the constructor; allowing
configuration changes mid-use that corrupt state.
**Code example from source:**
```go
// bufio/scan.go:275-293
// Buffer sets the initial buffer to use when scanning
// and the maximum size of buffer that may be allocated during scanning.
// ...
// Buffer panics if it is called after scanning has started.
func (s *Scanner) Buffer(buf []byte, max int) {
if s.scanCalled {
panic("Buffer called after Scan")
}
s.buf = buf
s.maxTokenSize = max
}
// Split sets the split function for the [Scanner].
// ...
// Split panics if it is called after scanning has started.
func (s *Scanner) Split(split SplitFunc) {
if s.scanCalled {
panic("Split called after Scan")
}
s.split = split
}
```