💊

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.

Go Code Review Comments

私の設計では、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 Proverbs

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
}

AnalyzeTaskInteractortaskAnalyzer 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)
GitHubで編集を提案

Discussion