Methods vs Functions — Senior Level¶
Table of Contents¶
- Introduction
- Architectural View
- Dispatch Mechanics
- Method Values, Closures, and Memory
- Method Expressions for Generic Dispatch
- Encapsulation Strategy
- Composition Over Inheritance
- Testing Strategies
- Concurrency and Receiver Choice
- Generics + Methods
- Real-World Refactoring
- Library Design Decisions
- Cheat Sheet
- Summary
Introduction¶
At the senior level the choice between method and function is no longer about syntax — it is an architectural decision. Every method: - Exposes API surface - Defines an ownership boundary - Affects how the code is tested and mocked - Influences concurrency and memory layout
This file looks at methods and functions in the context of an entire project.
Architectural View¶
Domain layer — methods¶
// Domain entities — behavior in methods
type Order struct {
ID string
Items []Item
state OrderState
}
func (o *Order) AddItem(i Item) error {
if o.state != Draft {
return ErrFrozen
}
o.Items = append(o.Items, i)
return nil
}
func (o *Order) Submit() error {
if len(o.Items) == 0 {
return ErrEmptyOrder
}
o.state = Submitted
return nil
}
Service layer — orchestration: usually methods (for DI)¶
type CheckoutService struct {
orders OrderRepo
payments PaymentGateway
notify Notifier
}
func (s *CheckoutService) Checkout(ctx context.Context, orderID string) error {
order, err := s.orders.Find(ctx, orderID)
if err != nil { return err }
if err := s.payments.Charge(ctx, order.Total()); err != nil { return err }
return s.notify.Send(ctx, order.UserID, "Order paid")
}
Utility layer — functions¶
// Pure helpers — function
package strutil
func TrimQuotes(s string) string { ... }
func Slugify(s string) string { ... }
Rule: Domain behavior in methods, infrastructure dependencies in the method's struct (DI), pure logic in functions.
Dispatch Mechanics¶
Static dispatch (90% of cases)¶
Direct method calls are resolved at compile time:
Dynamic dispatch (through an interface)¶
The itab (interface table) is an internal Go runtime structure: a type-to-methods mapping. Each interface satisfaction is cached in an itab the first time it occurs.
Method value — implicit closure¶
fn holds a pointer (or a copy) of o inside it. If you pass fn to a global slice, it can escape to heap.
Method Values, Closures, and Memory¶
Method value escape¶
type Service struct{ db *DB }
func (s *Service) Handle(req Req) Resp { ... }
func register(handlers map[string]func(Req) Resp) {
s := &Service{db: openDB()}
handlers["x"] = s.Handle // s escapes to the heap
}
When s.Handle creates a method value, Go implicitly stores the s pointer. The closure lives on the heap, and s (the Service struct) is moved to the heap as well.
Compiler view¶
go build -gcflags='-m' main.go
# main.go:5: &Service{...} escapes to heap
# main.go:6: s.Handle escapes to heap
Performance impact¶
- Method value escape = one GC allocation.
- Method expression (
Service.Handle) does NOT escape — the receiver is passed at runtime.
Method expression with receiver dispatch¶
type Pipeline []func(*Service, Req) Resp
func (s *Service) Step1(r Req) Resp { ... }
func (s *Service) Step2(r Req) Resp { ... }
p := Pipeline{(*Service).Step1, (*Service).Step2}
// The receiver is passed as an argument each time
This pattern minimizes allocations.
Method Expressions for Generic Dispatch¶
Strategy via method expression¶
type Order struct{ ... }
func (o *Order) ApplyTax(rate float64) { ... }
func (o *Order) ApplyDiscount(p float64) { ... }
type Strategy func(*Order, float64)
strategies := map[string]Strategy{
"tax": (*Order).ApplyTax,
"discount": (*Order).ApplyDiscount,
}
strategies["tax"](&order, 0.07)
This is polymorphism without interfaces: no interface is needed, yet you have pluggable behavior.
Encapsulation Strategy¶
Field gating¶
type Account struct {
balance float64 // private
mu sync.Mutex
}
func (a *Account) Balance() float64 {
a.mu.Lock()
defer a.mu.Unlock()
return a.balance
}
func (a *Account) Withdraw(amt float64) error {
a.mu.Lock()
defer a.mu.Unlock()
if amt > a.balance { return ErrInsufficient }
a.balance -= amt
return nil
}
Here the balance field is invisible from the outside — the only way to interact is through Balance() and Withdraw(). Mutex synchronization happens inside the method — the caller doesn't know and cannot get it wrong.
Functional core, imperative shell¶
Pure logic — function. The state-changing part — method:
// Pure core
func computeTotal(items []Item, taxRate float64) float64 { ... }
// Stateful shell
func (o *Order) RecalculateTotal() {
o.Total = computeTotal(o.Items, o.taxRate)
}
How testing looks: - computeTotal — pure unit test - RecalculateTotal — minimal integration test
Composition Over Inheritance¶
There is no inheritance in Go. Instead, embedding is used:
type Base struct{ ID string }
func (b Base) Identify() string { return b.ID }
type User struct {
Base
Email string
}
u := User{Base: Base{ID: "u1"}, Email: "a@b.c"}
fmt.Println(u.Identify()) // u1 — Base's method is "promoted" to User
Promotion rules¶
- Exported methods of the embedded type are promoted to User.
- Method set: if
Baseis embedded by value,User's method set containsBase's value methods; if*Baseis embedded — both value and pointer methods are included. - Method overriding is possible: if
Userdefines its ownIdentify(),Base.Identify()is shadowed.
Embedding with interfaces¶
type Reader interface { Read([]byte) (int, error) }
type LoggingReader struct{ Reader } // embedding an interface
func (lr LoggingReader) Read(p []byte) (int, error) {
n, err := lr.Reader.Read(p)
log.Println("read", n)
return n, err
}
This is the decorator pattern, Go style.
Testing Strategies¶
Pure function — no mock¶
// Pure
func calculateTax(amount float64, rate float64) float64 {
return amount * rate
}
// Test
func TestCalculateTax(t *testing.T) {
if got := calculateTax(100, 0.07); got != 7 {
t.Errorf("got %v, want 7", got)
}
}
Method with dependency — interface for mock¶
type EmailSender interface {
Send(to, body string) error
}
type Notifier struct{ sender EmailSender }
func (n *Notifier) Notify(to, msg string) error { ... }
// Test
type fakeSender struct{ sent []string }
func (f *fakeSender) Send(to, body string) error {
f.sent = append(f.sent, to)
return nil
}
func TestNotify(t *testing.T) {
f := &fakeSender{}
n := &Notifier{sender: f}
n.Notify("a@b.c", "hi")
if len(f.sent) != 1 { t.Fatal("expected 1 send") }
}
Writing tests for a method — table-driven¶
func TestOrderSubmit(t *testing.T) {
cases := []struct {
name string
items []Item
wantErr bool
}{
{"empty", nil, true},
{"valid", []Item{{ID: "i1"}}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
o := &Order{Items: c.items}
err := o.Submit()
if (err != nil) != c.wantErr {
t.Errorf("got err=%v, wantErr=%v", err, c.wantErr)
}
})
}
}
Concurrency and Receiver Choice¶
Mutex and pointer receiver¶
type SafeCounter struct {
mu sync.Mutex
n int
}
// MUST be a pointer — to prevent the mutex copy bug
func (c *SafeCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
If a value receiver is used:
func (c SafeCounter) Inc() { ... } // BAD
var c SafeCounter
go c.Inc() // a copy of c is taken — the mutex is copied → vet warning
go vet reports a "passes lock by value" warning. A pointer receiver prevents this bug.
Atomic + value receiver¶
type Counter struct{ n atomic.Int64 }
// Even with a value receiver this cannot work — the atomic gets copied
// Always: pick a pointer receiver
func (c *Counter) Inc() { c.n.Add(1) }
atomic.Int64 carries a noCopy marker — go vet will detect copy-and-use.
Generics + Methods¶
Method rules for generic types in Go 1.18+:
type List[T any] struct{ items []T }
func (l *List[T]) Add(x T) { l.items = append(l.items, x) }
func (l *List[T]) Get(i int) T { return l.items[i] }
func (l *List[T]) Len() int { return len(l.items) }
Rules: - A method cannot add its own type parameter (only the receiver type's parameters) - The method name must fully match the struct's parameter list
// ERROR
// func (l *List[T]) Map[U any](f func(T) U) *List[U] { ... }
// Correct way — a generic function at package level
func Map[T, U any](l *List[T], f func(T) U) *List[U] { ... }
This restriction exists to keep Go's generics simple.
Real-World Refactoring¶
Example: scattered functions → cohesive type¶
Before (procedural):
func validateUser(name, email string) error { ... }
func saveUser(name, email string) error { ... }
func sendWelcome(email string) error { ... }
func registerUser(name, email string) error {
if err := validateUser(name, email); err != nil { return err }
if err := saveUser(name, email); err != nil { return err }
return sendWelcome(email)
}
After (cohesive):
type User struct {
Name, Email string
}
func (u User) Validate() error { ... }
type UserService struct {
repo Repo
email EmailGW
}
func (s *UserService) Register(u User) error {
if err := u.Validate(); err != nil { return err }
if err := s.repo.Save(u); err != nil { return err }
return s.email.SendWelcome(u.Email)
}
Benefits: - User's behavior lives on User - UserService's dependencies are visible (DI) - Easy to mock for tests
Example: God object → focused types¶
Before:
type App struct{}
func (a *App) ParseRequest(r *http.Request) ...
func (a *App) ValidateAuth(token string) ...
func (a *App) ConnectDB() ...
func (a *App) RenderTemplate(name string) ...
After:
type RequestParser struct{}
type Authenticator struct{}
type DBConnector struct{}
type TemplateEngine struct{}
Each type has one responsibility (SRP).
Library Design Decisions¶
Public API: function or method?¶
| Use case | Choice |
|---|---|
| Quick utility | function (strings.Contains) |
| Stateful object | method (http.Client.Do) |
| Builder | method-chain (strings.Builder.Write*) |
| Configuration | functional options (function) |
| Domain entity | method |
Versioning and backward compatibility¶
Adding a public method is non-breaking. Removing a method is breaking. Changing the receiver type (value→pointer) is breaking (the method set changes).
Minimal interface (the caller declares it)¶
// Bad — the library exposes a large interface
type DB interface {
Query(...) ...
Exec(...) ...
Begin() ...
Close() ...
Stats() ...
}
// Good — minimal interface defined on the caller side
type Querier interface { Query(...) ... }
func DoX(q Querier) error { ... }
The caller can pass *sql.DB, *sql.Tx, or a mock — anything whose method set fits Querier.
Cheat Sheet¶
ARCHITECTURE LAYERS
────────────────────────────
Domain → entity methods
Service → struct + DI methods
Utility → pure functions
DISPATCH
────────────────────────────
Direct call → static (compile time)
Interface → dynamic (itab)
Method value → closure (heap)
Method expression → receiver as arg (no escape)
PERFORMANCE
────────────────────────────
Method value (s.M) → s escapes to heap
Method expr (T.M) → no escape
Inlining or speed of value/method are the same
CONCURRENCY
────────────────────────────
With mutex/atomic → always pointer receiver
go vet "passes lock by value" — catches the copy bug
GENERICS
────────────────────────────
type List[T] — methods can use T
A method CANNOT add its own type parameter
Generic transform → package-level function
PUBLIC API
────────────────────────────
Adding a pointer receiver → non-breaking
Pointer→value, value→pointer → BREAKING
Removing a method → BREAKING
Adding a method → non-breaking
Summary¶
Senior-level method/function decisions:
- Method — domain entity, stateful service, interface satisfier.
- Function — pure logic, utility, generic transform.
- Method value — has escape consequences; be careful with callbacks.
- Method expression — escape-free dispatch.
- Embedding — composition instead of inheritance.
- Concurrency — pointer receiver when there is a sync primitive.
- Generics — methods can't add their own parameters; lift to a package function.
- Public API — changing the receiver type is breaking; be careful.
The senior choice is built on minimal interfaces, pure methods, and clear encapsulation. At the professional level we then combine all of this with domain modeling, DDD, and large-team coding standards.