Go Idioms
When to Use
Use this skill when:
- The user asks how to design a Go interface and is unsure how large or small it should be, whether to use embedding, or how to decide what belongs on an interface
- The user wants to implement the functional options pattern (also called "option functions" or "variadic options") for configuring a struct or constructor
- The user asks how to write idiomatic Go: proper receiver naming, zero-value usability, named return values, blank identifiers, or init functions
- The user wants to know when and how to use type embedding versus composition, and how promoted methods interact with the outer type
- The user is writing table-driven tests and wants to know the canonical Go structure, sub-test naming, and how to use
t.Run - The user asks about the standard library's conventions -- how
io.Reader/io.Writercompose, when to usebufio, howsync.Onceandsync.Poolare intended to be used - The user is reviewing Go code for idiomatic quality, naming conventions (
CamelCasevsmixedCaps), package naming, or comment style pergodoc - The user is converting code from another language (Python, Java, C#) into idiomatic Go and wants to shed OOP patterns like inheritance hierarchies or checked exceptions
Do NOT use this skill when:
- The user asks about Go project layout, module initialization,
go.modmanagement, or build toolchain setup -- usego-project-setupinstead - The user asks about goroutines, channels,
select,sync.WaitGroup,sync.Mutex, or concurrent data structures -- usego-concurrency-patternsinstead - The user asks about wrapping errors with
%w,errors.Is,errors.As, sentinel errors, or custom error types -- usego-error-handlinginstead - The user needs help with a specific Go framework (Gin, Echo, Chi) rather than core language idioms
- The user is asking about Go generics type constraints and parametric polymorphism at depth -- that is a separate specialization
Process
1. Identify the Primary Idiomatic Pattern Category
Before writing any code, classify what the user actually needs. Go idioms fall into five categories -- choose the one that most directly addresses the question:
- Type design: zero-value readiness, struct field ordering (largest fields first to minimize padding), unexported fields with exported accessors only when mutation must be controlled
- Interface design: size (aim for 1--3 methods), placement in the consumer package not the producer package, implicit satisfaction
- Constructor and configuration: functional options vs. config structs vs.
New(required) + setters - Control flow and data transformation: table-driven logic, the
comma okidiom,for rangewith blank identifiers,deferfor cleanup - Standard library integration: choosing the right
ioabstraction, usingstrings.Buildernotbytes.Bufferfor string construction,encoding/jsontags andomitempty
Determine which category applies before generating any code. A question about "how do I make my Server configurable?" is a constructor/configuration question, not an interface question.
2. Apply Zero-Value and Struct Design Principles
Every Go struct should be usable in its zero state wherever possible. This is a load-bearing idiom in the standard library (sync.Mutex, bytes.Buffer, http.Client).
- A zero-value
sync.Mutexis unlocked and ready -- model your own types similarly - Use pointer fields sparingly; prefer value fields and initialize lazily inside methods with
sync.Oncewhen needed - Order struct fields by size to reduce alignment padding:
int64and*T(8 bytes) first, thenint32(4 bytes), thenint16, thenint8/boollast; this can reduce struct size by 30--50% for dense structs - Unexported fields enforce invariants; exported fields signal that callers can modify them freely -- most non-trivial structs should have unexported fields
- Avoid init functions that perform I/O, register globals, or have side effects; prefer explicit construction
Example zero-value ready pattern:
type RateLimiter struct {
mu sync.Mutex
count int64
limit int64
}
func (r *RateLimiter) Allow() bool {
r.mu.Lock()
defer r.mu.Unlock()
if r.limit == 0 {
return true // zero-value: unlimited
}
r.count++
return r.count <= r.limit
}
3. Design Interfaces at the Right Level
Go interfaces should be defined at the point of consumption, not at the point of definition. This is the single most misunderstood Go idiom coming from Java or C#.
- Define interfaces in the package that uses the behavior, not the package that implements it -- this breaks circular imports and enables decoupled testing
- Keep interfaces small:
io.Readerhas 1 method,io.ReadWriterhas 2,http.Handlerhas 1 -- these are the canonical examples - The 1-method interface is the workhorse of Go; name it after the method with an
-ersuffix:Reader,Writer,Closer,Stringer,Handler - A 3-method interface is the practical maximum before you should question whether it is a coherent abstraction
- Never pre-emptively create interfaces "for extensibility" -- let the consumer define them when a real second implementation exists
- Embed standard library interfaces to compose capability:
type ReadSeekCloser interface {
io.ReadSeeker
io.Closer
}
- Accept interfaces, return concrete types -- the canonical Go API shape; callers can always wrap a concrete type in an interface themselves
Decision rule: If there is only one implementation and no test double needed, do not create an interface. Add it when the second real use case arrives.
4. Implement Functional Options for Configurable Constructors
When a constructor has more than 2--3 optional parameters, or when you anticipate adding parameters over the life of a package, use the functional options pattern. This is the modern Go idiom, preferred over config structs for library code.
The canonical structure:
// server.go
type Server struct {
host string
port int
timeout time.Duration
maxConn int
}
type Option func(*Server)
func WithHost(h string) Option {
return func(s *Server) { s.host = h }
}
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 WithMaxConnections(n int) Option {
return func(s *Server) { s.maxConn = n }
}
func NewServer(opts ...Option) *Server {
s := &Server{
host: "localhost", // sane defaults
port: 8080,
timeout: 30 * time.Second,
maxConn: 100,
}
for _, opt := range opts {
opt(s)
}
return s
}
Key rules for functional options:
- Always set production-ready defaults inside
New*, before applying options - Return
Option(the function type), not apply the function directly -- this allows options to be stored, composed, and conditionally applied - Validate after applying options, not inside each option function -- keep option functions simple
- For library packages: export the Option type; for internal packages: unexported
optiontype is fine - Config struct pattern is acceptable when all fields are required or when the struct is also serialized (e.g., from a YAML config file) -- functional options are not JSON-serializable
When to prefer a config struct over functional options:
- The struct is loaded from a file or environment (use
envconfigorviper) - All fields are required and there are no sensible defaults
- The team is unfamiliar with first-class functions
5. Write Table-Driven Tests Using t.Run
Table-driven tests are the canonical Go testing idiom. Every Go developer should be able to read and write them immediately.
func TestDivide(t *testing.T) {
t.Parallel()
tests := []struct {
name string
numerator float64
denominator float64
want float64
wantErr bool
}{
{name: "positive integers", numerator: 10, denominator: 2, want: 5},
{name: "negative denominator", numerator: 10, denominator: -2, want: -5},
{name: "divide by zero", numerator: 10, denominator: 0, wantErr: true},
{name: "fractional result", numerator: 7, denominator: 2, want: 3.5},
}
for _, tt := range tests {
tt := tt // capture range variable (required pre-Go 1.22)
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := Divide(tt.numerator, tt.denominator)
if (err != nil) != tt.wantErr {
t.Fatalf("Divide() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && got != tt.want {
t.Errorf("Divide() = %v, want %v", got, tt.want)
}
})
}
}
Key conventions:
- Use
ttas the loop variable name -- this is community-wide convention - Add
tt := ttinside the loop when usingt.Parallel()in sub-tests (not needed in Go 1.22+, where the loop variable is scoped per iteration) - Name test cases with human-readable lowercase descriptions, not
"test1","case2" - Use
t.Fatalfwhen the test cannot proceed (nil pointer risk),t.Errorffor independent checks - Add
t.Helper()inside any helper function that callst.Error/t.Fatalso line numbers point to the call site - Keep
wantfor expected values andgotfor actual values -- these names are conventional throughout the Go standard library and test packages
6. Use the Standard Library as the First Dependency
Go's standard library is extraordinarily complete. Reaching for third-party packages before exhausting standard library options is an anti-pattern.
Key standard library patterns and their correct usage:
strings and bytes:
- Use
strings.Builderfor building strings in a loop -- it avoids allocation on each concatenation unlike+in a loop - Use
bytes.Bufferwhen you need aio.Writerandio.Readeron the same buffer -- butstrings.Builderonly implementsio.Writer strings.Splitreturns[]string{""}(a slice with one empty string) for an empty input -- checklen(parts) == 1 && parts[0] == ""before iterating- Prefer
strings.ContainsRuneoverstrings.Containswhen checking for a single character
io package composition:
io.Copy(dst, src)is the idiomatic way to move data betweenio.Readerandio.Writer-- it uses an internal 32KB bufferio.LimitReader(r, n)wraps any reader to read at mostnbytes -- essential for untrusted inputio.MultiWriter(w1, w2)fans out a write to multiple writers simultaneouslyio.TeeReader(r, w)reads fromrwhile writing tow-- useful for logging or hashing alongside readingio.Pipe()creates a synchronous in-memory pipe connecting anio.Writerto anio.Reader
bufio:
- Always wrap a
net.Connor file inbufio.NewReader/bufio.NewWriterbefore performing many small reads/writes -- raw syscall overhead is significant - Default buffer size is 4096 bytes; use
bufio.NewReaderSize(r, 65536)for large streaming reads bufio.Scanneris the correct idiom for reading line-by-line; the default split function isScanLines
encoding/json:
- Use
json:"fieldname,omitempty"for optional fields --omitemptyomits zero values (0, false, nil, "", empty slice) - Implement
json.Marshalerandjson.Unmarshalerinterfaces for types that need custom serialization json.Decoderis preferred overjson.Unmarshalwhen reading from a stream (http.Request.Body) -- it does not read the entire body into memory first- Never use
interface{}(orany) as a decode target when you know the schema -- use a typed struct
time:
- Always store and compare
time.Timevalues in UTC:t.UTC() - Use
time.Since(start)instead oftime.Now().Sub(start)for elapsed time - Never use
time.Sleepin production code that needs to be testable -- inject a clock or use a ticker
7. Apply Naming and Comment Conventions Rigorously
Go naming is load-bearing -- the compiler and tooling rely on capitalization for visibility, and godoc generates documentation directly from comments.
- Package names: single lowercase word, no underscores, no
util, nocommon, nohelpers-- name packages by what they provide, not what they contain - Exported names: always
CamelCase, nevercamelCasefor exported symbols;IDnotId,URLnotUrl,HTTPnotHttp-- acronyms are all caps - Receiver names: short, 1--2 characters, consistent throughout the type, typically the first letter of the type:
func (s *Server) Start()notfunc (server *Server) Start() - Getter methods: no
Getprefix in Go --obj.Name()notobj.GetName(); setter methods may useSetName()but prefer functional options for struct construction - Boolean variable names:
isActive,hasError,ok-- theokpattern is idiomatic for the two-returnvalue, okidiom - Comment style: exported symbols must have a comment starting with the symbol name:
// Server handles incoming HTTP connections.--golintandstaticcheckwill flag missing doc comments - Error variable names: unexported sentinel errors use
errNotFound; exported sentinel errors useErrNotFoundfollowing theio.EOFconvention
8. Evaluate and Apply Type Embedding Correctly
Embedding is Go's primary mechanism for code reuse. It is not inheritance -- promoted methods do not create an "is-a" relationship. Use it to express a "has-a plus delegation" relationship.
Decision criteria for embedding:
- Embed when the outer type genuinely wants to expose all the methods of the inner type as its own API
- Compose with named field when you want the inner type's functionality but do not want to expose all its methods
- Embed interfaces in interfaces to compose capabilities (this is idiomatic and encouraged)
- Embed interfaces in structs with caution -- this enables partial implementation of an interface (useful for test doubles) but causes panics at runtime if the embedded interface field is nil and an unimplemented method is called
// Embedding for method promotion -- all io.ReadWriter methods become Logger methods
type Logger struct {
io.ReadWriter // promoted: Read and Write are Logger's methods
prefix string
}
// Named field composition -- Client has an http.Client but does not expose its methods
type Client struct {
http *http.Client // unexported -- callers cannot reach http.Client directly
apiKey string
}
// Interface embedding in structs for test doubles
type PartialFS struct {
fs.FS // embed the interface -- only implement what the test needs
}
func (p PartialFS) Open(name string) (fs.File, error) {
// only Open is implemented; Glob, ReadDir, etc. would panic if called
return os.Open(name)
}
Embedding traps to avoid:
- The outer type does not satisfy an interface "through" embedding if it shadows any embedded method with its own method of the same name -- the shadowing method wins
- Two embedded types with identically named methods cause a compile error at the call site, not at the embedding declaration
- Embedding
*sync.Mutex(pointer) instead ofsync.Mutex(value) means the mutex can be nil at zero value -- prefer value embedding
Output Format
When answering a Go idioms question, structure the response as follows:
## Pattern: [Pattern Name]
### When This Pattern Applies
[1-3 sentences identifying the specific situation]
### The Idiomatic Go Approach
[Code block with complete, compilable Go code]
### Why This Is Idiomatic
- [Bullet 1: connection to Go spec, standard library, or community convention]
- [Bullet 2: what it enables (testability, composability, extensibility)]
- [Bullet 3: what alternatives exist and why this is preferred]
### Common Mistakes
| Mistake | Problem | Fix |
|---------|---------|-----|
| [Anti-pattern 1] | [Why it's wrong] | [Correct approach] |
| [Anti-pattern 2] | [Why it's wrong] | [Correct approach] |
### Trade-offs
| Factor | This Pattern | Alternative |
|--------|-------------|-------------|
| Readability | [assessment] | [assessment] |
| Performance | [assessment] | [assessment] |
| Testability | [assessment] | [assessment] |
| Binary size | [assessment] | [assessment] |
### Minimal Working Example
[Complete, runnable Go code with package declaration, imports, and main or test function]
For multi-pattern questions, repeat the block above for each pattern and add a summary decision matrix at the end.
Rules
Never define an interface in the same package that provides the only implementation. The producer package returns a concrete type. The consumer package defines the interface it needs. This is how
net/http,io, anddatabase/sqlall work -- breaking this rule creates circular import risks and tight coupling.Never use a pointer receiver on a type that has a zero-value usable state unless the method must mutate state. Use value receivers for read-only methods on small structs (under ~64 bytes). Use pointer receivers consistently if any method requires mutation -- mixing receiver types on one type causes confusion and makes the type fail to satisfy interface expectations.
Never write a functional option that panics or returns an error. Option functions must be pure value-setting operations. Validation belongs in the constructor after options are applied. If an option depends on a value from another option, reorder or combine them.
Always use
t.Runfor sub-tests in table-driven tests, never index into a shared results slice. Thet.Runapproach enables individual test filtering (go test -run TestDivide/divide_by_zero), parallel execution, and clear failure attribution. The old pattern of indexing results provides none of these.Never use
interface{}(orany) as a function parameter type when the set of accepted types is known and finite. Define a typed interface or use a concrete union approach. Untypedanyparameters lose compile-time type safety and force callers into type assertions that can panic.Always prefer
io.Readerandio.Writeras parameter types over*os.File,*bytes.Buffer, or concrete stream types. Functions that acceptio.Readercan be tested withstrings.NewReader, can read from network connections, files, or compressed streams without any change to the function signature. This is Go's most powerful composability idiom.Never shadow the
errvariable from an outer scope using:=in a nested block. The shadowederrinside the block is a different variable. The outererrremains unchanged. This is a common source of silent failures where an error is set but the outerif err != nilcheck never sees it.Package names must never be
util,common,helpers,misc, orshared. These names communicate nothing about what the package provides. Name packages by their domain noun:currency,validation,pagination,middleware. The name appears before every exported symbol at the call site --validation.IsEmail(s)is clearer thanutil.IsEmail(s).Never add an abstraction layer (interface, wrapper type, adapter) without a second concrete use case in hand. The Go philosophy is "a little copying is better than a little dependency." Premature abstraction in Go creates interface sprawl, harder-to-follow call stacks, and no actual benefit until the second implementation exists.
Always write
defercleanup statements immediately after the resource acquisition that requires cleanup.f, err := os.Open(name); if err != nil { return err }; defer f.Close()-- thedeferappears on the very next line. Separating thedeferfrom the acquisition by more than a few lines makes it easy to miss, omit during refactoring, or accidentally defer inside a loop (which delays all closes until function return).
Edge Cases
Embedding an Interface to Create a Test Double (Partial Mock)
When a large interface (like fs.FS with Open, ReadDir, Glob, Stat) needs a test double but only one or two methods are exercised in a given test, embed the interface in a struct and implement only what the test calls. This compiles and works correctly -- but any call to an unimplemented method dereferences a nil interface and panics. Document this explicitly:
// testFS only implements Open; calling any other fs.FS method will panic.
type testFS struct{ fs.FS }
func (t testFS) Open(name string) (fs.File, error) {
return os.Open(filepath.Join("testdata", name))
}
Guard against accidents by wrapping test doubles in a method that documents the limitation. In production code where all methods may be called, this pattern is dangerous -- use a complete implementation or gomock/mockery.
Functional Options Validation Ordering
When one option depends on another (e.g., WithTLSConfig depends on WithCert and WithKey having been applied), validation inside options is tempting but wrong. Instead, apply all options first, then validate the combined state:
func NewServer(opts ...Option) (*Server, error) {
s := &Server{port: 8080, timeout: 30 * time.Second}
for _, opt := range opts {
opt(s)
}
// validate after all options applied
if s.tlsEnabled && (s.certFile == "" || s.keyFile == "") {
return nil, errors.New("server: TLS enabled but cert or key file not specified")
}
return s, nil
}
Note the constructor now returns (*Server, error) -- this is acceptable and idiomatic when initialization can fail. The functional options themselves never return errors.
Promoted Method Conflicts with Outer Type Intent
When embedding two types that both have a method named Close(), the Go compiler does not error at the embedding site -- it errors only when you call outer.Close(). The fix is to disambiguate by calling outer.Inner1.Close() or outer.Inner2.Close() explicitly, or by defining Close() on the outer type that orchestrates both:
type ReadWriteCloser struct {
*bufio.Reader
*bufio.Writer
conn net.Conn
}
func (rwc *ReadWriteCloser) Close() error {
if err := rwc.Writer.Flush(); err != nil {
return err
}
return rwc.conn.Close()
}
Table-Driven Tests with Expected Side Effects
When the function under test modifies external state (a database, a file, a counter), the table test struct must include setup and teardown hooks, not just input/output:
tests := []struct {
name string
setup func(t *testing.T) *sql.DB
input string
want int
wantErr bool
}{
{
name: "inserts new record",
setup: func(t *testing.T) *sql.DB {
db := setupTestDB(t)
t.Cleanup(func() { db.Close() })
return db
},
input: "new-record",
want: 1,
},
}
Do not share state between table test rows -- each row must be fully isolated. Running with t.Parallel() inside t.Run amplifies any hidden shared state into data races.
Zero-Value Unusable Type (Correct Handling)
Some types genuinely cannot have a useful zero value (a Connection with no network address, a Signer with no private key). In these cases, make the zero value obviously broken -- either by checking in every method and returning an error, or by making the struct unexported and using a constructor that validates:
// unexported struct, only constructable via NewSigner
type signer struct {
key *ecdsa.PrivateKey
}
// Signer is the exported type alias or interface -- callers cannot create a zero signer
type Signer interface {
Sign(data []byte) ([]byte, error)
}
func NewSigner(key *ecdsa.PrivateKey) (Signer, error) {
if key == nil {
return nil, errors.New("signer: private key must not be nil")
}
return &signer{key: key}, nil
}
This is the correct response to the zero-value rule: when zero-value usability is genuinely impossible, prevent the zero value from existing in the caller's hands.
strings.Builder vs bytes.Buffer for Performance
In a hot path building strings from many small pieces, the choice matters:
strings.Builder: write-only, no read back,String()to get result -- zero copy (returns the internal slice as a string directly since Go 1.10)bytes.Buffer: read and write,String()allocates a copy of the internal buffer
For benchmark comparison: building a 10,000-element string with strings.Builder is ~35% faster than bytes.Buffer.String() because Buffer.String() calls string(b.buf[b.off:]) which allocates. Use strings.Builder for pure string construction. Use bytes.Buffer when you need the io.Reader interface to re-read what you wrote.
The init() Function Trap
init() functions run before main() and before the package's exported functions are called. They are order-dependent, hard to test, and create hidden global state. The idiomatic Go solution:
- Never perform I/O in
init()-- reading config files, connecting to databases, or calling network services ininit()makes the package impossible to import in tests without side effects - Never register global state in
init()(e.g.,http.Handle(...)) in library packages -- only binaries should haveinit()with side effects - The one acceptable use of
init(): registering with a type-safe registry pattern (e.g.,sql.Registerin database drivers) where the registration is idempotent and pure
If existing code uses init() for configuration, migrate to explicit Setup() or New*() functions that callers invoke intentionally.
Example
Input: "I want to build a configurable HTTP client wrapper for my team's internal API SDK. It needs to support custom timeouts, base URLs, authentication headers, retry counts, and optional request logging. How do I design this idiomatically in Go?"
Output:
Pattern: Functional Options + Interface Boundaries for an HTTP Client SDK
When This Pattern Applies
You have a struct (Client) with several optional configuration parameters, some of which have sensible defaults, and you want the public API to remain stable as you add options over time. You also want to inject behavior (logging) without hardwiring a specific logger.
The Idiomatic Go Approach
package apiclient
import (
"context"
"fmt"
"io"
"log"
"net/http"
"time"
)
// Logger is defined in this consumer package -- not in a logger package.
// Any type with a Printf method satisfies it (including *log.Logger).
type Logger interface {
Printf(format string, v ...any)
}
// Client is the exported concrete type. Callers get *Client, not an interface.
type Client struct {
baseURL string
httpClient *http.Client
authHeader string
maxRetries int
logger Logger
}
// Option is the functional option type.
type Option func(*Client)
// WithBaseURL sets the API base URL. Required for most usage.
func WithBaseURL(url string) Option {
return func(c *Client) { c.baseURL = url }
}
// WithTimeout overrides the default 30-second HTTP timeout.
func WithTimeout(d time.Duration) Option {
return func(c *Client) { c.httpClient.Timeout = d }
}
// WithAuthHeader sets a static Authorization header value.
// Example: WithAuthHeader("Bearer " + token)
func WithAuthHeader(header string) Option {
return func(c *Client) { c.authHeader = header }
}
// WithMaxRetries sets the number of retry attempts on 5xx responses.
// Default is 0 (no retries). Maximum recommended is 3.
func WithMaxRetries(n int) Option {
return func(c *Client) { c.maxRetries = n }
}
// WithLogger injects a logger. Pass nil to disable logging explicitly.
// Accepts *log.Logger or any type implementing Logger.
func WithLogger(l Logger) Option {
return func(c *Client) { c.logger = l }
}
// New constructs a Client with production-ready defaults.
// Validation errors are returned, not panicked.
func New(opts ...Option) (*Client, error) {
c := &Client{
baseURL: "",
httpClient: &http.Client{Timeout: 30 * time.Second},
maxRetries: 0,
logger: log.Default(), // default: stdlib logger
}
for _, opt := range opts {
opt(c)
}
// validate after all options applied
if c.baseURL == "" {
return nil, fmt.Errorf("apiclient: base URL is required; use WithBaseURL")
}
if c.maxRetries < 0 || c.maxRetries > 5 {
return nil, fmt.Errorf("apiclient: maxRetries must be 0-5, got %d", c.maxRetries)
}
return c, nil
}
// Get performs a GET request against the given path (appended to base URL).
// It retries on 5xx up to maxRetries times.
func (c *Client) Get(ctx context.Context, path string) (*http.Response, error) {
url := c.baseURL + path
var resp *http.Response
var err error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 && c.logger != nil {
c.logger.Printf("apiclient: retrying %s (attempt %d/%d)", url, attempt, c.maxRetries)
}
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if reqErr != nil {
return nil, fmt.Errorf("apiclient: build request: %w", reqErr)
}
if c.authHeader != "" {
req.Header.Set("Authorization", c.authHeader)
}
resp, err = c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("apiclient: execute request: %w", err)
}
if resp.StatusCode < 500 {
break // success or non-retryable error
}
io.Copy(io.Discard, resp.Body) // drain body before retry
resp.Body.Close()
}
return resp, nil
}
Table-Driven Tests for the Client
package apiclient_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestClient_Get(t *testing.T) {
t.Parallel()
tests := []struct {
name string
serverStatus int
maxRetries int
wantStatus int
wantCallCount int
wantErr bool
}{
{
name: "success on first attempt",
serverStatus: 200,
maxRetries: 2,
wantStatus: 200,
wantCallCount: 1,
},
{
name: "retries on 503 and succeeds after server stabilizes",
serverStatus: 503,
maxRetries: 3,
wantStatus: 503,
wantCallCount: 4, // 1 initial + 3 retries
},
{
name: "no retries on 404",
serverStatus: 404,
maxRetries: 2,
wantStatus: 404,
wantCallCount: 1,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.WriteHeader(tt.serverStatus)
}))
defer srv.Close()
client, err := New(
WithBaseURL(srv.URL),
WithTimeout(5*time.Second),
WithMaxRetries(tt.maxRetries),
WithLogger(nil), // disable logging in tests
)
if err != nil {
t.Fatalf("New() unexpected error: %v", err)
}
resp, err := client.Get(context.Background(), "/test")
if (err != nil) != tt.wantErr {
t.Fatalf("Get() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr {
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("Get() status = %d, want %d", resp.StatusCode, tt.wantStatus)
}
if callCount != tt.wantCallCount {
t.Errorf("server called %d times, want %d", callCount, tt.wantCallCount)
}
}
})
}
}
Why This Is Idiomatic
Loggerinterface is defined inapiclient(the consumer), not in a logger package -- callers inject*log.Logger,*zap.SugaredLogger, or a test double without any import of this package's logger typeNewreturns(*Client, error)-- validation failures surface at construction time, not as panics insideGetWithLogger(nil)is a valid explicit choice to disable logging -- functional options handle this gracefully without a separateWithoutLogger()optionhttp.NewRequestWithContextis used instead ofhttp.NewRequest-- passingcontext.Contextenables timeout propagation and cancellation from the caller, which is mandatory for production HTTP clientsio.Copy(io.Discard, resp.Body)beforeresp.Body.Close()on retry ensures the underlying TCP connection is returned to the pool, not abandoned -- this is a performance-critical idiom that prevents connection exhaustion
Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
Defining Client interface in this package |
Creates circular import risk; callers cannot extend without modifying this package | Let consumers define the interface they need |
| Panic inside an Option function for invalid values | Panics in options are unrecoverable at the call site | Return (*Client, error) from New; validate there |
Not draining resp.Body before Close() on retry |
The underlying TCP connection is not reused; connection pool is exhausted under load | io.Copy(io.Discard, resp.Body) before resp.Body.Close() |
Using http.NewRequest without a context |
No timeout or cancellation propagation | Always use http.NewRequestWithContext(ctx, ...) |
Embedding *http.Client in Client |
Exposes all *http.Client methods on Client; callers can bypass retry and auth logic |
Use an unexported httpClient *http.Client field |
Trade-offs
| Factor | Functional Options | Config Struct |
|---|---|---|
| API stability | Excellent -- add new With* funcs without breaking callers |
Good -- add new fields with zero values |
| JSON/YAML loadable | No -- functions are not serializable | Yes -- struct tags work directly |
| Discoverability | Requires reading docs or godoc | IDE autocomplete shows all fields |
| Validation location | Centralized in New() |
Must be in New() or Validate() -- same result |
| Composability | Options can be stored in []Option slices and reused |
Config structs can be embedded or merged |
For a library SDK distributed to external teams, functional options are preferred because the API surface is stable across minor versions. For an internal service configuration loaded from environment variables, a config struct with envconfig tags is more practical.