TL;DR
2つ以上のパッケージが直接、またはチェーンを通じて互いにインポートし合うと、Goはコンパイルできません。修正は常に構造的なものです。競合するパッケージが触れない第3のパッケージに共有コードを移動するか、パッケージが密結合すぎる場合はマージします。
エラーの見た目
$ go build ./...
package myapp/service
imports myapp/repository
imports myapp/service: import cycle not allowed
Goはインポートチェーン全体を出力するため、どこでサイクルが閉じるかを特定できます。下から上に読んでください。最後に表示されたパッケージが最初のパッケージをインポートすることで循環が完成します。
GoがImportサイクルを禁止する理由
Goは厳格な依存関係の順序でパッケージをコンパイルします。パッケージAがパッケージBを必要とし、BがAを必要とする場合、コンパイラは型チェックの段階でデッドロックに陥ります。有効なコンパイル順序が存在しないためです。
JavaやPythonのような言語はリンク時にこれを回避します。Goはそうしません。言語レベルの回避手段がない、厳格なコンパイル時エラーです。構造を修正するしかありません。
サイクルの検出
エラーメッセージにはすでにチェーンが示されています。依存グラフが複雑な大規模プロジェクトでは、いくつかの追加ツールが役立ちます:
# パッケージのすべての依存関係を表示
go list -f '{{ .Imports }}' ./internal/service
# モジュール全体のすべてのサイクルを検索
go build ./... 2>&1 | grep 'import cycle'
# 視覚的な依存関係グラフを生成(Graphvizが必要)
go install github.com/kisielk/godepgraph@latest
godepgraph -s ./... | dot -Tpng -o deps.png
よくあるシナリオと修正方法
シナリオ1:ServiceとRepositoryが互いにインポートし合う
Goのサイクルエラーの9割はこのパターンです。サービス層がリポジトリを呼び出すのは問題ありません。しかし、リポジトリがサービスパッケージ内で定義された型を参照するのは問題です。
// BAD: myapp/repository/user.go
import "myapp/service" // cycle!
func (r *UserRepo) Save(u service.User) error { ... }
修正方法: Userのような共有型を専用のmodelsまたはdomainパッケージに移動します。serviceもrepositoryも互いにインポートせず、どちらもmodelsをインポートします。
// myapp/models/user.go — 末端パッケージ、内部インポートなし
package models
type User struct {
ID int
Email string
}
// myapp/repository/user.go
import "myapp/models" // OK
func (r *UserRepo) Save(u models.User) error { ... }
// myapp/service/user.go
import (
"myapp/models" // OK
"myapp/repository" // OK
)
func CreateUser(repo *repository.UserRepo, email string) error {
u := models.User{Email: email}
return repo.Save(u)
}
シナリオ2:アプリケーションロジックに手を伸ばすユーティリティパッケージ
utilsにconfigの型を必要とするヘルパーを追加する場合があります。それ自体は問題ありません。しかしconfigが別の理由でutilsをインポートするとサイクルが発生します。
// myapp/utils/helpers.go
import "myapp/config" // configがutilsをインポートするとサイクル発生
修正方法: ユーティリティパッケージは内部インポートをゼロにすべきです。パッケージを参照する代わりに、値をパラメータとして渡します。
// BEFORE — ヘルパーがconfigを自ら取得する
func GetTimeout() time.Duration {
cfg := config.Load()
return cfg.Timeout
}
// AFTER — 呼び出し元が値を提供する
func FormatDuration(d time.Duration) string {
return d.String()
}
シナリオ3:1つにまとめるべき2つのパッケージ
サイクルが問題の根本ではなく症状に過ぎない場合もあります。packageAとpackageBが常に互いを呼び出していて、きれいな抽出ポイントがない場合、それらは同じパッケージに属しています。
# Before — 互いなしには存在できない2つのパッケージ
myapp/auth/
myapp/session/
# After — 1つの凝集したパッケージにマージ
myapp/auth/ # authとsessionの両方のロジックを含む
シナリオ4:インターフェースを使って依存関係を逆転させる
双方向通信が本当に必要な場合は、下位レベルのパッケージにインターフェースを定義します。上位レベルのパッケージがそれを実装します。共有インポートは不要で、依存関係は一方向に流れます。
// myapp/repository/notifier.go
package repository
// NotifierはServiceレイヤーによって実装されます。
// repositoryはserviceをインポートしません。
type Notifier interface {
OnUserSaved(id int)
}
type UserRepo struct {
notifier Notifier
}
func NewUserRepo(n Notifier) *UserRepo {
return &UserRepo{notifier: n}
}
func (r *UserRepo) Save(u User) error {
// ... 保存ロジック ...
r.notifier.OnUserSaved(u.ID)
return nil
}
// myapp/service/user.go — repository.Notifierを実装
package service
import "myapp/repository"
type UserService struct{}
func (s *UserService) OnUserSaved(id int) {
// 保存後の通知を処理
}
func SetupRepo(svc *UserService) *repository.UserRepo {
return repository.NewUserRepo(svc) // serviceをインターフェースとして渡す
}
サイクルを防ぐ構造的なルール
- 依存関係は内側に流れる:
cmd→service→repository→models。下位レイヤーは上位レイヤーをインポートしません。開始前にホワイトボードにこの矢印を描いておけば、違反が明らかになります。 - 共有型は末端パッケージに置く:
models、domain、またはtypes— 内部インポートがゼロのパッケージです。他のすべてのパッケージはリスクなく安全にインポートできます。 - インターフェースはコンシューマーに属する: インターフェースは実装するパッケージではなく、使用するパッケージで定義します。これはGoの慣用的なスタイルであり、サイクルが形成される前に自然に破ることができます。
internal/commonトラップに注意: 無関係なヘルパーを蓄積するcommonパッケージはサイクルを機械的に解決しますが、メンテナンスの混乱を招きます。そこに本当に属するものを意図的に選択してください。
修正の確認
# クリーンな終了、出力なし = サイクルが解消された
go build ./...
# 何も壊れていないことを確認
go test ./...
# vetで再確認
go vet ./...
# オプション:修正前後を確認するためにグラフを再生成
godepgraph -s ./... | dot -Tpng -o deps-after.png
go build ./...がコード0で終了し、何も出力されないことが確認の証拠です。出力がある場合はまだ作業が残っています。
このエラーに直面したときのクイックチェックリスト
- サイクルチェーン全体を読む — どのパッケージが問題の原因かを特定する。
- 1つの型や関数のためだけにインポートが必要な場合は、その型を共有末端パッケージに移動する。
- 2つのパッケージが常に互いを呼び出している場合は、マージする。
- インポートをローカルで定義したインターフェースに置き換えられる場合は、そうする。
- 下位レイヤーのパッケージから上位レイヤーのパッケージをインポートしない — 例外なし。

