DEV Community

Bala Paranj
Bala Paranj

Posted on

Functions vs Methods in Go: 6 Decision Criteria With Code Examples

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
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)