Goでクリーンアーキテクチャを導入するとinterfaceが爆発する問題への処方箋
はじめに
Goでクリーンアーキテクチャを導入したとき、私が最初にぶつかった壁は「interfaceが多すぎる」という問題でした。
Repository、InputPort、OutputPort、UseCase、Presenter…レイヤーごとにinterfaceと実装のペアが増殖しました。1つの機能を追加するのに何ファイルも触る状態でした。
しかし運用を続ける中で、interfaceの数そのものは問題ではないと分かりました。本当の問題は1つのinterfaceが太すぎることと、Go の言語特性を活かせていないことでした。
この記事では、教科書通りに作った設計をGo の思想で見直すプロセスを共有します。
教科書通りに作った設計
最初に私が採用したのは、Ports & Adaptersパターンです。interfaceの種類と配置場所を明確に分けました。
internal/{module}/
├── domain/
│ ├── model/ # エンティティ、値オブジェクト
│ └── repository/ # Repository interface
├── usecase/
│ ├── port/
│ │ ├── input/ # Input Port interface
│ │ └── output/ # Output Port interface
│ └── interactor.go # UseCase実装
├── interface/
│ └── rest/
│ ├── handler/ # HTTPハンドラ
│ └── dto/ # リクエスト/レスポンスDTO
└── infrastructure/
└── postgres/ # Repository実装
各interfaceは1〜2メソッドに絞り、ISP(インターフェース分離の原則)も意識しました。たとえばタスク管理システムの分析機能では、次のようにInput Portを分離しました。
// usecase/port/input/task_input_port.go
type AnalyzeTaskInputPort interface {
Analyze(ctx context.Context, input *AnalyzeTaskInput) (*AnalyzeTaskOutput, error)
}
type GetTaskResultsInputPort interface {
GetResults(ctx context.Context, input *GetResultsInput) (*GetResultsOutput, error)
}
type BatchAnalyzeInputPort interface {
BatchAnalyze(ctx context.Context, input *BatchInput) (*BatchOutput, error)
}
この設計は動きます。テスタビリティも高く、レイヤー間の依存方向も正しく保たれています。しかし運用を続ける中で、Go の思想とのズレに気づきました。
Go の思想との3つのズレ
ズレ1:interfaceを「提供側」が定義している
Go の公式 Wiki にはこう書かれています。
Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values.
私の設計では、usecase/port/input/にinterfaceを定義し、それをHandler層が参照していました。つまりinterfaceを提供側が定義するJava/C#的なアプローチです。
Go では利用側がinterfaceを定義するのが慣習です。io.Readerはデータを読む側のパッケージで定義されており、os.Fileはその存在を知りません。
ズレ2:interfaceが「大きすぎる」
Go の作者 Rob Pike はこう述べています。
The bigger the interface, the weaker the abstraction.
Go 標準ライブラリのinterfaceはほとんどが1〜2メソッドです。
| interface | メソッド数 |
|---|---|
io.Reader |
1 |
io.Writer |
1 |
error |
1 |
fmt.Stringer |
1 |
http.Handler |
1 |
sort.Interface |
3 |
私のRepository interfaceは5〜8メソッドありました。Go の基準では「太い」interfaceでした。
ズレ3:「将来のため」のinterfaceが残っている
// ❌ 実装が1つしかないInput Port
type GetTaskResultsInputPort interface {
GetResults(ctx context.Context, input *GetResultsInput) (*GetResultsOutput, error)
}
// この実装しか存在しない
type getTaskResultsInteractor struct {
repo repository.TaskResultRepository
}
Go コミュニティではこれを**Preemptive Interface(先回りinterface)**と呼び、アンチパターンとされています。
A great rule of thumb for Go is accept interfaces, return structs.
— Jack Lindamood, Preemptive Interface Anti-Pattern in Go
処方箋1:Input Portを廃止し、利用側でinterfaceを定義する
最も効果が大きかった改善です。usecase/port/input/ディレクトリを廃止し、interfaceを利用側で定義するようにしました。このパターンがDIP(依存性逆転の原則)とどう結びつくかは「クリーンアーキテクチャの同心円図が伝えきれないこと」で解説しています。
Before:提供側でInput Portを定義
// usecase/port/input/task_input_port.go
type AnalyzeTaskInputPort interface {
Analyze(ctx context.Context, input *AnalyzeTaskInput) (*AnalyzeTaskOutput, error)
}
// interface/rest/handler/task_handler.go
type TaskHandler struct {
analyzer input.AnalyzeTaskInputPort // 提供側のinterfaceを参照
}
After:利用側でinterfaceを定義
// usecase/analyze_task_interactor.go
// structとして公開する(interfaceは定義しない)
type AnalyzeTaskInteractor struct {
resultRepo taskResultWriter
classifier taskClassifier
}
func (i *AnalyzeTaskInteractor) Analyze(ctx context.Context, input *AnalyzeTaskInput) (*AnalyzeTaskOutput, error) {
// 分析ロジック
}
// interface/rest/handler/task_handler.go
// Handlerが必要なメソッドだけinterfaceとして定義
type taskAnalyzer interface {
Analyze(ctx context.Context, input *usecase.AnalyzeTaskInput) (*usecase.AnalyzeTaskOutput, error)
}
type TaskHandler struct {
analyzer taskAnalyzer // 利用側で定義したinterface
}
AnalyzeTaskInteractorはtaskAnalyzer interfaceの存在を知りません。Go のimplicit interface(暗黙的なinterface満足)により、自動的にinterfaceを満たします。
Interactor同士の組み合わせも同様
複合的なInteractorが他のInteractorを使う場合も、利用側でinterfaceを定義します。
// usecase/batch_analyze_interactor.go
type singleAnalyzer interface {
Analyze(ctx context.Context, input *AnalyzeTaskInput) (*AnalyzeTaskOutput, error)
}
type BatchAnalyzeInteractor struct {
analyzer singleAnalyzer // AnalyzeTaskInteractorを暗黙的に受け取る
repo batchResultWriter
}
処方箋2:Output Portは残す(ただし利用側で定義する選択肢もある)
外部サービス(LLM、メッセージキュー、認証基盤等)との連携を抽象化するOutput Portには、残す価値があります。
// usecase/port/output/classifier_port.go
type TaskClassifierPort interface {
Classify(ctx context.Context, content string) (*ClassifyResult, error)
}
Output Portを残す理由は、実装が実際に差し替わるからです。私のプロジェクトでは、分析ロジックを「キーワードマッチング → 機械学習ベースの分類」に段階的に移行しました。Output Portが分離されていたため、新しい実装を追加するだけで移行できました。
ただし、Go の思想に厳密に従うなら、Output Port も利用側(Interactor)で定義する方法があります。
// usecase/analyze_task_interactor.go
// Interactorが必要な分だけ定義
type taskClassifier interface {
Classify(ctx context.Context, content string) (*ClassifyResult, error)
}
type AnalyzeTaskInteractor struct {
classifier taskClassifier
}
複数のInteractorが同じ外部サービスを使う場合は、共有interfaceとしてOutput Portを1箇所で定義する方が重複を避けられます。これは実用上のトレードオフです。
判断基準
| 条件 | 方針 |
|---|---|
| 1つのInteractorからしか使わない | 利用側で定義する |
| 複数のInteractorから共有する | Output Portとして定義する |
| 実装の差し替え実績・予定がない | interfaceを作らない |
処方箋3:Repositoryを Reader / Writer に分離する
Repository interfaceはdomain/repository/に配置していました。メソッド数は5〜8個です。
// ❌ 太いRepository interface
type TaskResultRepository interface {
FindByID(ctx context.Context, id string) (*TaskResult, error)
FindAll(ctx context.Context, filter *ResultFilter) ([]*TaskResult, int64, error)
Save(ctx context.Context, result *TaskResult) error
GetStatistics(ctx context.Context, filter *StatsFilter) (*Statistics, error)
Delete(ctx context.Context, id string) error
BulkSave(ctx context.Context, results []*TaskResult) error
}
Go の基準では太すぎます。そこでReader / Writer に分離しました。
// domain/repository/task_result_repository.go
type TaskResultReader interface {
FindByID(ctx context.Context, id string) (*model.TaskResult, error)
FindAll(ctx context.Context, filter *model.ResultFilter) ([]*model.TaskResult, int64, error)
GetStatistics(ctx context.Context, filter *model.StatsFilter) (*model.Statistics, error)
}
type TaskResultWriter interface {
Save(ctx context.Context, result *model.TaskResult) error
Delete(ctx context.Context, id string) error
BulkSave(ctx context.Context, results []*model.TaskResult) error
}
読み取り専用のInteractorはTaskResultReaderだけに依存し、書き込み処理を知る必要がなくなります。
// usecase/get_statistics_interactor.go
type GetStatisticsInteractor struct {
reader repository.TaskResultReader // 読み取りだけ
}
infrastructure層の実装は両方のinterfaceを暗黙的に満たします。
// infrastructure/postgres/task_result_repository.go
var _ repository.TaskResultReader = (*taskResultRepository)(nil)
var _ repository.TaskResultWriter = (*taskResultRepository)(nil)
var _ Interface = (*Impl)(nil) はGoの定番パターンです。interfaceの変更時に、実装漏れをコンパイルエラーで検出できます。
処方箋4:ディレクトリ構成を整理する
処方箋1〜3を適用した結果のディレクトリ構成です。
Before
internal/{module}/
├── domain/
│ ├── model/
│ └── repository/ # Repository interface
├── usecase/
│ ├── port/
│ │ ├── input/ # Input Port(廃止対象)
│ │ └── output/ # Output Port
│ └── interactor.go
├── interface/
│ └── rest/handler/
└── infrastructure/
└── postgres/
After
internal/{module}/
├── domain/
│ ├── model/
│ └── repository/ # Reader / Writer に分離
├── usecase/
│ ├── port/
│ │ └── output/ # Output Port(共有interfaceのみ残す)
│ └── interactor.go # structとして公開
├── interface/
│ └── rest/handler/ # 利用側でinterface定義
└── infrastructure/
└── postgres/
usecase/port/input/ がなくなり、interfaceの配置場所が減りました。
よくある疑問と私の考え
「Input Portがないと、依存の注入はどうするのか」
これは私も最初に悩んだ点です。結論としては、手動DI・DIコンテナのどちらでも問題ありません。
手動DIの場合、Interactorのstructをそのまま渡すだけです。
// 手動DI:main.go や provider.go での初期化
interactor := usecase.NewAnalyzeTaskInteractor(repo, classifier)
handler := handler.NewTaskHandler(interactor) // structを渡す
DIコンテナ(uber-go/dig等)を使っている場合も、structを直接登録する方式へ切り替えるだけです。
// Before: interfaceとして登録
c.Provide(usecase.NewAnalyzeTaskInteractor, dig.As(new(input.AnalyzeTaskInputPort)))
// After: structを直接登録
c.Provide(usecase.NewAnalyzeTaskInteractor)
どちらの場合も、Handler側でinterfaceを定義しているため、structを渡しても暗黙的にinterfaceを満たします。これがGo のimplicit interfaceの強みです。
「interfaceファイルが各Handlerに散らばって管理しづらくないか」
もっともな懸念です。ただ、各Handlerファイルの先頭にprivate interfaceとして定義するため、interfaceと利用箇所が常に隣接します。実際に運用してみると、「このinterfaceはどこで使われているか」を探す手間はほとんど発生しませんでした。
// interface/rest/handler/task_handler.go
type taskAnalyzer interface {
Analyze(ctx context.Context, input *usecase.AnalyzeTaskInput) (*usecase.AnalyzeTaskOutput, error)
}
type resultFetcher interface {
GetResults(ctx context.Context, input *usecase.GetResultsInput) (*usecase.GetResultsOutput, error)
}
type TaskHandler struct {
analyzer taskAnalyzer
fetcher resultFetcher
}
「小さなプロジェクトでもこの構成が必要か」
正直なところ、CRUD中心のアプリケーションなら、ここまでの分離は必要ないと感じています。Repository interface + structのUseCaseで十分です。この構成が活きるのは、外部サービスとの連携が多いか、複数の戦略を切り替える必要があるプロジェクトです。
まとめ
| 処方箋 | 内容 | Go の根拠 |
|---|---|---|
| 1. Input Port廃止 | 利用側でinterfaceを定義する | "Accept interfaces, return structs" |
| 2. Output Port精査 | 共有interfaceのみ残す | 実装差し替えの実績がある場合のみ |
| 3. Repository分離 | Reader / Writerに分ける | "The bigger the interface, the weaker the abstraction" |
| 4. ディレクトリ整理 | port/input/ を廃止する | interfaceは利用側のパッケージに属する |
教科書通りのPorts & Adaptersを導入した当初は「きれいに分離できた」と満足していました。しかしGo の思想を学ぶ中で、interfaceの数を減らすのではなく、各interfaceを正しい場所に置くことが本質だと気づきました。
Go のimplicit interfaceは、「提供側がinterfaceを定義しなくても依存性逆転ができる」という強力な仕組みです。この仕組みを活かすことで、interfaceの爆発を防ぎつつ、テスタビリティと保守性を両立できます。
参考文献
| 内容 | 出典 |
|---|---|
| クリーンアーキテクチャ原典 | Robert C. Martin, Clean Architecture(2017) |
| Ports & Adapters | Alistair Cockburn, Hexagonal Architecture |
| Go Proverbs | Rob Pike, Go Proverbs |
| Goのinterface設計原則 | Jack Lindamood, Preemptive Interface Anti-Pattern in Go |
| Go公式 interface配置ガイド | Go Wiki, Go Code Review Comments |
| ISP | Robert C. Martin, Agile Software Development, Principles, Patterns, and Practices(2002) |
Discussion