skip to content

How do you break a Go import cycle that the compiler rejects between two packages?

level: seniorimportance: must knowfreq 66%

answer

  1. the package graph must be a DAG
  2. the error prints the whole chain
  3. interfaces are satisfied implicitly
  4. declare it where it is consumed

basics

~20 s

Go requires an acyclic package graph and fails the build with import cycle not allowed. Break it by declaring the interface you need in the consuming package, by lifting the shared types into a third package both import, or by merging two packages that were split in the wrong place.

solid answer

~50 s

The compiler prints `import cycle not allowed` along with the whole chain, because Go compiles each package once into a single object and initialises packages in dependency order — both require a DAG, so there is no lazy escape hatch. The idiomatic break uses implicit interface satisfaction: the package that needs the behaviour declares a small interface of its own and takes it as a parameter, and the implementing package satisfies it without importing anything new, so one edge simply disappears. The alternatives are to lift the shared types into a third, dependency-free package that both sides import, to move the function to the side that already owns the data, or to merge the two packages when the cycle is telling you they are really one concept. Treat a cycle as a design signal about where the boundary was drawn, not as an obstacle to route around.

code

go · 16 lines
go
// package order
type Notifier interface {
	Notify(orderID string) error
}

func Place(id string, n Notifier) error {
	// persist the row, then:
	return n.Notify(id)
}

// package mail — no import of the order package
type Sender struct{}

func (Sender) Notify(orderID string) error {
	return nil
}

go deeper

for a junior

Remember that Go refuses to build circular imports outright, with the message import cycle not allowed, and that a common first move is pulling the shared type into its own package.

for a middle

Explain why the graph must be acyclic: each package compiles once as a unit and packages initialise in dependency order. Be clear that the rule holds between packages, never between files of one package.

for a senior

Demonstrate the interface break concretely — the consumer declares the small interface, implicit satisfaction removes the edge, the provider changes nothing — and read the cycle as a boundary drawn in the wrong place.

for a principal

Decide when the right answer is to merge packages rather than add abstraction. Every interface introduced only to break a cycle is indirection that the next reader pays for without any design being clarified.

## The rule If package `a` imports `b` and `b` imports `a`, the build fails: import cycle not allowed and the toolchain prints the full chain, which matters because real cycles are usually indirect: `a` imports `b`, `b` imports `c`, `c` imports `a`. A package also cannot import itself. There is no flag, no forward declaration and no lazy-resolution mode. One clarification that trips people up: **the rule is between packages, never between files**. Two files in the same package may reference each other's declarations freely, in any order, with no imports involved. Splitting code into more files is free; splitting it into more packages is what creates edges. ## Why Go insists on a DAG Two parts of the model depend on it. First, **compilation**: Go compiles a package once, as a unit, into a single object file, and it needs its dependencies already compiled to do so. A cycle has no valid compilation order. This is a large part of why Go builds are fast — there is never a fixed-point iteration or a partial-type dance. Second, **initialisation**: package-level variables and initialisation functions run in dependency order, each imported package fully initialised before the package that imports it. A cycle has no valid order there either, and languages that permit cycles pay for it with half-initialised modules that behave differently depending on which file was loaded first. So the restriction is not pedantry; it buys a build model and an initialisation model with no undefined corners. ## Break one: the interface on the consumer's side This is the move that is specific to Go and the one an interviewer wants to hear. Go interfaces are satisfied **implicitly**. A type implements an interface by having the right methods; it never declares that it does, and it never imports the package where the interface lives. So an interface can be declared by whichever package *needs* the behaviour rather than by the package that *provides* it. Suppose `order` wants to send a notification and therefore imports `mail`, while `mail` needs an order's fields and therefore imports `order`. Instead, `order` declares a one-method interface describing what it needs, takes it as a parameter, and imports nothing new. `mail` supplies a type with that method and never mentions `order`. The edge from `mail` to `order` is gone, and the cycle with it — with no change to how either package is used at the call site that wires them together, typically the main package. The cost is a small amount of indirection. Keep the interface tiny and define it where it is consumed, which is idiomatic Go regardless of cycles. ## Break two: extract a third package When the cycle exists because both sides need the same **types** — a domain entity, an identifier, an error value — move those types into a third package that imports neither side. Both packages import it, and the edge between them disappears. This works cleanly when the extracted package is genuinely a leaf with no dependencies of its own. It fails, or merely relocates the cycle, when people use it as a dumping ground for anything two packages happen to share: such a package accumulates dependencies, eventually needs to import one of its own consumers, and the cycle returns in a less obvious form. ## Break three: move the code Often the cycle is a straightforward misplacement. A function sits in the package that does not own the data it manipulates, so it has to import the one that does, while that one already imports it for something else. Moving the function to the side that owns the data removes an edge and usually reads better afterwards. ## Break four: merge If two packages cannot be described without each other — every change to one changes the other, and their types are mutually recursive — the honest conclusion is that they are one package that was split too early. Merging them is not a defeat; Go packages are meant to be units of *meaning* rather than units of file organisation, and a cycle is a strong signal that a boundary was drawn where there is no seam. ## Reading the cycle as a design signal Before choosing a technique, ask which direction the dependency *should* run. Usually one of the two packages is more general or more stable, and it should not know about the other at all. The cycle appeared because a specific concern leaked into the general package. The interface break formalises that answer; extraction and merging are the other two shapes it can take. What to avoid is adding an interface purely to placate the compiler, without deciding which side is the consumer. That leaves indirection every future reader must trace with no design gained.

  • Why can two files in the same package refer to each other freely?
    Because the compiler treats a package as one compilation unit: file order and declaration order inside it are irrelevant, and no imports are involved between files of the same package. The acyclicity requirement applies to the import graph between packages, which is what the compiler must order to compile and initialise.
  • Does extracting a shared package always work?
    It works when the cycle is caused by shared types: move them into a package that imports neither side and both can depend on it. It does not work when the cycle is caused by shared behaviour, where the consumer-side interface or a merge is the honest fix. A catch-all shared package tends to grow dependencies until it imports one of its own consumers and the cycle returns.
  • How do you decide which of the two packages should keep the dependency?
    Ask which one is more general and more stable. The general package should not know about the specific one, so the edge should run from specific to general. If the cycle exists because a specific concern leaked into the general package, the fix is to express that concern as an interface the general package declares and the specific one satisfies.

Two teams each waiting for the other's sign-off ship nothing. Progress starts when one of them writes down what it needs instead of asking the other to hand it over.

saying these in an interview costs you the question

  • Says Go resolves cycles lazily like some other languages
  • Proposes a catch-all shared package to absorb both sides
  • Thinks two files in one package can form a cycle
  • Believes an alias or a build tag can silence the error