Go Import Cycles: The Compiler's Guide to Better Architecture
How Go's strict import rules forced us to build better software architecture - turning compilation errors into architectural guidance.
I used to curse Go's import cycle restrictions. You know the feeling - you're trying to build something logical, and the compiler hits you with:
Then I realized something: the Go compiler was trying to teach me better architecture. Those import cycles weren't arbitrary restrictions - they were early warnings that my design had fundamental problems.
The Import Cycle That Changed Everything
We were building a workflow engine that needed to integrate with multiple cloud providers. My first instinct was to create this structure:
1 | // internal/core - central workflow logic |
2 | package core |
3 | |
4 | import "github.com/us/project/internal/providers" |
Note Core imports providers package | |
5 | |
6 | type WorkflowEngine struct { |
7 | providers map[string]providers.Provider |
8 | } |
9 | |
10 | // internal/providers - provider implementations |
11 | package providers |
12 | |
13 | import "github.com/us/project/internal/core" |
Note Providers imports core package - creating a circular dependency | |
14 | |
15 | type HetznerProvider struct { |
16 | engine *core.WorkflowEngine // Need to report back to engine |
Note The provider needs a reference back to the engine, which is the root cause of our circular dependency problem | |
17 | } |
18 | |
19 | // internal/workflow - workflow definitions |
20 | package workflow |
21 | |
22 | import ( |
23 | "github.com/us/project/internal/core" |
24 | "github.com/us/project/internal/providers" |
25 | ) |
Classic import cycle. Core needs providers, providers need core, workflow needs both. The compiler said no, and I spent an hour trying to restructure the imports.
Then I stopped and asked: why does my provider need to know about the workflow engine?
The Architecture Problem
The import cycle was revealing a deeper issue. I was building tightly coupled components that all knew about each other:
- Providers needed to call back to the engine
- The engine needed to know about specific provider implementations
- Workflows needed to coordinate both
This is a classic violation of the dependency inversion principle. High-level modules (the workflow engine) should not depend on low-level modules (specific providers). Both should depend on abstractions.
The Solution: Events and Interfaces
The Go compiler forced us to think about information flow. Instead of circular dependencies, we needed unidirectional data flow with clear boundaries.
Here's what we built instead:
1 | // internal/events - shared event definitions |
2 | package events |
3 | |
4 | type Event interface { |
5 | Type() string |
6 | Payload() map[string]interface{} |
7 | } |
8 | |
9 | type StepCompleted struct { |
10 | StepID string |
11 | WorkflowID string |
12 | } |
13 | |
14 | func (s StepCompleted) Type() string { return "step.completed" } |
15 | func (s StepCompleted) Payload() map[string]interface{} { |
16 | return map[string]interface{}{ |
17 | "step_id": s.StepID, |
18 | "workflow_id": s.WorkflowID, |
19 | } |
20 | } |
Clean Architecture Emerges
With events as our communication mechanism, the architecture became much cleaner:
1 | // internal/workflow - workflow orchestration |
2 | package workflow |
3 | |
4 | import ( |
5 | "github.com/us/project/internal/events" |
6 | ) |
7 | |
8 | type WorkflowEngine struct { |
9 | providers map[string]Provider |
10 | publisher EventPublisher |
11 | } |
12 | |
13 | func (w *WorkflowEngine) ExecuteStep(ctx context.Context, step Step) error { |
14 | provider := w.providers[step.ProviderType] |
15 | |
16 | if err := provider.Execute(ctx, step); err != nil { |
17 | return err |
18 | } |
19 | |
20 | // Publish completion event |
21 | w.publisher.Publish(ctx, events.StepCompleted{ |
22 | StepID: step.ID, |
23 | WorkflowID: step.WorkflowID, |
24 | }) |
25 | |
26 | return nil |
27 | } |
Lessons Learned
Go's import cycle restrictions taught us several valuable lessons:
- Circular dependencies signal design problems - They're not just inconvenient; they're architectural code smells
- Events enable loose coupling - When components communicate through events, they don't need to know about each other
- Dependency inversion reduces coupling - Depend on interfaces, not implementations
- Compiler constraints guide good design - Sometimes the tool knows better than we do
The Result
What started as a frustrating compilation error became the foundation for a much better architecture. Our workflow engine became:
- Testable - Each component could be tested in isolation
- Extensible - New providers plugged in without changing core logic
- Maintainable - Clear boundaries made the code easier to understand
- Scalable - Event-driven architecture prepared us for microservices
Now when I see an import cycle error, I don't curse the compiler. I thank it for catching an architectural problem before it became a maintenance nightmare.
Sometimes the best teachers are the ones that refuse to let you take shortcuts.
Go detects circular dependencies at compile time - here the core package is importing itself through a chain of dependencies.