JavaRush /Courses /Go SELF /How to avoid cyclic imports

How to avoid cyclic imports

Go SELF
Level 28, Lesson 4
Available

1. Cyclic import: what it is and why it appears

A cyclic import (import cycle) is a situation where package A imports package B, and package B (directly or through a chain of other packages) imports package A. And the Go compiler reacts to this the same way I react to trying to debug without coffee: sharply and with no negotiation—the build stops.

In Go, cycles are forbidden at compile time. The reason is not only “because the language designers decided so,” but because cycles almost always mean architectural confusion: it’s unclear who is responsible for what, and where the contract boundaries are.

A minimal example that is guaranteed to blow up:

// a/a.go
package a

import "example.com/todo/b"

func A() { b.B() }
// b/b.go
package b

import "example.com/todo/a"

func B() { a.A() }

The build will end with an error like "import cycle not allowed". And here is the key point: the compiler is not complaining about a “specific line,” but about the fact that you tried to create a circular dependency in the package graph.

2. The import graph: how to think about dependencies

To stop fixing cycles “after the fire starts,” it helps to learn to see the project as a dependency graph. Nodes are packages, an arrow A → B means “A imports B”. If the arrows form a ring, the project will not compile.

This is convenient to draw. For our training application (a hypothetical todo manager), the correct picture usually looks like this:

flowchart LR
    main[main<br/>composition root] --> app[app<br/>usecases]
    main --> memstore[adapters/memstore]
    app --> domain[domain<br/>model + rules]
    memstore --> domain

The point of the diagram is simple: “inner” things (domain) do not depend on “outer” things (adapters, main). And main is the place where we wire everything together. We already did this in the DI lecture, and here we just add: if you start pulling arrows back, you will almost certainly build a cycle.

A separate remark about the import path: when you import your own packages, the path usually starts with the module path from go.mod. This is not “just a long string,” but the identifier of your module that go tool uses to resolve imports. There is even a mechanism where public domains with meta tags tell go tool where the real repository is located.

3. Where cycles come from in real projects

In reality, cycles rarely look like “package A imports package B, and B imports A”—that only happens in demos or in the “I’ll do it quick now and fix it later” state. In practice, a cycle grows out of a small “convenient” decision that feels harmless but breaks the dependency direction.

This usually happens in three storylines.

First storyline—the use-case layer (app) suddenly starts creating a concrete adapter implementation inside itself. For example, you decide: “Why do I need DI? I’ll just call memstore.New() right in app—it’s faster.” But if the adapter imports app (for example, to do a compile-time interface check), the loop closes.

Second storyline—you put “shared types” into a package that has the wrong responsibility. For example, DTO/constants/errors are declared in adapters, but are needed by app and domain. Then domain starts importing the adapter (which is already suspicious), while the adapter imports domain (which is natural), and that’s it—a ring.

Third storyline—you try to “make it pretty” and create a utils/common/shared package where you start throwing absolutely everything. At first it feels like you are saving the project from copy-paste. Then it turns out that common imports half the project, and half the project imports common. At that point common turns from “common” into “common pain.”

4. Dependency rules that prevent cycles

Now you will get a set of rules that sound almost like “traffic rules.” They don’t guarantee 100% accident-free driving, but without them you definitely won’t get far.

Domain does not import app and adapters

Domain is the “problem domain”: data + rules. It should not know how you store tasks (in memory, a file, a DB), and it should not know how you run use cases. Otherwise you get an inverted architecture: the inside knows about the outside.

A correct domain is something like:

package domain

import "errors"

var ErrEmptyTitle = errors.New("empty title")

type Task struct {
	ID    int
	Title string
	Done  bool
}

func NewTask(id int, title string) (Task, error) {
	if title == "" {
		return Task{}, ErrEmptyTitle
	}
	return Task{ID: id, Title: title}, nil
}

This code is “self-sufficient”: it asks for no memstore, no cli, no http. It can live in a vacuum or on Mars—the only requirement is that Go exists there.

The interface is declared by the consumer, not the implementation

If app (use cases) wants a “task store,” it declares the TaskStore interface in its own package, because app is the dependency consumer, and only it knows which methods it actually needs.

package app

import (
	"context"

	"example.com/todo/domain"
)

type TaskStore interface {
	NextID(ctx context.Context) (int, error)
	Save(ctx context.Context, t domain.Task) error
}

Then an adapter (for example, memstore) implements these methods. And here is the key: the adapter does not have to import app to “implement the interface.” In Go, implementation is implicit: if the methods match, it fits.

When you declare an interface in the implementation package, you often force the consumer to import the implementation just to get the interface. That is already a path to cycles and tight coupling.

The composition root belongs in main, not in business logic

If you create memstore.New() inside app, you make app depend on a specific implementation. That is almost always an architectural mistake in our layered style.

Correct wiring:

package main

import (
	"context"
	"fmt"

	"example.com/todo/adapters/memstore"
	"example.com/todo/app"
)

func main() {
	store := memstore.New()
	svc := app.New(store)

	t, err := svc.Add(context.Background(), "read about import cycles")
	if err != nil {
		fmt.Println("error:", err)
		return
	}

	fmt.Println("added:", t.Title) // added: read about import cycles
}

main imports everyone, because it’s the “outer layer” and it is allowed to. But app must not import memstore—otherwise main will stop being the single composition root, and dependencies will start spreading.

A utility package is not a dumping ground for everything

Sometimes you really want to create a common package and put there: Task, ErrNotFound, ParseID, Config, Store, Service, Logger, Formatter, “and this little thing too, it’s shared.”

The problem is that a “shared package” almost always starts importing concrete project packages, and concrete packages start importing it. As a result, it becomes the center of the spider web—and the center of the spider web is great at closing cycles.

If something is truly shared, it should either live in the package that owns the responsibility (for example, domain.ErrNotFound), or be a small, autonomous package with no dependencies “down into the project.”

5. Practice: how to accidentally create a cycle in a todo app

Let’s model a typical “accidental cycle.”

Imagine we have app/service.go, and we decide to “make life easier”: let app.New() create memstore itself so that main looks clean. It will look like this:

package app

import "example.com/todo/adapters/memstore"

type Service struct {
	store *memstore.Store
}

func New() *Service {
	return &Service{store: memstore.New()}
}

Now imagine that in memstore you want to add a compile-time check that your Store implements the app.TaskStore interface (a useful technique, but optional):

package memstore

import "example.com/todo/app"

var _ app.TaskStore = (*Store)(nil)

And there it is, the cycle: app imports memstore, and memstore imports app.

The tricky part is that both pieces of code look “reasonable” in isolation. One says, “I’m hiding details inside the constructor.” The other says, “I want to verify that I implement the interface.” But together they break the basic rule of dependency direction.

The fix here is not a “clever hack,” but returning to the correct architecture: app accepts the TaskStore interface again, and memstore either does not import app at all, or does the check carefully (and definitely does not force app to import memstore).

6. How to cut an import cycle

When the project no longer builds, you want a fast recipe like “move a file over there.” Sometimes it works, but it’s better to act systematically: treat the cycle as a symptom and understand which responsibility boundary was violated.

Below are the most effective techniques. I will explain them as a “thought process,” because in real life you almost always combine several.

Move the contract up, and the implementation down

If app imports adapters, most often it means that app pulled in an implementation instead of a contract. The cure is: keep the interface in app, and create the concrete implementation in main and pass it inside.

Use-case layer service:

package app

type Service struct {
	store TaskStore
}

func New(store TaskStore) *Service {
	return &Service{store: store}
}

And main becomes the composition root:

package main

import (
	"example.com/todo/adapters/memstore"
	"example.com/todo/app"
)

func buildApp() *app.Service {
	store := memstore.New()
	return app.New(store)
}

This “reverses” the dependency and almost always removes the cycle.

Move a type or an error into the package that owns it

A very common cause of cycles is that a type lives “in the wrong place.” For example, Task is in memstore, but everyone needs it. Then app imports memstore just to use Task, while memstore imports domain or app for logic—and you get a loop.

Better: Task should live in domain. And semantic errors (for example, “not found”) often should also live in domain, so that use cases can branch on meaning rather than on strings.

By the way, the idea that “an error contains another error, adding context” is a normal and very common practice in Go: the upper layer adds information, but preserves the cause so that it can be recognized.

And that is exactly why it’s convenient to keep errors in the “right” packages: otherwise you will have to import “foreign” layers for errors.Is, and you will again risk building a cycle.

Separate public API from details via a subpackage or internal

Sometimes a cycle appears like this: you wanted to reuse a small helper and ended up importing the “insides” of a package. Today we already discussed internal/ as a boundary amplifier, but even without it a simple idea helps: a package has a “face” and “guts.”

If some code is only needed inside the app layer, you can move it into app/internal/... so other layers don’t “accidentally latch onto” it. Then even if a developer tries to import the internal package from the wrong place, Go won’t allow it. And this is not “a restriction for the sake of restriction,” but architecture insurance.

Make dependencies one-way and visually verify them

A useful trick: as soon as you see a cycle, don’t try to “break” it technically right away. First try to draw (at least in your head) the arrows: who should know whom.

If you see that domain ended up somewhere “outside,” and adapters ended up “inside,” then you mixed up the roles. If app imports adapters, then the use case started knowing about an implementation. If adapters imports cli or http, then the adapter knows about external I/O. Each such arrow almost always means: “someone took a responsibility that isn’t theirs.”

Cheat sheet: symptom → cause → remedy

Sometimes it’s easier to consult a short table than to reinvent architecture every time. Below are the most common cases.

Symptom (what you see) Common cause (why it happened) What to usually do
app imports adapters/... The use case creates or uses a concrete implementation Move creation into main; keep only the interface in app
domain imports app Use-case logic or app-layer errors leaked into the domain Move rules/errors into domain, and keep orchestration in app
Package common imports half the project A “dumping ground of shared things” became the dependency center Distribute the “shared” things into their responsible packages; keep common empty or very small
The cycle appeared “because of a single interface check” The adapter imports app for var _ app.IFace = ..., and app imports the adapter Remove the adapter import from app; keep the compile-time check, but keep wiring in main

7. Typical mistakes

Mistake #1: “For convenience, let’s call memstore.New() right in app.”
At the line-of-code level, this looks like an innocent optimization, but at the architecture level you break the principle “a use case depends on a contract, not an implementation.” Very often the next step is that the adapter will also need something from app (an interface, an error, a helper type), and the cycle is ready.

Mistake #2: “Interfaces should live next to the implementation; that’s more logical.”
This is a common habit from other languages, but in Go it often leads to the consumer being forced to import the implementation package just to get the interface. The result is that dependencies start flowing “backward.” It is much more robust to declare the interface where it is used: the contract stays minimal and doesn’t pull in extra baggage.

Mistake #3: “I’ll move everything shared into utils/common/shared; we’ll sort it out there.”
On the first three files, things really do get easier. On the thirtieth, you suddenly discover that this package became the “center of the world,” and every package depends on it, and it depends on every package. After that, cycles start appearing without your involvement—like weeds after rain.

Mistake #4: “I’ll break the cycle by copy-pasting a type or an error into another package.”
Copying sometimes “fixes” the build, but breaks meaning: you end up with two ErrNotFound errors in different packages, or two almost identical Task types, and the code starts behaving strangely (for example, errors.Is won’t work because it’s a different error variable). It’s much better to honestly decide once: where is the responsibility for this type/error, and move it there.

Mistake #5: “I’ll compare errors by string—no imports needed.”
This looks like a clever way to “cut the dependency,” but in reality you turn the contract into a fragile string. In Go, the whole idea of errors as values (including wrapped errors) exists so you can check meaning, not text.

1
Task
Go SELF, level 28, lesson 4
Locked
One-way imports
One-way imports
1
Task
Go SELF, level 28, lesson 4
Locked
Ticket Number Generator
Ticket Number Generator
1
Task
Go SELF, level 28, lesson 4
Locked
Breaking the dependency
Breaking the dependency
1
Task
Go SELF, level 28, lesson 4
Locked
Architectural boundary
Architectural boundary
1
Survey/quiz
Package architecture, 28 level, 4 lesson
Unavailable
Package architecture
Package architecture
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION