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:
- 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?
- 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.
- 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:
- Separate concerns. Keep packages small and focused on a single concern. For example, don't let products care about inventories.
- 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 packagemain
. - Use layered architectures that support only one direction of dependencies across layers.
In coding and in real life, don't go in circles.