How to break up circular dependencies

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

– Rob Pike

What are circular package dependencies?

Go disallows circular import dependencies by design. So if package A imports package B, then package B cannot import package A. And if package A imports package B and package B imports package C, then package C cannot import package A or B.

In short, package dependencies must form a directed acyclic graph (DAG).

Yes, I know, A, B, C, blah blah. Here is a tangible example. Consider an e-commerce application with product, inventory, and order packages. A circular dependency can easily happen:

package product

import "example.com/project/inventory"

func (p Product) InStock() bool {
    return inventory.HasProduct(p.ID)
}
package inventory

import "example.com/project/order"

type InventoryItem struct {
    ID       int
    Quantity int
}

func (in Inventory) IsAvailable(productID int) bool {
    return in.count(productID) - order.CheckPendingOrders(productID) > 0
}
package order

import "example.com/project/product"

func (o Order) Total() (total int) {
    for _, p := range o.Products {
      total += p.Price()
    }
    return total
}

Why would Go disallow such import circles?

Because there are considerable advantages of non-circular dependency graphs:

  1. Fast compilation: The compiler can walk the dependency graph faster, and skip unmodified packages easier. Seriously, who isn't delighted by Go's compilation speed?
  2. A sane application architecture: Circular dependencies often indicate poor design or overly coupled components. By disallowing them, Go encourages developers to create cleaner, more modular architectures with clear separation of concerns. This leads to more maintainable and understandable codebases.
  3. Simplified dependency management: Without cycles, it's easier to reason about package relationships, update dependencies, and manage versioning. This simplifies both development and long-term maintenance of projects.

How to remove import cycles

Once you unintentionally have created a circular dependency, you must get rid of it. The compiler will not engage in any discussion about this.

But… how? By checking for these aspects:

1. Missing separation of concerns

Ask yourself, are all concerns correctly separated?

Every entity in an application architecture should care about a very specific set of concerns and ignore others.

Look at the product code: Should a product know if it exists in some inventory? If a product could speak, and you ask it about the inventories that list it, the product would answer, “that's none of my business.”

Move the InStock() function over to the inventory package and see the dependency cirle dissolve into little, puffy clouds:

package inventory

func (in Inventory) InStock(productID int) bool {
    for _, item := range in.items {
      if item.productID == productID {
        return true
      }
    }
    return false
}

2. (Too) close relationships

If two entities in different packages are very closely related to each other so that separation of concerns does not seem to make sense, consider lumping them together into a single package.

The e-commerce example is perhaps not the best example, but you might decide packing all inventory and product functionality into a single package, say, goods. Then you have only one dependency left, from order to goods.

3. Dependencies across application layers

Applications are often designed as layers, such as domain entities, business logic, interfaces, and external entities (storage, network, etc.). Layers should depend upon other layers in one direction only: from the bottom (the most concrete) layer to the top (the most abstract) layer.

Consider this mutual dependency between inventory (abstract) and inventory_storage:

package inventory

import "example.com/project/inventory_storage"

type Inventory struct {...} 
type Item struct {...}

func (in Inventory) StoreItem(id int) {
    inventory_storage.Store(in.item[id])
}
package inventory_storage

import "example.com/project/inventory"

func Store(item inventory.Item) {
  // store the item
}

Here package inventory calls inventory_storage.Store(), passing down an inventory.Item. Now the two packages are tightly coupled in a mutual dependency.

The easy, and preferred, remedy for this mutual dependency is dependency injection.

Preferred, because it not only removes the mutual dependency but also removes knowledge from inventory (living in an abstract layer) about inventory_storage (living in a concrete layer).

Easy, because dependency injection is ridiculously easy in Go:

Just let an interface represent the inventory storage:

package inventory

type storage interface {
  Store(Item)
}

type Inventory struct {
  ...
  s storage // interface, will hold a real storage
}

func (in Inventory) StoreItem(id int) {
    in.s.Store(item)
}

Field s of type Inventory is an interface with a Store() function. Now you only need to inject a real storage object in package main, using a suitable Inventory constructor:

package inventory

type Inventory...

func New(st storage) *Inventory {
  return &Inventory {
    s: st  // now we have a real storage in s
  }
}
package main

import "example.com/project/inventory"
import "example.com/project/inventory_storage"

func main() {
  ...
  // Do the wiring:
  is := inventory_storage.New(...)
  in := inventory.New(is) // pass a storage object
  ...
  in.Store(42)
  ...
}

Thanks to the interface abstraction, inventory does not depend on inventory_storage. Even the inventory constructor New() does not know about inventory_storage. It only expects an interface.

In package main, we can finally wire up a new inventory object with a concrete storage object. Done!

(Read more about easy dependency injection here.)

Keep your dependency graph clean

Now you know how to fix circular dependencies, all that's left is to prevent new circular dependencies from sneaking in.

A few hints in this regard:

  1. Separate concerns. Keep packages small and focused on a single concern. For example, don't let products care about inventories.
  2. Use interfaces to abstract dependencies away. For example, instead of reading from a file, read from an io.Reader and wire up the input file in package main.
  3. Use layered architectures that support only one direction of dependencies across layers.

In coding and in real life, don't go in circles.