Skip to content
intermediate Phase 7 · Packages, Modules, and Project Layout

Import Cycles and Package Design

Break circular dependencies by extracting shared types and inverting interfaces.

35m
0 problems
Topic Progress 0%

The Error That Means Your Packages Are Friends Now

You are adding a login audit trail. The audit package stamps every action with the acting user, so it imports session for the user type:

// audit/audit.go
package audit

import "example.com/app/session"

func Record(actor session.User, action string) error {
	return store.Append(actor.ID, action)
}

The session package logs each new login through the same trail, so it imports audit:

// session/session.go
package session

import "example.com/app/audit"

func Login(u User) (Token, error) {
	if err := audit.Record(u, "login"); err != nil {
		return Token{}, err
	}
	return issue(u)
}

The build stops with the shortest, strictest error Go produces:

import cycle not allowed:
	session -> audit -> session

There is no flag to silence it and no configuration to relax it. Go forbids import cycles outright, and the reason is structural: package initialization order is derived from the import graph, and a cycle leaves the runtime no point where "finished initializing" is defined. The compiler refusing the cycle is the language refusing to invent an order that does not exist.

Notice where the cycle lives. It is not between files — two files in one package reference each other freely, no matter how tangled, because they compile as a unit. The cycle is between namespaces: session cannot see inside audit without importing it, audit cannot see inside session without importing it, and each package's exported surface now demands the other's existence. The package boundary you drew does not separate two responsibilities; it cuts through one collaboration and then demands they call each other.

That framing matters because the fix is never "find a way to keep the cycle." The fix is to remove an edge — by needing less, by moving shared vocabulary to common ground, or by inverting who knows whom. Three techniques, in the order you should reach for them.

Fix One: Need Less, and the Edge Disappears

Look again at what audit actually uses from session: actor.ID, a string. It imports an entire package — and its transitive closure — to read one field. The first question when facing a cycle is not "how do I rearrange these packages" but "which of these edges is pretending to be a need?"

// audit no longer imports session
package audit

func Record(actorID, action string) error {
	return store.Append(actorID, action)
}

// session builds the value audit needs
package session

import "example.com/app/audit"

func Login(u User) (Token, error) {
	if err := audit.Record(u.ID, "login"); err != nil {
		return Token{}, err
	}
	return issue(u)
}

The cycle is gone because one edge is gone, and nothing was sacrificed: audit never needed to construct or inspect a session.User. This fix has a name worth remembering — depend on what you use, not on what owns it. A parameter of type string instead of a foreign struct, a result returned as a plain value the caller already holds, a small interface with one method instead of a concrete package. Each substitution removes a package-level dependency and shrinks the API surface at the same time.

Reach for this first because it improves the design even when there is no cycle. A package that imports half the application to do its job is hard to test and harder to move; a package that takes strings and returns structs can be exercised with nothing but a go test file.

When the caller genuinely does not have the value — when audit must materialize a user-shaped thing on its own — the need is real, and the second technique applies: move the shared vocabulary to a package that sits below both.

// principal/principal.go — the leaf both sides can import
package principal

type User struct {
	ID    string
	Email string
}

Now session imports principal to create users, audit imports principal to read them, and neither imports the other. The dependency graph becomes a diamond instead of a loop: two packages over one shared leaf. The rule of thumb is that data types shared across a layer belong below the layer, in a package with no opinions about behavior — the same instinct that keeps internal boundaries honest and the same direction the compiler wants the arrows to point.

Fix Two: Invert the Arrow with a Consumer-Owned Interface

Some cycles are not about data at all. Suppose session needs a richer audit facility — retries, sampling, a failure policy — and the right implementation really does live in audit. Extracting types does not help because the dependency is behavioral: session needs to call audit.

Go's answer is to invert who knows whom, following the proverb: define interfaces where they are consumed.

// session declares what it needs — no import of audit
package session

type AuditTrail interface {
	Record(actorID, action string) error
}

type Sessioner struct {
	trail AuditTrail // supplied from outside
}

func (s *Sessioner) Login(u User) (Token, error) {
	if err := s.trail.Record(u.ID, "login"); err != nil {
		return Token{}, err
	}
	return issue(u)
}
// audit provides the implementation — imports session? No. It never names session.
package audit

func (t *Trail) Record(actorID, action string) error { /* ... */ }
// wiring happens at the top, where both packages are already legal to import
trail := &audit.Trail{}
s := &session.Sessioner{trail: trail}

The dependency arrows now point one way: audit knows nothing about session; session names only its own interface; main (or an app package) sees both and connects them. The cycle is not hidden — it is eliminated, because no package imports its own consumer.

The same shape shows up everywhere once you look: a handler declares the UserStore it needs and the repository satisfies it silently — implicit satisfaction means database/sql's driver interfaces work this way too (Interfaces and Implicit Satisfaction). Two side effects follow that people undervalue: session can be tested with a fake trail in five lines, and swapping implementations no longer touches the package that consumes them (Test Doubles with Interfaces).

One warning: do not invert reflexively. If both packages are already one package's business, merging them is simpler than an interface between them. An interface earns its place when you need the substitution or when it is the only edge left to cut — after needing less and sharing a leaf, it is the third tool, not the first.

What the Cycle Is Telling You About the Shape

Import cycles rarely arrive as isolated mistakes. They cluster around three smells, and recognizing the smell fixes the root cause instead of the symptom.

The packages are too small. If session and audit are three files each and half of each file exists to serve the other, the boundary is churn, not structure. Files within a package cannot cycle; merging them into one package — say login — makes the collaboration internal and invisible to the compiler's import graph. Splitting is for consumers outside the unit, not for every noun in the README. When you split, split along lines someone else crosses (When a Folder Becomes a Package).

Registration is hiding in init(). The classic shape: a registry package holds a map, plugins import it to register themselves, and the registry imports the plugins to instantiate them. Cycle. The wiring does not belong in package initialization — it belongs at the top:

// main assembles; packages stay unaware of each other
registry := NewRegistry()
registry.Register("sqlite", &sqlite.Driver{})
registry.Register("postgres", &postgres.Driver{})
app := NewApp(registry)

Each driver now registers through a value it was handed or simply gets passed into Register by whoever imports both. Initialization order stops being architecture.

The cycle repeats because only internal was tried. Visibility is orthogonal to cycles: internal controls who may import a package, and the two packages in your loop are still allowed to import each other — they sit in the same subtree. The error message does not change. Only removing an edge changes it: shrink the need, move the shared type down, invert the call, or merge the units. Any "solution" that keeps both arrows is decoration.

For review, a compact checklist: does the cycle carry data or behavior? Data → a smaller parameter or a shared leaf package. Behavior → a consumer-declared interface wired from above. Both → probably one package. After the fix, confirm the arrows point the same direction across the whole repo — a graph with no cycles and a consistent flow is what lets each package be compiled, tested, and understood alone. When that graph is a set of modules you build and version together, the tooling question changes shape entirely, which is where Multi-Module Workspaces with go.work takes over.

Quiz

1. The audit package imports session for session.User, but only ever reads the ID field. session imports audit to record logins. What is the best first fix?

Question 1 options

2. session needs to call an audit implementation, and audit genuinely must stay a separate package. Where should the interface be declared?

Question 2 options

3. Two packages keep cycling because each holds one small type the other reads. What is the canonical structural fix?

Question 3 options

4. A plugin system registers drivers from init() in each driver package while the registry package imports the drivers to construct them. What makes the cycle go away?

Question 4 options

Flashcards

Question

Why does Go reject import cycles while allowing files in one package to reference each other freely?

Answer

Package initialization order is derived from the import graph, and a cycle leaves no well-defined point where initialization is finished. Files in a single package compile as one unit with no ordering question, so the restriction only applies across package boundaries.

Question

What are the three standard ways to break an import cycle, in order of preference?

Answer

First, need less: replace the foreign type with a smaller value (a string ID instead of session.User) so the edge vanishes. Second, move shared types into a leaf package both sides import, turning the cycle into a diamond. Third, invert behavior with an interface declared by the consumer and wired from main. Merge the packages when all three feel like ceremony.

Question

Why is 'define the interface in the consumer' the fix for a behavioral import cycle?

Answer

The consumer names only its own interface, so it stops importing the implementer; the implementer never names the consumer. The remaining wiring happens in a package that imports both. This also gives you a seam for a five-line fake in tests.

Question

Does placing one of the cycling packages under internal/ fix the cycle?

Answer

No. internal restricts which packages outside the subtree may import it; packages inside the same subtree can still import each other in both directions. The import graph is unchanged, so 'import cycle not allowed' is unchanged.

Question

What design smell does an import cycle most often reveal?

Answer

A boundary drawn through the middle of one collaboration — packages too small to justify their own namespace, or registration logic hidden in init(). The cycle is the compiler telling you the arrows should point one consistent direction (or that the units belong together).

Revision Notes

Key Takeaways

  • 1. The compiler rejects cycles because initialization order cannot be derived from a loop — no flag relaxes it
  • 2. Most cycles are fake needs: replacing a foreign type with a smaller value removes the edge and improves the API
  • 3. Shared data types belong in a leaf package; shared behavior gets a consumer-declared interface wired from above
  • 4. internal/ controls visibility, not topology — it never breaks a cycle
  • 5. A cycle often means the packages are too small; merging is a legitimate design fix, not a retreat

Interview Tips

  • • "How do you fix an import cycle?" — name the error, then walk the three fixes in order (need less, shared leaf, consumer interface) and say when you would just merge the packages
  • • "Where do you define an interface in Go?" — at the consumer; follow with the cycle example and the testability side effect
  • • "Why does the runtime forbid cycles?" — initialization order comes from the import graph; a loop has no defined completion point
  • • "A plugin registry cycles — what do you do?" — move registration out of init() into the composition root; mention that drivers no longer need the registry to be imported by it

Cheat Sheet

Import Cycles and Package Design

Diagnose: the error prints the loop (a -> b -> a). Ask: data or behavior crossing the boundary?

Fix decision table:

Situation Fix
Package only reads one field/type from the other Smaller parameter (pass string, not a foreign struct)
Both need the same data type Move the type to a leaf package (diamond, not loop)
One needs to call the other's behavior Interface declared by the consumer, wired in main
Packages are tiny and mutually serving Merge them — files can't cycle
Registration lives in init() Move assembly to main/app package
'Tried internal/' internal is orthogonal — only removing an edge works

Consumer-declared interface template:

// in the consumer — no import of the implementer
type AuditTrail interface {
	Record(actorID, action string) error
}

type Sessioner struct { trail AuditTrail }

// wired where both are visible
s := &session.Sessioner{trail: &audit.Trail{}}

Direction rule: arrows point down toward shared leaves and outward toward consumers — never back into a package that imports you.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading