断り書き:俺はN5レベルで日本語を勉強中です。間違いがあるかもしれないけど、頑張っています!
知らないGoのリポジトリをクローンして、「この機能どこに追加するんだ」って10分悩んだことがあるなら、構成が悪いコストをもう体で分かってるはずだ。Goはレイアウトを押し付けてこない — go.dev/doc/modules/layout の公式ガイドもわざと薄い — だから自由な分だけ、プロジェクトの形はバラバラになる。いろんなコードベースを見てきて、俺は儀式っぽくならずに、プロジェクトが育っても崩れない小さな慣習のセットに落ち着いた。
構成のアドバイスなんて誰でも言える。だからこの記事はハードルを上げてある:ツールチェーンが実際に守ってるって主張には、全部go1.26.4で取った本物のエラーメッセージかテスト結果を添える。全部実際に動く — daveamit/go-architecture-demos をクローンして 01-folder-structure/demo.sh を実行すれば、この記事の証拠を全部再現できる。
短い版 #
バイナリは cmd/ に、非公開のロジックは internal/ に、意図的に共有するヘルパーは pkg/ に置く。main.go は50行以内に抑える。この3つの慣習でほとんどのプロジェクトはカバーできる — でもこれは慣習であってルールじゃないし、小さいプロジェクトにはどれも要らない。
なぜ構成が大事なのか #
ディレクトリ構造はコミュニケーションの道具だ。コードを一行も読まないうちに、「Xはどこにある?」に答えてくれる。その答えがいつも同じ場所に決まってるなら、新しい人はすぐ慣れるし、コードレビューも楽になるし、リファクタリングで変更が関係ないパッケージに散らばることも減る。目的はスペックに従うことじゃなくて、意図を一目で見えるようにすることだ。
これが何も要らない時 #
小さいバイナリ一つ、またはライブラリを作ってるだけなら、正しい構成はモジュールのルートに単一パッケージを置くことだ — go.mod の隣に main.go(またはlib名.go)を置くだけでいい。公式ガイドもそこから始まってる。下で紹介するディレクトリは、プロジェクトがそれに見合うようになってから使う:二つ目のバイナリができた、とか、他のモジュールに絶対インポートさせたくないコードができた、とか。必要になる前に足す構成はただの儀式だ。
3つのディレクトリ #
myapp/
├── cmd/
│ └── myapp/
│ └── main.go ← build deps, call run(), nothing else
├── internal/
│ ├── user/ ← domain logic, protected by the go command
│ └── transport/ ← HTTP / gRPC handlers
├── pkg/
│ └── retry/ ← genuinely reusable across repos
├── migrations/
├── Dockerfile
└── go.mod
cmd/ はバイナリごとに一つのディレクトリを持つ。それぞれの main.go はわざと薄く作る — エラーを返す run 関数に丸投げするだけだ:
func main() {
if err := run(context.Background(), os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
run はフラグを解析して、依存を組み立てて、プロセスを開始する。main と同じパッケージに置いて構わない。エラーを返す形にしておけば os.Exit はコードの中で一箇所だけになって、これがバイナリをend-to-endでテストできる理由だ。main.go が50行を超えて育ってきたら、internal/ にあるべき何かが紛れ込んでる合図だ。
internal/ は実際のロジックが住む場所で、このページで唯一、実際に強制力があるディレクトリだ。ルールはGo 1.4のリリースノートで決まっていて、ディレクトリベースで効く:internal/ 配下のパッケージは、internal/ の親をルートにしたディレクトリツリーの中からしかインポートできない。ルート直下の internal/ なら親はモジュールルートだから、モジュールの外からは一切触れない。俺の経験だと、これはGoのあまり使われていない機能の一つだと思う。遠慮なく使って、共有する具体的な理由ができるまで pkg/ には手を出さないこと。
pkg/ は一番議論を呼ぶディレクトリだから、正直に言っておく:pkg/ はツールチェーンにとって何の意味も持たない。internal/ の外にあるものは、pkg/ の下だろうとなかろうと他のモジュールからインポートできるし、公式のレイアウトドキュメントもこれを推奨してないし、tailscale・hugo・caddyみたいな有名リポジトリの多くは、代わりにモジュールルートに直接インポート可能なパッケージを置いてる。このパターンを広めた golang-standards/project-layout というリポジトリは、Russ Coxに公式に関係ないと否定された — 名前はそれっぽいけど、Goチームとは無関係だ。どっちのスタイルでも動く。俺が pkg/ を残してる理由は一つだけ:ルートが cmd/、migrations/、Dockerfile、CI設定でごちゃごちゃしてるリポジトリだと、意図的に共有してるコードが一目で分かるからだ。これはメカニズムじゃなくてシグナルだと思ってくれ。ここに何かを昇格させる前に、自分にこう聞いてみてほしい:「他のチームが今日これを本当にインポートするか?」答えが「いつかするかも」なら、internal/ に置いたままにして、本当に必要になった時に昇格させればいい。
境界が守られる瞬間を見る #
コンパイラ周りの挙動についての主張には証拠がいる。使い捨てモジュール example.com/myapp、go1.26.4。まず、別のモジュールが example.com/myapp/internal/user をインポートしようとする場合:
package example.com/othermod
main.go:6:2: use of internal package example.com/myapp/internal/user not allowed
コードが一行もコンパイルされる前に、ビルドは失敗する。誰が喋ってるか注目してほしい:このエラーは go コマンドがインポート解決してる時に出るもので、コンパイラからじゃない — コンパイラはそのコードを見ることさえない。
二つ目の証拠は意外かもしれない:このルールはモジュールベースじゃなくてディレクトリベースだから、ネストした internal/ は自分自身のモジュールの他の部分からもアクセス禁止になる。ここでは other/ と sub/internal/deep は同じモジュールに入ってる:
package example.com/myapp/other
other/other.go:3:8: use of internal package example.com/myapp/sub/internal/deep not allowed
同じモジュールなのに、やっぱりブロックされる — other/ は sub/ の中にないから、つまりその internal/ の親をルートにしたツリーの外だからだ。大きいコードベースはこれをわざと使う:ネストした 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/ をインポートできるけど、go コマンドは internal/ へのインポートを問答無用でブロックする。ただ、どの矢印が実際に強制されてるかははっきりさせておきたい:点線の矢印だけだ。internal/ の外にあるもの — pkg/、モジュールルート、他何でも — はデフォルトでインポート可能だから、公開は勝手にそうなる。守るほうこそ、自分で選ばないといけない。これが本当の非対称性で、internal/ がロジックのデフォルトの置き場所であるべき理由でもある:そこに置けば境界は保証されるし、他のどこかに置いたら公開されたも同然だ。
internal/の中身:レイヤーじゃなくてドメインでパッケージ分けする #
さっきのツリーは internal/user/ を internal/transport/ の隣に置いてたけど、この組み合わせはちゃんと説明しとく価値がある。二つの違う考え方が混ざってるからだ。user/ はドメインパッケージ — 一つの概念を丸ごと持つ:型、ビジネスルール、ユーザーのストレージアクセス全部だ。transport/ はレイヤーパッケージ — こっちは仕組みを持つ。
俺のルールはこうだ:ドメインでグループ化して、レイヤーの例外は端っこに一つだけ許す。ドメインパッケージ(user、order、billing)は一つの概念に関する全部を一箇所にまとめるから、機能の変更は一つのパッケージに収まって、インポートグラフも木構造のままになる。レイヤー優先のやり方 — handlers/、models/、services/ — は他のエコシステムのMVCの癖をそのまま持ち込んでて、Goだといつも同じ壊れ方をする:機能を一つ足すたびに三つのパッケージを触ることになって、models は腐って、誰もがインポートする型の寄せ集めになっていって、循環インポートのエラーがドメインじゃなくて設計そのものを決め始める。
transport/ が例外を許される理由は、境界に立ってるからだ:外の世界の形(HTTPリクエスト、gRPCメッセージ)をドメインパッケージへの呼び出しに変換する。全部のドメインパッケージに依存するけど、そこに依存してくるものは何もない。一方向だけの依存を持つ端のレイヤーは、絡まらない。
命名も同じ考え方から出てくる。パッケージ名は呼び出す側のコードにいつも出てくるから、短くて、小文字で、単数形のドメイン名詞にする:user.UserService じゃなくて user.Service だ — 仕組みの詳細はGoブログのpackage-names記事に書いてある。あと util、common、helpers は絶対に避ける:「これじゃないもの」っていう名前のパッケージは、居場所のないものを何でも引き寄せて、一年もすればリポジトリの半分をインポートするようになる。
テスト #
ユニットテストはテストするコードの隣に置く — user.go の隣に user_test.go。公開APIだけをテストして、内部が誤って外に漏れてないか確認したい時は package user_test(外部テストパッケージ)を使う。
統合テストはトップレベルの test/ ディレクトリに置いて、個別のパッケージじゃなく組み立て済みのシステム全体を対象にできる。でも置き場所だけじゃ何も変わらない。証拠を見せよう — test/ ディレクトリにテストを一つ置いて、普通に go test ./... を実行する:
? example.com/myapp/cmd/myapp [no test files]
? example.com/myapp/internal/user [no test files]
ok example.com/myapp/pkg/retry 0.553s
ok example.com/myapp/test 0.989s
動いてしまった。./... はモジュール内の全パッケージにマッチするから、test という名前のディレクトリだって特別扱いされない。統合テストを本当にオプトインにするのはガードだ — ファイルに //go:build integration を足せば、そのパッケージは通常の実行から丸ごと外れる:
? example.com/myapp/cmd/myapp [no test files]
? example.com/myapp/internal/user [no test files]
ok example.com/myapp/pkg/retry (cached)
…そして go test -tags integration ./... で戻ってくる。testing.Short() を -short でスキップするやり方も同じように使える。どっちか一つを選んで、CIに組み込めばいい。
同じ「証明しろ」の基準はドキュメントにも当てはまる。pkg/ の中身には Example 関数を書いてほしい。でもtestingパッケージのドキュメントにあるルールを知っておくこと:出力コメントがないExampleは、コンパイルはされるけど実行はされない。この例は今日も、君のCIでも、こっそり go test を通ってしまう:
func ExampleDo() {
retry.Do(3, func() error { return nil })
panic("this example is broken and CI will never know")
}
Exampleに // Output: <nil> を足せば、それは本物のテストになる — go test -v を実行すれば、どれが本当に役に立ってるか正確に分かる:
=== RUN ExampleDo_verified
--- PASS: ExampleDo_verified (0.00s)
PASS
ok example.com/myapp/pkg/retry 0.140s
RUN の行は一つだけなのに、Exampleは二つある。// Output: コメントがあるかないかが、検証済みのドキュメントと単なる飾りの違いになる。
複数のバイナリとモノレポ #
一つのリポジトリに複数のバイナリがあるのは全然普通のことだ。それぞれ自分の cmd/<name>/ ディレクトリを持って、internal/ は自由に共有する。複数の go.mod に手を出すのは、独立したリリースサイクルが本当に必要な時だけにする — 整理整頓のためじゃない。モジュールを分けて増える複雑さは、早い段階だとまず割に合わない。(分けた場合の地味に便利な副作用:ネストしたモジュールは親の go test ./... から見えなくなる。さっきの統合テストのガードの、もっと重い版だと思えばいい。)
散らかったコードベースの移行 #
全部を一気に構成し直そうとしないこと。一番大事なパッケージを一つ選んで、テストを書いて、internal/ に移して、CIを緑のまま保つ。それを繰り返す。構成は少しずつ良くなっていって、何もビルドできない状態には一度も陥らない。
Go特有の注意点が二つある。パッケージを internal/ の下に移すのは、モジュールの外からインポートしてる人にとっては破壊的変更だ — 移す前に外部のインポート元がないか確認すること。それと、インポートパスの書き換えは sed じゃなくて gopls のリネームを使うこと。パッケージグラフを理解してくれるし、どうせエディタに最初から入ってる。
意見としてのまとめ #
- まずはフラットに始める。 プロジェクトがディレクトリに見合うようになるまでは、モジュールルートに単一パッケージでいい。公式のレイアウトドキュメントもそう言ってる。儀式はアーキテクチャじゃない。
internal/がロジックのデフォルトの置き場所だ。 ツールチェーンが強制する唯一の境界だからで — さっきのエラーメッセージがそれを証明してる。このページの他のことは全部、コードレビューが維持する慣習にすぎない。pkg/はメカニズムじゃなくてシグナルとして使う — もしくは全然使わなくてもいい。ルート直下のパッケージも同じくらいイディオマティックだ。何かを守ってくれると思い込まないこと。internal/の中はドメインでパッケージ分けする。 端の例外はtransport/だけ。utilって打とうとしたら、手を止めろ。- テストの主張は本物にする:統合テストにはビルドタグ、Exampleには
// Output:コメント。どっちも、こっそりCIを通ってしまう壊れ方がある — それはテストがないより悪い。 main.goはだいたい15行に保つ、run()パターンで。50行は上限であって、目標じゃない。
良い構成は、うまくいってる時は目に入らない。レイアウトはシステムを説明するものであって、縛るものじゃない。
Goアーキテクチャ シリーズの次回:Goでモジュラーモノリスを設計する — モジュールの境界、プロセス内イベント、単一デプロイの中の組み立てレイヤーの話。
Goアーキテクチャ シリーズ: 1. Goプロジェクトの構成方法 · 2. Goでモジュラーモノリスを設計する