断り書き:俺はN5レベルで日本語を勉強中です。間違いがあるかもしれないけど、頑張っています!
知らないGoのリポジトリをクローンして、機能をどこに追加するか10分悩んだことがあるなら、悪い構成のコストを感じたことがあるだろう。Goはレイアウトを決めていない — それは意図的な選択だ。でも、その自由のせいでプロジェクトの形がバラバラになる。たくさんのコードベースで仕事した後、俺はシンプルで予測しやすい慣習にたどり着いた。
短い版 #
バイナリは cmd/ に、プライベートなロジックは internal/ に、公開したいヘルパーは pkg/ に置く。main.go は50行以下にする。他は全部この3つのルールから来る。
なぜ構成が大事か #
ディレクトリの構造はコミュニケーションのツールだ。コードを読む前に、「Xはどこにある?」に答えてくれる。その答えがいつも同じなら、新しい人が早く分かるし、コードレビューも簡単になるし、リファクタリングで変更があちこちに散らばらない。目的はスペックに従うことじゃない — 意図を一目で見えるようにすることだ。
3つのディレクトリ #
myapp/
├── cmd/
│ └── myapp/
│ └── main.go ← 依存を作って、Run()を呼ぶだけ
├── internal/
│ ├── user/ ← ドメインロジック、コンパイラが守る
│ └── transport/ ← HTTP / gRPC ハンドラ
├── pkg/
│ ├── retry/ ← 本当に他のリポジトリで使えるもの
│ └── config/
├── migrations/
├── Dockerfile
└── go.mod
cmd/ はバイナリごとに一つのディレクトリを持つ。各 main.go は意図的に薄い:フラグを読んで、依存を作って、プロセスを開始する。50行より長くなったら、internal/ にあるべきものが漏れている。
internal/ は本当のロジックがある場所だ。Goはこれをコンパイル時に守る — モジュールの外のコードは internal/ をインポートできない。Goの一番使われていない機能だと思う。自由に使って、具体的な理由があるまで pkg/ にしないこと。
pkg/ は意図的な決定であって、ゴミ箱じゃない。ここに移す前に聞いてみて:「他のチームが今日これをインポートする?」答えが「いつかね」なら、internal/ に置いて、本当に必要になった時に移す。
依存の流れ #
graph LR cmd["cmd/"] -->|wires| int["internal/"] int -->|uses| pkg["pkg/"] ext["other repos"] -.->|blocked| int ext -->|allowed| pkg
cmd/ は internal/ に依存する。internal/ は pkg/ を使える。外のリポジトリは pkg/ にアクセスできるけど、コンパイラが internal/ へのアクセスをブロックする。この非対称性がポイントだ:ビジネスロジックはデフォルトで守られて、pkg/ で公開するものは明確で意図的な選択になる。
テスト #
ユニットテストはテストするコードの隣に置く — user.go の隣に user_test.go。公開APIだけをテストしたい時は package user_test(外部テストパッケージ)を使う。
統合テストはトップレベルの test/ ディレクトリに置いて、個別のパッケージじゃなく全体のシステムをテストする。go test ./... が速く動くように分けておく。
pkg/ のものには Example 関数を書く — go test の一部として動いて、いつも最新のドキュメントになる。
複数のバイナリとモノレポ #
一つのリポジトリに複数のバイナリがあるのは普通だ。それぞれが cmd/<name>/ を持って、internal/ を共有する。独立したリリースサイクルが必要な時だけ複数の go.mod を使う。整理のためだけにモジュールを分けても、増える複雑さに見合わない。
散らかったコードベースの移行 #
一回で全部を変えないこと。一番大事なパッケージを選んで、テストを書いて、internal/ に移して、CIを緑に保つ。繰り返す。構成は少しずつ良くなって、ビルドが壊れる状態にならない。
おわりに #
良い構成は、うまくいっている時は見えない。cmd/、internal/、pkg/ を意図的に使って、エントリーポイントを薄くして、Goのコンパイラに境界を守らせる。レイアウトはシステムを説明するべきで、制限するべきじゃない。