Go doesn't like import cycles

I find it hard to tell you, ‘cause I find it hard to take
When people run in circles
It's a very, very
Mad world

–Tears for Fears, Mad World

Imagine this: You are about to build an account package and a transaction package as part of an e-commerce app. While you work out the details of these packages, you notice that both packages seem to depend on each other: An account needs to manage its transactions, and a transaction needs account details to, for example, validate funds.

Now you have an import cycle between the two packages, also known as a circular dependency.

I have an import cycle… yeah, so what?

From the perspective of code logic, an import cycle might seem entirely valid, maybe even desirable. But from a dependency management perspective, the opposite is true. It's harder for the compiler to find out what to compile, and it's harder for the developer to maintain such import cycles. In Go, cyclic imports won't even compile. Go intentionally disallow import cycles.

Citing Rob Pike:

The lack of import cycles in Go forces programmers to think more about their dependencies and keep the dependency graph clean and builds fast. Conversely, allowing cycles enables laziness, poor dependency management, and slow builds. Eventually one ends up with a single cyclical blob enclosing the entire dependency graph and forcing it into a single build object. This is very bad for build performance and dependency resolution. These blobs also take much more work to detangle than is required to keep the graph a proper DAG in the first place.

This is one area where up-front simplicity is worthwhile.

Import cycles can be convenient but their cost can be catastrophic.

Rob Pike

TL;DR: The two main problems with import cycles are:

  • Long compile times
  • Increased maintenance burden and mental load

The first one should be a no-brainer for everyone. We all love the crazy fast Go compiler, don't we?

The second one is equally important. Creating import cycles is easy if you're lazy with building your app's architecture. But you won't be able to manage those import cycles later with the same laziness used to creating them. The tech debt would hit hard.

Obviously, the better approach is to avoid import cycles, or detangle them the moment they occur.

But how?

Breaking the cycles

Here are a few approaches to fixing an import cycle:

  1. Don't let a package import itself. This one may sound trivial, but it's easy to accidentally write foo.Bar() inside package foo, and if the formatter happily auto-adds the package to the import list, you have unconsciously created a single-package import cycle. (At least, none of the Go formatters I tested auto-adds a self-import.)
  2. Use dependency injection to reverse the dependency relationship between two packages. Instead of directly accessing a package function or type, use an interface and have the constructor (the New() or NewX...() function) accept this interface. Now the clients of the package can do the wiring without causing an import cycle.
  3. Move common code to an extra package. Identify code used by all packages involved in the import cycle and move it to a new package that the existing packages can import.
  4. Merge the packages. An import cycle between two packages can be a sign that the code is tightly coupled and should better live in the same package.

The next time you encounter an import cycle not allowed error, step back and restructure. Your future self will thank you.