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:

[1 highlights]
1 package command-line-flag-parsing imports github.com/you/project/internal/core
2 imports github.com/you/project/internal/workflow
3 imports github.com/you/project/internal/providers
4 imports github.com/you/project/internal/core: import cycle not allowed
Note

Go detects circular dependencies at compile time - here the core package is importing itself through a chain of dependencies.

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:

[3 highlights]
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 )
Circular Dependency Graph
Core depends on providers, providers depend on core, and workflow touches both—exactly the kind of loop the Go compiler refuses to compile.

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:

internal/events/events.go
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 }
Decoupled Workflow Architecture
The engine depends on provider interfaces and an event publisher; providers emit events instead of calling back into core, eliminating the cycle.

Clean Architecture Emerges

With events as our communication mechanism, the architecture became much cleaner:

internal/workflow/engine.go
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:

  1. Circular dependencies signal design problems - They're not just inconvenient; they're architectural code smells
  2. Events enable loose coupling - When components communicate through events, they don't need to know about each other
  3. Dependency inversion reduces coupling - Depend on interfaces, not implementations
  4. 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.