The case against helper packages named `utils`

I argued earlier that a function’s signature should never lie, and then that errors are values only if you treat them like values. Both posts circled the same question at different scales: what is this code for? This one closes the loop at the package level. The short version: if your package is named utils, you don’t have an answer.

The grep that started it

I was reading a popular Go project on GitHub on a Sunday, the kind with thousands of stars and a logo, trying to learn how a piece of it worked. I ran this in the clone:

$ grep -rl "^package utils" . | wc -l
14

Fourteen utils packages. Different directories, different subsystems, different contents. One had retry logic and a SHA-256 helper. One had a StringPtr(s string) *string and three flavors of Must. One was just SliceContains. They imported each other through a chain that ran roughly:

cmd/api ──► internal/utils ──► pkg/utils ──► common/utils
                  │                │
                  ▼                ▼
            shared/utils ◄── infra/utils

That diagram is not unusual. I’ve seen the same shape in three other projects since. It’s what happens when every subsystem writes its own utils and then nobody agrees which one owns the next helper.

Why utils is a name smell

Three failure modes, every time.

Anti-cohesion. A package is a unit of meaning. auth, billing, pricing, httpx all promise something. utils promises nothing except “we didn’t know where to put it.” Functions land there because the author ran out of patience, not because they belong together. Six months later nobody can describe what the package is.

Import magnet, then primitive re-inventor. utils is convenient, so everyone imports it. Once everyone imports it, it can’t import anyone back without risking a cycle. So when utils needs something from auth or billing, it doesn’t import them, it re-implements them. Now you have two copies of the same logic and a slowly diverging definition of “user.”

Untestable culture turns into a graveyard. It starts pure: a string helper, a slice helper. No domain, no tests needed, “it’s just utils.” Then someone adds utils.NormalizeUserEmail which knows the rules of your product. Then utils.ChargeIfTrial. Domain logic sneaks in through the side door and lives somewhere with no owner and no tests.

The rename game

Every helper in a utils package can be renamed into a real one. Five examples, each one line, each pointing at a concept that already has a name.

1. utils.RetryHTTPhttpx.Retry. It’s about HTTP. Put it next to HTTP.

package httpx

func Retry(do func() (*http.Response, error), n int, backoff time.Duration) (*http.Response, error) {
    var (
        resp *http.Response
        err  error
    )
    for i := 0; i < n; i++ {
        resp, err = do()
        if err == nil && resp.StatusCode < 500 {
            return resp, nil
        }
        if resp != nil {
            resp.Body.Close()
        }
        time.Sleep(backoff)
    }
    return resp, err
}

The package name carries half the meaning. httpx.Retry reads. utils.RetryHTTP reads like an apology.

2. utils.ParseDurationtimex.ParseLoose. The stdlib already owns time.ParseDuration. If you wrote your own, it does something extra (probably "d" for days). Say so in the name and the package.

package timex

func ParseLoose(s string) (time.Duration, error) {
    if strings.HasSuffix(s, "d") {
        d, err := time.ParseDuration(strings.TrimSuffix(s, "d") + "h")
        if err != nil {
            return 0, err
        }
        return d * 24, nil
    }
    return time.ParseDuration(s)
}

3. utils.SliceContainsslices.Contains. The stdlib has had this since Go 1.21. Delete yours.

import "slices"

ok := slices.Contains(users, "alice")

Half the utils.go files I’ve seen could be deleted by upgrading Go and running gofmt -r. Do that first.

4. utils.MustEnvconfig.MustEnv. This one is sneaky. MustEnv isn’t a generic helper. It’s a policy: “at process start, this variable must be set, otherwise we refuse to boot.” That belongs with the rest of your configuration policy.

package config

func MustEnv(key string) string {
    v, ok := os.LookupEnv(key)
    if !ok || v == "" {
        panic(fmt.Errorf("config: missing required env var %q", key))
    }
    return v
}

5. utils.HashPasswordauth.HashPassword. If this one is in utils in your codebase, stop reading and go fix it. Password hashing is a security concern with an owner. Give it to the owner.

package auth

func HashPassword(plaintext string) (hash, salt string, err error) {
    // ...
}

Notice the pattern. None of these renames invented new packages out of nowhere. They moved each function next to the concept it serves.

The rule

If you can’t name the package after a concept, the code doesn’t belong together.

httpx, timex, slices, config, auth are concepts. utils, helpers, common, misc, shared are excuses. They tell you the author gave up.

A useful test: write the package’s first doc comment.

// Package httpx adds small extensions on top of net/http.

Try the same for utils:

// Package utils contains... uh... assorted things.

If you can’t finish the sentence without listing the functions, the package shouldn’t exist.

Migration tip: shrink, don’t merge

The instinct when you find a 2,000-line utils package is to “clean it up” in one big PR. Don’t. You’ll spend two weeks rebasing and merge it half done.

Instead: every PR that touches utils is allowed to only delete from it.

  • Move one function to its real home.
  • Update callers.
  • Open a 30-line PR.
  • Merge.

After a dozen of those, utils is empty and you delete the directory. PRs that only delete are the most satisfying kind to review, and they never conflict with feature work.

If a function genuinely has no concept home (Ptr[T any](v T) *T, say), it’s almost always small enough to inline at the call site or to live in a 3-line internal/ptr package. Three lines is fine. Two thousand lines of “assorted” is not.

Wrapping the trilogy

Three posts, one question.

Post 1 said: a function’s signature should answer “what is this code for?” If you need a comment to clarify it, the signature is wrong.

Post 2 said: an error should answer the same question on the way out. If you need to grep logs to figure out where it came from, the error is wrong.

This one says: a package name answers the same question one level up. If you can’t name it after a concept, the package is wrong.

Signatures, errors, package names. Same conversation, three scales. The codebases I enjoy working in are the ones where every level of naming points at the thing it actually does. The ones I dread are the ones where every level shrugs.

Stop shrugging. Delete utils.

comments powered by Disqus