Writing Idiomatic Go: Patterns That Separate Clean Code From "Java in Go"
Go punishes code written with a Java or C# accent. Here are the patterns that mark genuinely idiomatic Go, from interface placement to error handling to goroutine discipline.
Every Go codebase we inherit tells us where its authors came from. Deep inheritance-style struct embedding, interfaces for everything, factory functions returning interfaces, getters and setters on every field: that is Java wearing a Go costume. It compiles, it runs, and it fights the language at every turn.
Idiomatic Go is not about style points. Code that works with the grain of the language is shorter, easier to test, and dramatically easier for the next engineer to modify. Here are the patterns we look for when we review Go code, including in our own hiring assessments.
1. Define interfaces where they are used, not where they are implemented
The single most common “Java in Go” tell. In Java, the implementer declares the interface. In Go, the consumer does:
// package storage: just export the concrete type
type S3Store struct{ /* ... */ }
// package report, the CONSUMER, declares what it needs
type BlobReader interface {
Read(ctx context.Context, key string) ([]byte, error)
}
func Generate(r BlobReader) (*Report, error) { /* ... */ }
The consumer asks for exactly the behavior it needs, nothing more. Mocking in tests becomes trivial, and packages stop depending on each other’s interface definitions.
2. Keep interfaces small
The standard library’s most-used interfaces have one or two methods: io.Reader, io.Writer, fmt.Stringer. A five-method interface is a design smell; a fifteen-method one is a Java DAO that wandered into the wrong repository. Small interfaces compose; big ones calcify.
3. Errors are values, and context is mandatory
No sentinel panics, no exception-style control flow. Wrap errors with context at every boundary where you can add information:
if err := s.charge(ctx, order); err != nil {
return fmt.Errorf("charging order %s: %w", order.ID, err)
}
The %w verb preserves the chain for errors.Is and errors.As. A failure three layers deep should read like a story: “handling request: charging order ord_123: card declined”. If your logs make you grep four files to reconstruct what happened, your error handling is not done.
4. Make the zero value useful
Idiomatic Go types work without a constructor when possible. sync.Mutex needs no initialization; bytes.Buffer is ready on declaration. Before writing NewThing(), ask whether the zero value could simply work. When construction is genuinely required, one NewThing function, not a builder hierarchy.
5. Concurrency is a design decision, not a habit
Goroutines are cheap to start and expensive to reason about. The questions that matter: who owns this goroutine, how does it stop, and where do its errors go? If you cannot answer all three, you have a leak or a silent failure waiting.
- Pass
context.Contextas the first argument and honor cancellation. - Prefer
errgroupor a worker pool with explicit lifetime over fire-and-forgetgostatements. - Run tests with
-racein CI, always. No exceptions.
And sometimes the most idiomatic concurrency is none: a sequential loop you can read beats a channel pipeline you cannot.
6. Accept interfaces, return structs
Functions should accept the minimal interface they need and return concrete types. Returning interfaces hides the methods callers might legitimately need and breaks type assertion ergonomics. Let callers decide how to abstract you.
7. Package names are part of your API
util, common, helpers, and base are where cohesion goes to die. A Go package should be named for what it provides (retry, pdf, billing), and the name is part of every call site: retry.Do, not utils.RetryOperation. If you cannot name the package cleanly, the boundaries are wrong.
8. Table-driven tests, not test-class hierarchies
tests := []struct {
name string
input string
want int
wantErr bool
}{
{"empty input", "", 0, true},
{"single value", "5", 5, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { /* ... */ })
}
One pattern, infinitely extensible, and the next engineer adds a case by adding a row. Compare that to a mock-heavy JUnit-style setup and the difference in maintenance cost is enormous.
The meta-pattern
Everything above points the same direction: Go rewards code that is boring, explicit, and locally readable. The clever abstraction you are proud of today is the onboarding cost someone pays next quarter.
This is exactly what our AI interview platform probes when we vet Go engineers: not whether they know syntax, but whether they make these judgment calls instinctively. If you want engineers who already write Go like this, that is what we place.