Pointer Receivers — Professional Level¶
Table of Contents¶
- Introduction
- Library Design
- Public API Stability
- Common Conventions
- Linter Rules
- Documentation Standards
- Domain Modeling
- Migration Strategies
- Real-World Production Patterns
- Cheat Sheet
Introduction¶
At the professional level, choosing and using pointer receivers is closely tied to: - Library API stability - Team conventions - Linter and review rules - Domain modeling (DDD) - Production-grade patterns
Library Design¶
Choosing a receiver — public API¶
In public methods, the receiver choice is a permanent commitment. Changing it is breaking.
// v1
func (c Client) Do(req *Request) (*Response, error) // value
// v2 (BREAKING)
func (c *Client) Do(req *Request) (*Response, error) // pointer
Reasons it's breaking: 1. The method set changes — interface satisfaction breaks 2. If the caller was using a Client{} value — now they need &Client{} 3. Compilation error or subtle bug
Standards¶
| Type characteristic | Receiver |
|---|---|
| Resource holder (DB, File, Conn) | Pointer |
| State accumulator (Buffer, Builder) | Pointer |
| Sync primitive (Counter, Cache) | Pointer |
| Value object (Money, Color, ID) | Value |
| Configuration | Value or Pointer (value if immutable) |
| Service (with DI) | Pointer |
Reference: Go standard library¶
*http.Client— pointer (state, config)*sql.DB— pointer (resource)bytes.Buffer— pointer (writable)strings.Builder— pointer (writable, andnoCopy)time.Time— value (immutable)time.Duration— value (alias)*template.Template— pointer (parsed templates)
Public API Stability¶
Catalog of breaking changes¶
| Change | Breaking? |
|---|---|
| Adding a new method (T) | Non-breaking |
| Adding a new method (*T) | Non-breaking |
| Removing a method | BREAKING |
| Receiver value → pointer | BREAKING (method set, caller usage) |
| Receiver pointer → value | BREAKING (method set) |
| Adding an argument | BREAKING |
| Adding variadic (to existing arg) | BREAKING |
| Adding a return value | BREAKING |
| Renaming a method | BREAKING |
Adding a new method — choosing the receiver¶
When adding a new method, choose the receiver that fits the type. But follow the style of existing methods:
// Existing
type Client struct{}
func (c *Client) Do(...) {}
func (c *Client) Close() {}
// New method — pointer (for consistency)
func (c *Client) Stats() Stats { ... }
Constructor convention¶
This is the standard convention — the Go community expects it. The constructor encourages the caller to responsibly hold a pointer.
Common Conventions¶
Convention 1: New* constructor¶
Convention 2: noCopy marker¶
To restrict copying of a type:
type noCopy struct{}
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
type SafeCounter struct {
_ noCopy
mu sync.Mutex
n int
}
go vet finds the _ noCopy field and warns about copy operations.
Convention 3: Receiver name — abbreviated type¶
type DatabasePool struct{}
func (p *DatabasePool) Acquire() {} // p — Pool
type HTTPClient struct{}
func (c *HTTPClient) Do() {} // c — Client
Convention 4: Pointer-only types¶
Some types must work only via pointer — document it:
// Buffer is a write-once accumulator.
// Use NewBuffer to create one. Do not use the zero value.
// Buffer must not be copied after first use.
type Buffer struct {
_ noCopy
// ...
}
Convention 5: Method order — public first¶
type Server struct{ ... }
// Public methods first
func (s *Server) Start() error { ... }
func (s *Server) Stop() { ... }
func (s *Server) Stats() Stats { ... }
// Private helpers after
func (s *Server) handle(...) { ... }
func (s *Server) cleanup(...) { ... }
Linter Rules¶
go vet¶
- passes lock by value — mutex/sync.Cond/sync.WaitGroup value receiver
- method has pointer receiver — interface compatibility check
staticcheck¶
- SA9001 — defer in loop
- SA1015 —
time.Tickresource leak - ST1016 — Receiver name should be consistent
- ST1020 — Exported method without comment
revive¶
- var-naming — receiver naming
- unused-receiver — receiver not used
- receiver-naming — receiver name length
gocritic¶
- methodExprCall — incorrect use of method expression
- paramTypeCombine — group parameters of the same type
errcheck¶
- Ignoring an error returned by a method
Custom analyzer¶
For team standards:
// pkg/lint/receiver.go
func checkReceiverName(...) {
// Receiver name must be 1-2 chars
// Same type must use same receiver name
}
Documentation Standards¶
Public method comment¶
// Submit confirms the order and emits an OrderSubmitted event.
// Returns ErrEmptyOrder if no items have been added,
// or ErrInvalidState if the order is not in Draft state.
//
// Submit is the only valid transition from Draft to Submitted.
// Concurrency: not safe for concurrent use.
func (o *Order) Submit() error { ... }
Concurrency disclaimer¶
Document the safety of working with a pointer receiver:
// Cache provides thread-safe access to a key-value store.
// All methods are safe for concurrent use.
type Cache struct { ... }
// Buffer is NOT safe for concurrent use.
// Concurrent access must be synchronized externally.
type Buffer struct { ... }
Lifecycle disclaimer¶
// NewClient creates a new HTTP client.
//
// The caller must call Close() to release resources.
// Calling methods on a closed Client returns ErrClosed.
func NewClient(addr string) *Client { ... }
Domain Modeling¶
Aggregate root — pointer¶
type Order struct {
id OrderID
items []OrderItem
status OrderStatus
}
func (o *Order) AddItem(p Product, qty int) error { ... }
func (o *Order) Submit() error { ... }
Aggregate root — with state-changing methods, always pointer.
Value object — value¶
type Money struct { amount, scale int64 }
func (m Money) Add(o Money) Money { ... } // immutable, returns new
func (m Money) Format() string { ... }
A value object is immutable — pointer receiver isn't needed, it's not reused.
Domain service — pointer struct¶
type CheckoutService struct {
orders OrderRepository
payments PaymentGateway
}
func (s *CheckoutService) Execute(...) error { ... }
The service struct is passed via DI, internal mutability is allowed.
Repository — interface + pointer concrete¶
type OrderRepo interface {
Find(ctx context.Context, id OrderID) (*Order, error)
Save(ctx context.Context, o *Order) error
}
type pgOrderRepo struct{ db *sql.DB }
func (r *pgOrderRepo) Find(...) (*Order, error) { ... }
func (r *pgOrderRepo) Save(...) error { ... }
Migration Strategies¶
V1 → V2: Changing receiver type¶
This is breaking — requires a new major version.
// v1
type Counter struct{}
func (c Counter) Inc() Counter { ... } // immutable style
// v2 — breaking
type Counter struct{}
func (c *Counter) Inc() { ... } // mutable
Migration checklist¶
- Create a new type or new package
- Keep the old API around for a while
- Add a deprecation comment:
- Remove it in a major release
Soft migration: alongside¶
// v1.x
func (c Counter) Inc() Counter { ... }
// v1.5 — new method added (pointer)
// Deprecated: use IncP for in-place increment.
func (c Counter) Inc() Counter { ... }
func (c *Counter) IncP() { ... }
In V2, Inc() is removed.
Real-World Production Patterns¶
Pattern 1: Worker pool¶
type Pool struct {
in chan Job
quit chan struct{}
wg sync.WaitGroup
}
func NewPool(workers int) *Pool {
p := &Pool{
in: make(chan Job),
quit: make(chan struct{}),
}
for i := 0; i < workers; i++ {
p.wg.Add(1)
go p.worker()
}
return p
}
func (p *Pool) worker() {
defer p.wg.Done()
for {
select {
case j := <-p.in:
j.Process()
case <-p.quit:
return
}
}
}
func (p *Pool) Submit(j Job) { p.in <- j }
func (p *Pool) Stop() {
close(p.quit)
p.wg.Wait()
}
Pattern 2: Circuit breaker¶
type Breaker struct {
mu sync.Mutex
state state
failures int
threshold int
}
func NewBreaker(threshold int) *Breaker {
return &Breaker{state: closed, threshold: threshold}
}
func (b *Breaker) Call(fn func() error) error {
b.mu.Lock()
if b.state == open {
b.mu.Unlock()
return ErrCircuitOpen
}
b.mu.Unlock()
if err := fn(); err != nil {
b.recordFailure()
return err
}
b.recordSuccess()
return nil
}
func (b *Breaker) recordFailure() {
b.mu.Lock(); defer b.mu.Unlock()
b.failures++
if b.failures >= b.threshold { b.state = open }
}
func (b *Breaker) recordSuccess() {
b.mu.Lock(); defer b.mu.Unlock()
b.failures = 0
b.state = closed
}
Pattern 3: Graceful shutdown¶
type Server struct {
srv *http.Server
quit chan os.Signal
}
func NewServer(addr string) *Server {
s := &Server{
srv: &http.Server{Addr: addr},
quit: make(chan os.Signal, 1),
}
signal.Notify(s.quit, syscall.SIGINT, syscall.SIGTERM)
return s
}
func (s *Server) Start() error {
go func() {
<-s.quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s.srv.Shutdown(ctx)
}()
return s.srv.ListenAndServe()
}
Pattern 4: Observable state¶
type Counter struct {
n atomic.Int64
onChange []func(int64)
mu sync.RWMutex
}
func (c *Counter) Inc() {
n := c.n.Add(1)
c.mu.RLock()
callbacks := c.onChange
c.mu.RUnlock()
for _, cb := range callbacks { cb(n) }
}
func (c *Counter) Watch(cb func(int64)) {
c.mu.Lock(); defer c.mu.Unlock()
c.onChange = append(c.onChange, cb)
}
Cheat Sheet¶
LIBRARY DESIGN
────────────────────────────────
Resource holder → pointer (*DB, *Client)
State accumulator → pointer (*Buffer, *Builder)
Value object → value (Money, Color)
Service struct → pointer
Configuration → value (immutable) or pointer
CONVENTIONS
────────────────────────────────
NewX() *X — constructor returns pointer
noCopy marker — copy-protection
Receiver name — short and consistent
Method order — public first
PUBLIC API STABILITY
────────────────────────────────
Adding a method → non-breaking
Removing a method → BREAKING
Changing receiver type → BREAKING
Changing argument/return → BREAKING
DOCUMENTATION
────────────────────────────────
Concurrency safety — always document
Lifecycle (Close) — always document
Returning errors — reasons and conditions
MIGRATION
────────────────────────────────
Adding a new method → soft
Deprecate old → comment
Remove → major version
Summary¶
Professional pointer receiver: - Following library design conventions - API stability — minimizing breaking changes - Maintaining team standards with linters and analyzers - Documentation — about concurrency and lifecycle - Domain modeling — aggregate vs value object - Migration — soft transition + deprecation - Production patterns — worker pool, circuit breaker, graceful shutdown
Pointer receiver is a powerful tool, but using it at a professional level comes with team conventions, documentation, and testing. A well-written pointer receiver method produces an API that can serve for the next 5+ years.