Small Go packages and boring APIs
I split a package this week because its name had become meaningless. util contained HTTP parsing, identifiers, retry logic, and one function that formatted dates. Importing it told a reader only that I had eventually run out of nouns.
My first repair was worse: one package per file. That produced retry, identifier, httputil, and dateutil, each with one exported function and several awkward dependencies. Package boundaries are not decorative folders. They should group a coherent capability and hide its implementation.
The useful split placed request parsing beside the server code that owns its rules. Identifier generation became a small package because several unrelated commands use the same format. Date formatting stayed with the output package that defines that presentation.
I also removed exported names. This API:
type RetryManager struct {
RetryCount int
RetryDelay time.Duration
}
func NewRetryManager(count int, delay time.Duration) *RetryManager
func (r *RetryManager) ExecuteRetryOperation(f func() error) error
became:
func Retry(n int, delay time.Duration, f func() error) error
There was no durable manager state and only one operation. The type existed because objects felt respectable. The function is harder to misuse, easier to test, and gives me fewer compatibility promises.
Exported identifiers are the public surface, even before a package reaches version one. Renaming one breaks every importer. Adding a method to an interface also breaks implementations outside the package, so I prefer accepting the smallest interface at the point of use rather than publishing a large “service” interface beside a concrete type.
Package names deserve the same economy. Callers write the package qualifier, so httpclient.Client stutters while client.Client or a more specific package often reads naturally. I avoid common, base, and misc; they attract unrelated code through gravity rather than design.
The test after the reorganization was not just go test ./.... I opened several call sites and read them without the implementation. If id.New() and server.ParseRequest(r) communicated enough, the boundary was doing work. If understanding required knowing which former utility file held the function, I had merely rearranged shelves.
Errors are part of the surface too. Callers should not need to compare prose. When they must distinguish a condition, I expose a stable sentinel error or a small concrete error type and document it. Most internal errors remain application details rather than becoming a taxonomy the package must support forever.
Small packages are useful. Tiny packages are not automatically virtuous. I now split around ownership and stable concepts, not line count. That gives me APIs boring enough that I can return to the actual program.