When to use a method (owns the data, needs state, is the domain question), when to use a function (operates across types, is a utility, was incorrectly coupled), and when to move code in both directions.
Go gives you two ways to attach behavior to logic: methods (with a receiver) and functions (without). Most languages make this decision for you — if it touches a class, it's a method. In Go, you have to choose.
The wrong choice shows up as code issues: a free function that takes a type as its first argument (should be a method), a method that doesn't use its receiver (should be a function), or a method on the wrong type (should be on the type it operates on).
Here are 6 decision criteria, each with a real before/after refactoring that moved code in the right direction — including two cases where methods became functions.
1. It Answers a Question About the Type → Method
Before: Free function inspects another type's internal state
// In package policy — operates on kernel.PrincipalScope
func scopePrecedence(s kernel.PrincipalScope) int {
switch s {
case kernel.ScopePublic: return 3
case kernel.ScopeAuthenticated: return 2
case kernel.ScopeCrossAccount: return 1
default: return 0
}
}
func upgradeScope(current, candidate kernel.PrincipalScope) kernel.PrincipalScope {
if scopePrecedence(candidate) > scopePrecedence(current) {
return candidate
}
return current
}
scopePrecedence takes a PrincipalScope as its argument and switches on its values. It's answering the question "how permissive is this scope?" — a question that belongs to the type, not to an external package.
After: Method on the type
// In package kernel — on PrincipalScope itself
func (s PrincipalScope) IsMorePermissiveThan(other PrincipalScope) bool {
return s.permissiveness() > other.permissiveness()
}
func (s PrincipalScope) permissiveness() int {
switch s {
case ScopePublic: return 3
case ScopeAuthenticated: return 2
case ScopeCrossAccount, ScopeAccount: return 1
default: return 0
}
}
The rule: If the function takes a type as its first argument and switches on its values, it's a method in disguise. Move it to the type.
What changed: scopePrecedence (exported, in wrong package) became permissiveness (unexported, on the type). upgradeScope (two-argument comparison) became IsMorePermissiveThan (method that reads as a domain question). The caller went from scopePrecedence(a) > scopePrecedence(b) to a.IsMorePermissiveThan(b).
2. It Operates on the Type's Internal State → Method
Before: Free function reaches into a struct's fields
func hasPublicReadAction(actions []string) bool {
for _, action := range actions {
a := strings.ToLower(action)
if isWildcardAction(a) || a == actionGetObject {
return true
}
}
return false
}
// Called with the struct's internal field:
if !hasPublicReadAction(stmt.Action) {
continue
}
hasPublicReadAction takes stmt.Action — the caller reaches into Statement to extract the field, then passes it to a free function. The function does work that Statement should own.
After: Method on Statement
func (s Statement) GrantsReadAccess() bool {
mask, _ := s.ResolveActions()
return mask.has(actionRead)
}
// Called on the struct directly:
if !stmt.GrantsReadAccess() {
continue
}
The rule: If the function takes a field FROM a struct, make it a method ON the struct. The caller shouldn't need to know which field to extract.
Bonus: The method uses ResolveActions() (the bitmask resolver) instead of reimplementing action string matching. The free function duplicated logic that the type already had.
3. It Doesn't Use Its Receiver → Function
Before: Method that only uses its parameters
func (e *Runner) partitionFindings(
findings []evaluation.Finding,
now time.Time,
) ([]evaluation.Finding, []evaluation.ExceptedFinding) {
// Uses e.Exceptions — but that's the ONLY field it touches
for _, f := range findings {
if e.Exceptions.ShouldExcept(f.ControlID, now) {
excepted = append(excepted, ...)
} else {
active = append(active, ...)
}
}
return active, excepted
}
partitionFindings is a method on Runner but only uses e.Exceptions. It doesn't use e.Controls, e.Clock, e.Hasher, or any other Runner field. It's coupled to the entire 12-field god object for the sake of one field.
After: Standalone function with explicit dependencies
func partitionFindings(
findings []evaluation.Finding,
exceptions *policy.ExceptionConfig,
now time.Time,
) ([]evaluation.Finding, []evaluation.ExceptedFinding) {
for _, f := range findings {
if exceptions.ShouldExcept(f.ControlID, now) {
excepted = append(excepted, ...)
} else {
active = append(active, ...)
}
}
return active, excepted
}
The rule: If a method uses only one field from its receiver, pass that field as a parameter instead. The function becomes testable without constructing the entire receiver struct.
What changed: e.Exceptions became an explicit parameter. Testing partitionFindings no longer requires a fully constructed Runner — just an ExceptionConfig and a slice of findings.
4. It Needs Shared State Across Calls → Method on Session
Before: Free function called with 5 drilled parameters
func evaluateControl(
ctl *policy.ControlDefinition,
timelines map[asset.ID]*asset.Timeline,
now time.Time,
acc *Accumulator,
identityIdx IdentityIndex,
) {
strategy := strategyFor(ctl)
for _, id := range sortedAssetIDs(timelines) {
check, findings := strategy.Evaluate(timelines[id], now, identityIdx)
acc.RecordCheck(check)
acc.RecordFindings(findings)
}
}
Five parameters, three of which (now, acc, identityIdx) are the same for every call. They're computed once in the parent function and drilled through to every helper.
After: Method on session struct
type assessmentSession struct {
assessor *Assessor
auditTime time.Time
collector *AssessmentCollector
idIndex IdentityIndex
}
func (s *assessmentSession) applyControl(
ctl *policy.ControlDefinition,
lifecycles map[asset.ID]*asset.ExposureLifecycle,
) {
strat := s.strategyFor(ctl)
for _, id := range sortedAssetIDs(lifecycles) {
check, findings := strat.Evaluate(lifecycles[id], s.auditTime, s.idIndex)
s.collector.RecordCheck(check)
s.collector.RecordFindings(findings)
}
}
The rule: If the same parameters are passed to every call in a sequence, group them into a session struct and make the calls methods on it. The parameters become fields — written once, read many times.
What changed: 5 parameters → 2 parameters. now, acc, identityIdx are fields on assessmentSession. The function went from "takes everything it needs" to "accesses shared state from its receiver."
5. It Works Across Multiple Types → Function
Before: Awkward placement on one type
// Which type does this belong to? ComplianceReport? FindingDetailRequest? Neither?
func (r *ComplianceReport) BuildFindingDetail(req FindingDetailRequest) (*FindingDetail, error) {
// Uses r.Findings, r.Checks, req.ControlID, req.AssetID, req.TraceBuilder
// Doesn't modify r — it's a read-only query across two types
}
BuildFindingDetail takes data from ComplianceReport AND FindingDetailRequest to produce a FindingDetail. It's on ComplianceReport by convention, but it doesn't modify the report — it's a cross-type transformation.
After: Standalone function with both types as parameters
func BuildFindingDetail(
r *evaluation.ComplianceReport,
req evaluation.FindingDetailRequest,
gen ports.IdentityGenerator,
) (*evaluation.FindingDetail, error) {
// Clear: this function needs data from both types
}
The rule: If the function needs equal access to two or more types and doesn't modify any of them, make it a standalone function. Placing it on one type as a method creates a false ownership relationship.
When to break this rule: If one type is clearly the "owner" — the function modifies it, or callers always have an instance of it — make it a method on that type even if it reads from others.
6. It Needs Access to Private Fields → Method
Before: Exported function with exported fields to support it
// Fields exported so the helper can access them
type Definition struct {
ID kernel.ControlID // Exported for external access
Description string // Exported for external access
Severity policy.Severity // Exported for external access
}
// Helper function constructs issues from the definition
func BuildIssueContext(ctl *Definition, extra map[string]string) map[string]string {
ctx := make(map[string]string)
if ctl.ID != "" {
ctx["control_id"] = ctl.ID.String()
}
if ctl.Description != "" {
ctx["description"] = ctl.Description
}
maps.Copy(ctx, extra)
return ctx
}
BuildIssueContext is a free function that accesses Definition fields. The fields must be exported for the function to work. But Definition should encapsulate its fields — they have invariants that external code shouldn't bypass.
After: Method with unexported fields
type Definition struct {
id kernel.ControlID // unexported
description string // unexported
severity policy.Severity // unexported
}
func (ctl *Definition) issueContext(extra map[string]string) map[string]string {
ctx := make(map[string]string, 2+len(extra))
if ctl.id != "" {
ctx["control_id"] = ctl.id.String()
}
if desc := strings.TrimSpace(ctl.description); desc != "" {
ctx["description"] = desc
}
maps.Copy(ctx, extra)
return ctx
}
func (ctl *Definition) newIssue(code diag.RuleID, extra map[string]string) *diag.Builder {
return diag.NewFinding(code).Attributes(ctl.issueContext(extra))
}
The rule: If a function accesses struct fields that should be unexported, make it a method. Methods can access unexported fields; functions outside the package can't.
What changed: Fields went from exported to unexported. The free function became a method. External code now uses ctl.newIssue(code, extra) instead of BuildIssueContext(ctl, extra) — the helper is encapsulated, and the definition controls its own representation.
The Decision Tree
Does the function answer a question about one type?
├── YES → Method on that type
│ Example: PrincipalScope.IsMorePermissiveThan()
│
Does it modify the type's internal state?
├── YES → Method on that type (pointer receiver)
│ Example: SanitizableMap.Set(key, value)
│
Does it need access to unexported fields?
├── YES → Method on that type
│ Example: Definition.issueContext()
│
Does the same set of parameters repeat across many calls?
├── YES → Method on a session/context struct
│ Example: assessmentSession.applyControl()
│
Does it use only one field from its receiver?
├── YES → Standalone function with that field as parameter
│ Example: partitionFindings(findings, exceptions, now)
│
Does it operate equally on two or more types?
├── YES → Standalone function
│ Example: BuildFindingDetail(report, request, gen)
│
Is it a pure utility with no state?
├── YES → Standalone function
│ Example: isLowerHex(s), sortedSnapshots(snaps)
│
Is it a constructor?
├── YES → Standalone function named New*
│ Example: NewControlID(raw), NewExposureLifecycle(asset)
Methods answer questions about their receiver or modify its state. Functions transform data or coordinate between types. When in doubt, start with a function — it's easier to promote a function to a method than to demote a method back to a function, because methods can access unexported fields that functions cannot.
These 6 function-vs-method decisions were made during refactoring of Stave, a Go CLI for offline security evaluation. Two moves went function→method (domain knowledge belongs on the type). Two went method→function (reducing coupling to the receiver). Two were methods-from-the-start for encapsulation and session state.
Top comments (0)