Working Around Import Cycles in Go
If you've worked in a Go codebase long enough, you've probably run into this error:
import cycle not allowed
You've probably learned the hard way that if you have a package foo that imports bar:
package foo
import "bar"
You would not also be able to import bar from foo.
package bar
import "foo" // import cycle not allowed
I want to show you a pragmatic way we solved this problem without needing a dramatic change to the package structure.
How we're dealing with import cycles in our legacy code
Today, we're migrating our usages of our LegacyCustomer to a new Customer component.
package legacycustomer
type LegacyCustomerService struct {
// ...
}
func NewLegacyCustomerService() *LegacyCustomerService {
// returns a service that's used to create legacy customers
}
The newer Customer component depends on LegacyCustomer.
package customer
import "legacycustomer"
type CustomerService struct {
LegacyCustomerService *legacycustomer.LegacyCustomerService
}
func NewCustomerService(svc *legacycustomer.LegacyCustomerService) *CustomerService {
// returns a service that depends on LegacyCustomerService
// and is used to create customers in the new world.
}
We instantiate these services in the process that bootstraps the server.
package main
import (
"legacycustomer"
"customer"
)
func main() {
legacySvc := legacycustomer.NewLegacyCustomerService()
custSvc := customer.NewCustomerService(legacySvc)
// and so on...
}
To facilitate a migration, we need to dual-write Customers from LegacyCustomerService.
We want to do something like the following code snippet, but this won't work.
package legacycustomer
import "customer" // import cycle!
type LegacyCustomerService struct {
CustomerService *customer.CustomerService
}
func NewLegacyCustomerService(svc *customer.CustomerService) *LegacyCustomerService {
// returns a service that's used to create legacy customers
// but will also start dual-writing new customers.
}
The customer package already imported legacycustomer,
so we can't import legacycustomer from customer.
We'll use dependency injection to avoid the need for a cyclic import. Here's how:
- Create an interface that
legacycustomerwill import. - Have
customerimplement that interface. - Let
legacycustomerhave a dependency on the interface, not theCustomerServicedirectly.
First, let's create the interface:
package api
import "legacycustomer"
type CustomerAPI interface {
CreateCustomerFromLegacyCustomer(c *legacycustomer.LegacyCustomer) error
}
Now, we'll let CustomerService implement CustomerAPI.
Unlike some languages like Java,
we don't have to state the fact that CustomerService implements CustomerAPI.
Go supports duck typing. So if it looks like a duck, and it quacks like a duck, then it must be a duck.
package customer
import "legacycustomer"
// Notice we say nothing about `CustomerAPI`, but we implement it nonetheless.
func (svc *CustomerService) CreateCustomerFromLegacyCustomer(c *legacycustomer.LegacyCustomer) error {
// ...
}
Now we can update legacycustomer to use the interface:
package legacycustomer
import "api"
type LegacyCustomerService struct {
CustomerAPI api.CustomerAPI
}
// ...
And when we start the server, we can pass the implementation of the interface (i.e. CustomerService) to legacycustomer:
package main
import (
"legacycustomer"
"customer"
)
func main() {
legacySvc := legacycustomer.NewLegacyCustomerService()
custSvc := customer.NewCustomerService(legacySvc)
// this is the line that does the depenency injection.
// since `CustomerService` implements `CustomerAPI`, this is legal.
legacySvc.CustomerAPI = custSvc
// and so on...
}
Voilà. You now have a technique that leverages dependency injection and duck typing to help you work around cyclic imports in Go.