- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a Go repo you've never seen. There's a package called
util. You open it. Inside: a JWT signer, a retry loop, a slice
dedup helper, a date formatter, and a function that talks to Redis.
Five unrelated jobs sharing one door. You needed to change the retry
loop, and now you have to read all five to be sure you don't break
something.
That package name told you nothing. util.Retry, util.Sign,
util.Dedup — the prefix is noise. It's a bucket, and buckets fill
up. Go treats the package name as part of the identifier, and that
is why util, helpers, common, and shared rot faster than the
code inside them. Split them into names that actually carry meaning
at the call site and the rot stops.
The package name is part of the API
In Go, you don't import a symbol. You import a package, and you call
its exported names through it. The package name is a permanent
prefix on every function, type, and constant you export. Read this
call:
sig, err := jwt.Sign(claims, key)
You know what that does before you read the docs. Now read the same
function living in a util package:
sig, err := util.Sign(claims, key)
util.Sign. Sign what? A JWT? A git commit? A form submission? The
package name had one job (give the reader a noun to anchor the verb)
and it punted. The Go blog's package-names post
and Effective Go
both say the same thing: the caller sees pkg.Name, so name the
package for the reader at the call site, not for the files on disk.
This is why the standard library never ships a util. It ships
strings, bytes, bufio, strconv. Each name is a topic. When
you write strings.TrimSpace, the package and the function read as
one phrase. That is the bar.
Why util, common, and helpers rot
The problem with util isn't the four letters. It's that the name
describes the code's relationship to you ("stuff I found handy"),
not the domain it serves. A name like that has no boundary. Nothing
is out of scope for a package called util, so everything drifts in.
Watch the lifecycle. Someone writes a stringutil.go file with one
function. A month later there's a date helper that doesn't deserve
its own package, so it lands in util too. Then a config loader.
Then a small HTTP wrapper. Six months in, util imports database/sql,
net/http, and three internal packages, and it's imported by
everything. Now it's a cycle risk and a merge-conflict magnet, and
nobody can move a function out because they can't tell who depends on
what.
common and shared fail the same way, plus they invite an import
cycle by design. "Common" means "imported by many," so it sits at the
bottom of your dependency graph. The moment one common function needs
something from a domain package, you get:
// package common
import "myapp/orders" // common now depends on a feature
And orders already imports common. That's a compile-time import
cycle in Go, and the compiler will stop you cold:
import cycle not allowed
package myapp/orders
imports myapp/common
imports myapp/orders
Go refuses to build it. That refusal is the language telling you the
package boundary was fiction.
The test: can you finish the sentence?
Here's a check you can run on any package name. Say it out loud with
one of its exported functions:
- "
strings.Splitsplits a string." Clean. - "
util.Splitsplits a util." Meaningless.
If the package name doesn't complete the sentence with a real noun,
it's a bucket. helpers, misc, tools, lib, base, core,
common — none of them name a thing. They name your feelings about
the code.
A good Go package name is a lowercase, single-word noun that names a
concept: token, retry, slug, money, ratelimit, httpx.
When you export from it, the call site reads as a sentence:
retry.Do, slug.Make, money.Add, ratelimit.Allow.
Splitting a util package, step by step
Take a real util with three unrelated functions.
// package util
func RetryDo(n int, f func() error) error { /* ... */ }
func Slugify(s string) string { /* ... */ }
func DedupInts(in []int) []int { /* ... */ }
Three verbs, three domains, one bucket. Split by concept, one
package per topic:
// package retry -> retry.Do
func Do(n int, f func() error) error { /* ... */ }
// package slug -> slug.Make
func Make(s string) string { /* ... */ }
Notice the rename. Inside package retry, the function is Do, not
RetryDo — the package already says "retry," so repeating it in the
function name gives you retry.RetryDo, a stutter Go's own
style guidance
calls out. Drop the prefix. Let the package carry it.
The DedupInts case is interesting, because in Go 1.21+ it probably
shouldn't be your code at all:
import "slices"
out := slices.Compact(slices.Clone(in))
Half of what lands in util is standard-library functionality
somebody rewrote before checking. slices, maps, cmp, and
slices.SortFunc absorbed a large chunk of the classic util grab
bag. Before you name a helper package, check whether the stdlib
already owns the noun.
When a helper genuinely has no home
Sometimes you have a two-line function that is honestly generic — a
Must wrapper, a pointer-to-value helper. It doesn't need util. It
needs to live next to the code that uses it, unexported. Go's scoping
rewards this: an unexported helper in the package that needs it has
zero API surface and zero import cost.
// package config, in load.go
func mustParse(s string) time.Duration {
d, err := time.ParseDuration(s)
if err != nil {
panic(err)
}
return d
}
mustParse is lowercase, so it never leaves config. No caller
anywhere types util.MustParse. The helper exists exactly where it's
used and nowhere else. That's the default for genuinely local glue —
not a shared bucket.
If the same helper turns out to be needed in three packages, then
and only then does it earn a real package with a real noun for a
name. Promote on the third use, not the first.
Import-path readability is the whole point
Go makes you type the package name on every call. That's not
friction to route around — it's a free readability check that runs on
every line. jwt.Sign, retry.Do, slug.Make, ratelimit.Allow
each read as subject-and-verb. util.Sign, common.Do, helpers.Make
read as noise-and-verb.
The internal/ directory is your friend here. Packages under
internal/ can't be imported from outside the module, so you can
carve up a util into internal/retry, internal/slug,
internal/token without exposing any of it as public API. You get the
naming discipline without committing to a stable external surface.
One util becoming five small named packages feels like more files.
It's less to read. Each package now states its scope in its name, the
call sites read as sentences, and the import graph shows real
dependencies instead of one bucket everything points at. When you
next open the repo you've never seen, the package list is a table of
contents instead of a junk drawer.
The one grep to run today
Search your module:
grep -rl 'package util\|package common\|package helpers\|package shared' .
For each hit, list the exported functions and try the sentence test
on each one. Every function that fails belongs in a package named for
its noun. Most of them will split into two or three clean topics, and
a surprising number will turn out to already exist in slices,
maps, or strings. Delete those outright.
Package naming in Go is not cosmetic. The name is a prefix the
compiler stamps on every call, a boundary the import cycle rule
enforces, and the first thing a new reader sees. Get it wrong once
with util and it compounds for the life of the repo. Get it right
and the codebase reads itself.
Package boundaries are where Go's design shows through: the compiler
turns a sloppy import cycle into a build error, and the call site
turns a lazy name into daily noise. The Complete Guide to Go
Programming goes deep on how packages, scope, and the import graph
actually work in the language. Hexagonal Architecture in Go is the
companion for keeping those boundaries in the right place as a service
grows, so util never has a chance to form.

Top comments (0)