Precision is the missing virtue in Go interfaces

alexrios.me · 2026-08-21 · by Long Nguyen ·generic
Every parameter is a contract. Make it small enough to honor

Precision is the missing virtue in Go interfaces

---

Two signatures. Same job. Read them carefully.

func SendWelcomeEmail(u User) error
func SendWelcomeEmail(email, name string) error

The first one accepts twenty fields to use two of them. The second one accepts exactly what it needs.

The difference between them isn’t style. It isn’t elegance. It isn’t even cleanness, whatever that means in 2026. It’s precision. The second signature is a promise that’s small enough to keep. The first one is a promise you’ll find yourself rewriting every time `User` shifts shape.

I’ve come to believe that precision in signatures is the single most undervalued virtue in Go codebases, and most of the design pain I help teams untangle comes from its absence. Not architecture. Not patterns. Not testing strategy. Just signatures that ask for more than they need.

What I mean by precision

A precise signature accepts the smallest input that lets the function do its job, and returns the smallest output that satisfies its callers. Nothing more.

type User struct {
    ID            string
    Email         string
    Name          string
    PasswordHash  string
    CreatedAt     time.Time
    LastLoginAt   time.Time
    Roles         []string
    PreferredLang string
    Timezone      string
    // ... and twelve more
}

func SendWelcomeEmail(u User) error { return mailer.Send(u.Email, welcomeBody(u.Name)) }

`SendWelcomeEmail` uses two strings. It accepts twenty fields. That’s imprecision encoded in the type system. The signature is making promises the function doesn’t keep and demanding inputs the function doesn’t use.

The cost lands later, in three places I’ve watched it surface again and again:

Every test has to fabricate a `User`. Even the test for the welcome email behavior, where eighteen of those fields are irrelevant.

Every change to `User` invites the question “does this break `SendWelcomeEmail`?” The answer is almost always no, but you have to look, every time.

Every caller that doesn’t already hold a full `User` either fabricates one or routes around the function. The function is unreusable for half the cases that could have used it.

None of these are dramatic. They’re a small tax, levied every time the codebase changes, forever.

Precision is cheap in Go

Here’s what makes this frustrating: the precise version takes less work, not more.

func SendWelcomeEmail(email, name string) error {
    return mailer.Send(email, welcomeBody(name))
}

Two strings. Zero opinions about the rest of `User`. Tests shrink. The function works for any caller with a name and an email, not only ones that happen to have a full `User`. The body of the function is unchanged. The signature is the only thing that moved.

This is the move I find myself recommending more than any other in code review, because it’s the one with the highest ratio of impact to effort. No new abstractions. No new patterns. No new tests. Just a smaller signature that’s truer to what the function actually does.

Where Go interfaces help, and where they hurt

The Go proverb is “accept interfaces, return structs.” Most developers I work with read it as a license to define interfaces. It should be read as a license to define small ones.

type Mailable interface {
    Email() string
    Name() string
}

func SendWelcomeEmail(m Mailable) error { return mailer.Send(m.Email(), welcomeBody(m.Name())) }

This interface is precise. It declares exactly the two capabilities the function uses. Any type with those two methods can be passed. The function knows nothing else about its caller.

This is fine. It’s also overkill for two strings, where the value version is simpler and equally precise. The interface earns its keep when there are multiple producers (a `User` from the DB, a `Guest` from a session, a `Lead` from a CRM), when the set of methods is small and stable, and when the function is reused widely enough that the indirection pays for itself.

The failure mode I see far more often is the fat interface:

type UserService interface {
    Create(...) error
    Update(...) error
    Delete(...) error
    Get(...) (User, error)
    List(...) ([]User, error)
    Authenticate(...) error
    ResetPassword(...) error
    // ... sixteen more methods
}

Someone wrote that because the concrete `userService` had twenty three methods. It’s an interface, technically. It’s not precise. Any function that accepts a `UserService` is implicitly claiming to need all twenty three capabilities, when it almost certainly needs two or three.

If your interface has more than four or five methods, ask which callers use which. You’ll almost always find the methods cluster into two or three subsets used by different parts of the system. Each subset is its own interface, declared next to the consumer that needs it.

That’s the move that’s actually idiomatic in Go. Tiny interfaces, defined where they’re consumed, accepting only what the consumer needs. Precision at every boundary, not just at the package boundary.

Precision compounds

This is the argument I find lands hardest in code review, because it’s not obvious from any one signature.

Imprecise signatures don’t add their cost. They multiply it. Every function that accepts a fat `User` is now in the blast radius of every `User` change. Every function that accepts a fat `UserService` is now in the blast radius of every `UserService` method change. A change to one upstream type cascades through every imprecisely typed consumer.

Precise signatures don’t propagate change. A function that accepts `email, name string` is invisible to any change to `User` that doesn’t touch those two fields. The blast radius of any given change is bounded by the precision of the signatures the change touches.

That’s the leverage. Not “cleaner code.” Not “better testability.” A smaller blast radius for every future change. That compounds across the lifetime of the codebase in a way that “I extracted a nicer abstraction” never does.

The heuristic I use

For every parameter I accept, I ask: which fields or methods does the body actually touch?

If the answer is “two out of fifteen”, I have a choice to make.

Pass the two values directly. Simplest. Most precise.

Declare a two method interface in the consuming package. More flexible if there are multiple producers.

Leave it alone, knowing I’ve taken on a dependency on the full shape.

The third option is sometimes right. A handler that legitimately operates on most of a `User`. A serializer that needs the whole record. But it should be a deliberate choice, not the default that fell out of my editor.

The Go community got the first half of the proverb right. The second half is the one most teams forget: pass the least you can pass. Define the smallest interface on the consumer’s side. Make every signature an honest promise about what the function actually needs.

That’s the discipline. Precision in every signature, every time, even when imprecision is easier. It’s not a style preference. It’s the single highest leverage habit I have.

See also: Why your beautiful API will become someone else’s technical debt, How I actually think about coupling.

alexrios.me · 1 images · 21 links

Comments (0)

No comments yet