はじめに
この記事では、Go のパッケージシステムの仕様とその設計思想を、Java と比較しながら整理します。
Java から Go に移ってきた人、あるいはマイクロサービス構成で internal の制約に詰まった経験がある人を対象にします。仕様の暗記ではなく、なぜそう設計されているのかを理解することを目的にします。
Go のパッケージと Java のパッケージの根本的な違い
Java のパッケージは名前空間の整理が中心ですが、package-private によるアクセス制御の単位としても機能します。com.example.service のようにドット区切りで階層を表現し、一般にはディレクトリ構造と対応します。アクセス制御はクラスやメンバーの public / protected / private / package-private によって決まります。
Go のパッケージはアクセス制御の単位そのものです。識別子の先頭が大文字かどうかでエクスポートされるかが決まり、パッケージ外からアクセスできるかどうかはこれだけで決定します。
// エクスポートされる(パッケージ外から参照できる)
func Validate(input string) error { ... }
// エクスポートされない(パッケージ内にしか見えない)
func validate(input string) error { ... }
Java のように public class や private class をクラス単位で制御するのではなく、識別子の命名規則だけで制御する設計です。シンプルで明快ですが、Java のような細かい制御はできません。
internal パッケージはなぜ存在するのか
Go には大文字・小文字によるエクスポート制御しかないため、「同じリポジトリ内の特定のコードにだけ公開し、外部には公開しない」という制御ができません。
これを解決するのが internal パッケージです。
internal ディレクトリ配下のコードは、その internal を含む親ディレクトリ配下のコードからしか import できません。
myapp/
internal/
auth/
token.go ← myapp 配下からしか import できない
api/
handler.go ← internal/auth を import できる
other/
main.go ← internal/auth を import できない(コンパイルエラー)
Java で言えば、package-private に近い概念ですが、ディレクトリ境界で制御するという点が異なります。Java の package-private はパッケージ名が一致すれば同じ制御になりますが、Go の internal はファイルシステムのパスで制御します。
なぜこの仕様になっているのか
Go の設計思想の根幹に「明示的であること」があります。何がどこから見えるかをファイルシステムの構造から判断できるようにしたい、という意図があります。
Java のように protected や package-private を組み合わせた細かいアクセス制御は、コードを読む人が都度確認しなければならない複雑さを生みます。Go はその複雑さを排除し、「internal の中にあるかどうか」という単純な判断基準に絞っています。
モジュールシステムと internal の関係
Go 1.11 以降、モジュールシステム(go.mod)が導入されました。これにより、依存関係の管理やバージョン管理の単位としてモジュールを扱うようになりました。
ただし、パッケージを import する単位そのものがモジュールになったわけではありません。Go のコードは今もパッケージ単位で構成されます。
internal の制約は、モジュール境界そのもので決まるわけではありません。internal を含む親ディレクトリ配下のコードだけが、その internal 配下のパッケージを import できます。
モジュール A: github.com/org/service-a
internal/
logic/
validation.go ← モジュール A の内部からしか使えない
モジュール B: github.com/org/service-b
main.go ← github.com/org/service-a/internal/logic は import 不可
通常の独立したサービス構成では、この条件を満たさないため別サービスから internal を import できません。
一方で、別モジュールであってもディレクトリ構造と import パスの条件を満たしていれば import できる場合はあります。重要なのは「別モジュールだから不可」ではなく、「internal の親ディレクトリ配下かどうか」で決まる点です。
Java でこれに相当する仕組みは、Java 9 で導入されたモジュールシステム(JPMS)です。module-info.java で exports するパッケージを明示的に宣言し、宣言していないパッケージは外部から参照できません。
// module-info.java
module com.example.serviceA {
exports com.example.serviceA.api; // ここだけ公開
// com.example.serviceA.internal は exports しない → 外部から参照不可
}
Go の internal はディレクトリ名という暗黙の規約で制御するのに対し、Java のモジュールシステムは module-info.java という明示的な宣言で制御します。どちらが良いかはトレードオフですが、Go の方が設定ファイルを増やさずに済む分、小さいプロジェクトには馴染みやすいです。
モノレポ・ポリレポとモジュール構成の関係
Go のパッケージ設計を理解する上で、リポジトリ構成の違いを整理しておくと判断しやすくなります。
モノレポ(monorepo)
複数のサービスやモジュールを1つのリポジトリで管理する構成です。
myorg/ ← 1つのリポジトリ
service-a/
go.mod
service-b/
go.mod
shared/
go.mod
共通ロジックを shared/ に置いて各サービスから参照できます。go.work を使えば replace ディレクティブなしでローカル参照できます。CI でのビルド範囲の制御は必要ですが、コードの見通しが良く、共通化のコストが低いです。
ポリレポ(polyrepo)
サービスごとにリポジトリを分ける構成です。
myorg/service-a/ ← リポジトリ A
go.mod
myorg/service-b/ ← リポジトリ B
go.mod
myorg/shared/ ← リポジトリ C
go.mod: github.com/myorg/shared
共通ロジックを使うには shared を独立モジュールとして公開し、各サービスの go.mod でバージョン指定して依存します。リポジトリが独立しているため、チームやリリースサイクルが完全に分離されます。ただし共通ロジックの修正には shared のバージョンアップと各サービス側の依存更新が必要になります。
Java であれば Maven Central や社内 Nexus に JAR を公開して依存管理するパターンが相当します。Go でも同様に、shared モジュールをタグ付きでリリースして各サービスが go get で更新する運用になります。
シングルモジュール構成
小規模なサービスや初期段階では、リポジトリ全体を1つの go.mod で管理するシングルモジュール構成が最もシンプルです。
myapp/ ← 1つのリポジトリ、1つの go.mod
go.mod
internal/
auth/
logic/
cmd/
api/
main.go
batch/
main.go
デプロイ単位が複数あっても cmd/ 以下に分けるだけで、モジュールは1つのままです。共通ロジックは internal/ に置けばどこからでも使えます。internal の制約が問題になるのは、この構成を超えてモジュールを分割し始めたときです。
どの構成を選ぶか
判断軸は次のとおりです。
- チームが1つで小規模: シングルモジュール構成から始める
- チームが複数でも同一リポジトリで管理できる: モノレポ +
go.work - チームやリリースサイクルを完全に分離したい: ポリレポ + 独立モジュール
共通ロジックの共有コストはシングルモジュール < モノレポ < ポリレポの順に上がります。分割の必要性が明確になってから構成を移行する方が、先に構成だけ決めるより現実的です。
デプロイ単位を分けると internal 制約が問題になる理由
サービスをデプロイ単位ごとに分けると、コード境界とデプロイ境界が一致します。
service-a/ ← モジュール github.com/org/service-a
internal/
logic/
validation.go
service-b/ ← モジュール github.com/org/service-b
internal/
logic/
validation.go ← service-a の validation.go をコピーして持っている
service-a 配下の internal は service-b から import できないため、共通化したいロジックをどこに置くかを事前に設計しておかないと、コピーが生まれます。
実際にこの問題が顕在化するのは、コピーが増えてからです。最初は service-a だけにあったバリデーションロジックが、service-b・service-c にも広がっていきます。その後、仕様変更でバリデーション条件が変わったとき、全サービスを修正しなければならないことに気づきます。1箇所直せば済むはずの修正が3サービスのリリースに膨らむ、というのが典型的な詰まり方です。
Java であれば、共通ライブラリを JAR として切り出して Maven や Gradle で依存管理するパターンが定着しています。Go でも同様に独立モジュールとして切り出すことはできますが、バージョン管理のコストが発生します。モノレポ構成であれば internal の外に共有パッケージを置くだけで済みます。
shared/
validation/
validation.go ← internal の外なので、どのサービスからも import できる
service-a/
handler.go
service-b/
handler.go
replace ディレクティブによるローカル参照
モノレポで複数モジュールを扱うとき、go.work が導入される前は replace ディレクティブが使われていました。現在でも go.work を使わない構成や、特定のモジュールだけ差し替えたい場面で登場します。
replace は go.mod に記述し、特定のモジュールパスを別のパス(ローカルディレクトリや別リポジトリ)に差し替えます。
// service-a/go.mod
module github.com/org/service-a
require github.com/org/shared v1.0.0
replace github.com/org/shared => ../shared
この設定により、github.com/org/shared を import したとき、リモートのモジュールではなく ../shared のローカルディレクトリが使われます。
ただし replace には次の問題があります。
-
go.modに書くため、コミットすると他の開発者やCIにも影響が出る - 複数モジュールを扱う場合、それぞれの
go.modにreplaceを書く必要がある -
replaceを書き忘れると、意図しないバージョンのモジュールが使われる
この煩雑さを解消するために go.work が導入されました。
go.work によるモノレポ管理
Go 1.18 以降、ワークスペース機能(go.work)が導入されました。モノレポで複数モジュールを扱うときに、replace ディレクティブを各 go.mod に書かずに済む仕組みです。
go.work
service-a/
go.mod
service-b/
go.mod
shared/
go.mod
go.work に使うモジュールを列挙するだけで、ローカルのモジュールを優先して参照できます。
// go.work
go 1.22
use (
./service-a
./service-b
./shared
)
この状態では service-a から shared を import するとき、replace ディレクティブなしでローカルの shared が使われます。go.work はコミットせずローカル開発専用で使う運用がよくあります。一方で、複数モジュールを常に一緒に開発するリポジトリではコミットする判断もありえます。CI では go.work に依存しすぎず、各モジュールを独立してビルドできる状態にしておく方が安全です。
Java の Maven マルチモジュールに相当する概念です。Maven では親 pom.xml に <modules> を列挙してプロジェクト全体を管理しますが、go.work はより軽量で、既存のモジュール構成を変えずに重ねられます。
<!-- Maven の場合: 親 pom.xml -->
<modules>
<module>service-a</module>
<module>service-b</module>
<module>shared</module>
</modules>
go.work を使うと replace の管理が不要になる分、モノレポで複数モジュールを扱う現場では実務的な選択肢になります。
Go のパッケージ設計が前提にしていること
Java はクラス単位で設計し、パッケージはその整理手段です。Go はパッケージ単位で設計し、クラスという概念がありません。
この違いは、設計の粒度に影響します。Java では1つのクラスに責務を閉じ込めることが多いですが、Go では1つのパッケージに関連する処理をまとめる発想になります。
Go のパッケージ設計で意識すべき点は次のとおりです。
- パッケージ名はその内容を端的に表す単語にする(
utilやcommonは避ける) - パッケージの import パスはモジュールパス + ディレクトリパスで一意に決まる
-
internalは「外部に公開しない実装の詳細」を置く場所として使う - 共通化が必要なコードは
internalの外、モジュールの外、または独立モジュールのどこに置くかを分割前に決める
まとめ
Go のパッケージシステムを Java と比べると、次の違いが浮かび上がります。
- アクセス制御の単位: Java はクラス修飾子、Go は識別子の大文字・小文字とディレクトリ境界
- 内部パッケージの制御: Java は JPMS の
exports宣言、Go はinternalディレクトリ - モジュール管理: Java は Maven/Gradle の依存宣言、Go は
go.modとバージョン管理
設計判断としての指針をまとめます。
- まずシングルモジュール構成から始める。
internalの制約が問題になるのはモジュールを分割してからです。 - モジュールを分割するなら、共通ロジックの置き場所を同時に決める。後から決めようとするとコピーが積み重なります。
- モノレポで複数モジュールを扱うなら
go.workは有力な選択肢です。replaceを各go.modに書き続ける運用は管理が煩雑になりやすいです。 -
internalはあくまで「外部に公開しない実装の詳細」を置く場所です。共有が必要なコードをinternalに置くのは設計の誤りです。