skip to content

Splitting a repo-wide Go util package produces an import cycle; how do you carve it up and name the pieces?

level: seniorimportance: should knowfreq 32%

answer

  1. start from the call sites, not the files
  2. one package per capability the caller wants
  3. the cycle was there before the split
  4. go list -deps shows who depends on whom
  5. the compiler enumerates every stale reference

basics

~20 s

Group by the capability callers want, not by file, and name each new package so its call sites read as phrases. The cycle is not new damage: references that were legal inside one package become a cycle Go's compiler rejects, exposing layering the bucket was hiding.

solid answer

~50 s

Start from the call sites, not the file list. Find who calls what — `go list -deps ./...` shows the dependency fan, and searching for `util.` shows which symbols are actually live — then group by the capability a caller wants and name each new package so `package.Identifier` reads as a phrase: `retry.Do`, `httpx.Redact`, `clock.Now`. Reject layer names, and reject `common` with a type `Common` in it. The cycle is the interesting part: files inside one package share a scope and may reference each other freely, so mutual dependencies are invisible there; once split, Go's compiler refuses them with `import cycle not allowed`. That is a real layering defect the bucket was hiding, and the fix is structural — push the shared type down into a small package that depends on nothing, or move the function to the side that owns the data, rather than duplicating the type. Migrate one capability at a time and let the compiler enumerate every stale reference.

code

text · 5 lines
text
$ go build ./...
package example.com/app/api
	imports example.com/app/retry
	imports example.com/app/clock
	imports example.com/app/retry: import cycle not allowed

go deeper

for a junior

Focus on the hard fact underneath this: Go refuses to build two packages that import each other, and files inside one package have no such restriction. Knowing that explains most of what a split turns up.

for a middle

Be able to describe the mechanics of a split: find live symbols and their callers, group by capability, name each package for its call sites, then resolve cycles by relocating the shared type rather than duplicating it.

for a senior

Show that you read the cycle as a diagnosis. Explain what the bucket was concealing, which relocation you would choose and why, and how you sequence the migration so the build stays green at every step.

for a principal

Own the decision and its cost: who approves a new shared package, why a forwarding shim is a liability rather than kindness, and how you keep a freshly split graph from re-accreting a bucket six months later.

## The situation One package — call it `util` — is imported by every service in the repository. It holds retry logic, a time helper, a redaction function for HTTP headers, some string manipulation and a handful of constants. Nobody can name what it is for, so nobody can refuse an addition, and now it is the most-imported package you own. ## Step one: read the call sites, not the files The unit you are redesigning is the string a caller types, so gather evidence about callers before touching the directory. `go list -deps ./...` gives you the transitive package dependencies of every package in the repository, which tells you the fan-in and, crucially, what the bucket itself depends on — a bucket that imports your domain packages is already the wrong shape. A plain search for the `util.` qualifier tells you which exported symbols are actually live and which are dead weight to delete rather than relocate. ## Step two: group by capability, then name for the call site Group the surviving symbols by the capability a caller wanted when they reached for them, and give each group a package name that makes its call sites read as phrases: - a retry loop becomes `retry`, so callers read `retry.Do(ctx, fn)` - header redaction becomes something owned by the HTTP concern, so callers read `httpx.Redact(h)` - an injectable time source becomes `clock`, so callers read `clock.Now()` Two failure modes to name explicitly in review. The first is renaming rather than carving: `util` becomes `helpers` or `common`, which names a bucket just as much as before, and predictably grows a type called `Common` so that call sites read `common.Common` — the stutter and the missing subject are the same defect seen from two angles. The second is splitting by technical layer (`models`, `adapters`, `internalutil`), which reproduces the bucket in triplicate because a layer name still cannot answer whether a given function belongs. ## Step three: the import cycle, and what it is telling you This is the part that catches teams out. Within a single Go package, all files share one scope: a function in `retry.go` may call one in `timefmt.go` and vice versa, with no declarations, no ordering rules and no visible dependency. The moment those two files become two packages, the same two calls become two imports pointing at each other, and the Go compiler refuses the build outright — the message is `import cycle not allowed`, with the chain of imports printed. Go has no forward declaration and no tolerance for cyclic package imports; this is a hard rule, not a warning. The important framing for a review or a postmortem is that the split did not create the cycle. The mutual dependency was always there, hidden by the fact that both halves lived in one package. The grab bag was concealing a layering defect, and the compiler is now doing you the favour of naming it. The fixes are structural, in rough order of preference: 1. **Push the shared thing down.** If both packages need a type or a constant, put it in a small package that imports nothing from either — a leaf of the graph. Types with no behaviour make excellent leaves. 2. **Move the function to the data.** Very often one of the two halves is simply in the wrong package: the function that formats a `Job` belongs with `Job`. 3. **Invert the direction with a small parameter.** Pass a function value or a narrow interface defined by the consumer so the lower package never has to import the higher one. What you should not do is duplicate the shared type into both packages to break the cycle. Two structurally identical types are distinct types in Go, and you will spend the next year writing conversions between them. ## Step four: migrate without a flag day Move one capability at a time. The old package keeps compiling while it still has symbols; delete each symbol as its new home takes over, and let the build tell you where the stale references are — because every reference is qualified with the package name, the compiler enumerates the complete list of call sites for you, which makes the edit mechanical rather than a search-and-hope. Do not leave a forwarding shim behind "for compatibility" inside a repository you control end to end; a shim keeps the old name alive at exactly the call sites you were trying to fix, and the old name is the problem. ## How to talk about it An interviewer is listening for three things: that you design from the call site, that you understand why the cycle appeared only at the split, and that you treat the cycle as information about layering rather than an obstacle to route around. Mentioning that the deletion pass is compiler-verified — every use is qualified, so nothing hides — is the detail that shows you have actually done one.

  • Why did the import cycle appear only after the split?
    Files inside one Go package share a single scope, so two functions in different files may call each other with nothing recorded anywhere. Once they live in separate packages, those calls become imports in both directions, and Go forbids cyclic package imports outright. The dependency existed all along; only its visibility changed.
  • The team proposes naming the new packages after their layer, such as helpers and adapters. What do you push back with?
    A layer name still cannot answer "does this belong here?", so each new package becomes a smaller bucket. Name for the capability the caller wants, and judge the name by the call site it produces: `retry.Do(ctx, fn)` tells a reader what is happening, `helpers.Do(ctx, fn)` does not. If nobody can propose a real name, the grouping is not a thing yet.
  • Someone breaks the cycle by copying the shared struct into both packages. What is wrong with that?
    Two structurally identical struct types in different Go packages are still distinct types, so every boundary crossing now needs a conversion and the two definitions drift apart. Push the shared type down into a small package neither side owns, or move the function to the package that owns the data.
  • How do you keep the migration from breaking every service in one change?
    Move one capability per change. The old package stays compilable while it still holds symbols, and because every cross-package reference is qualified with the package name, the compiler lists every call site you have not updated. Delete each old symbol as its replacement lands rather than leaving a forwarding shim behind.

saying these in an interview costs you the question

  • Renames util to common and calls the split done
  • Breaks the cycle by copying the shared type into both packages
  • Splits by file size or technical layer instead of capability
  • Claims Go tolerates import cycles inside one module
  • Leaves the new packages exporting common.Common or util.Util