はじめに
この記事では、クリーンアーキテクチャでモックをどこまで使うべきかを整理します。
主張はシンプルで、モックは必要な境界に限定した方が運用しやすい、です。
この記事は、理想的な依存逆転の教科書的整理より、Go + GORM + MySQL の業務アプリで実DB integration test を前提にしたとき、どこまで抽象化とモックを入れるのが現実的かを扱います。
特に次の条件のチームを対象にしています。
- Go
- GORM
- MySQL
- 業務アプリ
- Repository実装は実質1つ
- SQLの正しさが重要
- 実DBのintegration testを回せる
この前提では、Repository interfaceを先に増やすより、実装とテストをまっすぐ置く方が失敗しにくくなります。
これはクリーンアーキテクチャそのものを否定する話ではなく、実装が1つで実DB検証が可能な現場では、抽象化を増やしすぎない方が運用しやすいという実務上の判断です。
この記事は、あらゆる現場でRepository interfaceが不要だと主張するものではありません。
実装が実質1つで、SQL検証の重要度が高く、実DB integration testを継続的に回せる現場に限った現実解を整理しています。
率直な結論
クリーンアーキテクチャでも、Repository interfaceは常に必須とは限らない、と考えています。
効果がある場面はありますが、小中規模で実装が1つの現場では、乱用するとコストが勝ちやすいです。
よくあるのは次の状態です。
- 設計はきれい
- 依存逆転の形は整う
- でも実装は1つ
- interface, impl, mock, wiring が増える
- 変更時の追跡コストが上がる
理論としては正しくても、運用では過剰になりやすいというのが実感です。
モックと単体テストはコストでもある
ここは強調しておきたい点です。
モックはすぐ壊れることがあり、修正に弱いテストになりやすいです。
- メソッド引数の変更でテストが大量に壊れる
- 実装の意図ではなく呼び出し順に依存して壊れる
- 本質的な不具合がなくても赤くなる
単体テストは重要な資産ですが、保守コストも持ちます。
数を増やすこと自体を目的にするより、壊れにくく意味のあるテストを残す方が実務では重要です。
DBに依存しない純粋関数を増やす
もう1つ大事なのは、結合テストでDBに頼らないために、Domain層やUsecase層へ純粋関数を増やすことです。
- 状態遷移の判定
- バリデーション
- 料金計算や丸め
- 権限判定
- 集約ルールの整合チェック
この種のロジックは、DBアクセスと分離して純粋関数にすると高速で壊れにくいテストを書けます。
結果として、DB結合テストは「SQL・transaction・制約検証」に集中でき、全体のテストコストを下げやすくなります。
どこにモックを書くか
実務では、まず次の3点の配置から始めると安定しやすいです。
あくまで基本方針で、チームの事情によって例外はあります。
- Handler: Usecaseだけモック
- Usecase: 外部連携だけモック
- Repository: モックなしで実DB検証
Handlerの結合テストは、HTTP層の配線、ミドルウェア、認証、シリアライズまで含んで重くなりやすいです。
そのため結合テストを厚くする優先先はHandlerではなくUsecaseに置く方が、速度と保守性のバランスを取りやすくなります。
多くのチームでは、Repository interface、mock生成、mock前提のUsecaseテストを最初に整備します。
しかし運用が進むと、SQL変更でmockは壊れずintegration testだけが壊れる、という状態になりやすいです。
さらに壊れたintegration testを直すためにmockも調整し始めると、修正コストだけが増えていきます。
失敗しやすい悪い例
典型的には次の流れで壊れます。
- UsecaseテストでRepositoryモックに期待を書き込む
- RepositoryのSQL条件を変更する
- モックテストは通る
- integration testや本番で壊れて初めて気づく
具体例として、アクティブユーザー取得SQLを次のように誤って変えたとします。
- 変更前:
WHERE deleted_at IS NULL AND status = 'active' - 変更後:
WHERE status = 'active'
Usecaseがモック前提だと、FindActiveUsers() が呼ばれた事実しか見ないため、論理削除データ混入を検知できません。
Goでの最小コード例
以下はモック配置の考え方を示す簡略コードです。補助型や初期化処理は省略しています。
HandlerはUsecaseだけモック
func TestUserHandler_Get(t *testing.T) {
uc := new(MockUserUsecase)
uc.On("Get", mock.Anything, int64(10)).Return(User{ID: 10}, nil)
h := NewUserHandler(uc)
req := httptest.NewRequest(http.MethodGet, "/users?id=10", nil)
rec := httptest.NewRecorder()
h.Get(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), `"id":10`)
uc.AssertExpectations(t)
}
ここで見るのはHTTP入出力とUsecase呼び出しだけです。
MockUserUsecase や User の定義は説明を短くするため省略しています。
Usecaseは外部連携だけモック
type Mailer interface {
SendWelcome(ctx context.Context, to string) error
}
type UserUsecase struct {
repo *UserRepository
mailer Mailer
}
func (u *UserUsecase) Register(ctx context.Context, in RegisterInput) error {
user, err := u.repo.Create(ctx, in)
if err != nil {
return err
}
return u.mailer.SendWelcome(ctx, user.Email)
}
この場合はMailerだけモックし、repoは実DBで検証します。
この書き方は、厳密な依存逆転よりモック最小化と変更コスト最小化を優先した例です。複数実装が現実的になった時点でinterface化します。
なお、外部送信を含む処理は実務ではトランザクション境界やoutbox patternを別途検討します。ここではモック配置の説明に絞るため単純化しています。
Repositoryは実DBで検証
func TestUserRepository_FindActiveUsers(t *testing.T) {
db := setupTestDB(t)
seedUsers(t, db) // active, suspended, deleted を投入
repo := NewUserRepository(db)
got, err := repo.FindActiveUsers(context.Background())
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, "active", got[0].Status)
assert.Nil(t, got[0].DeletedAt)
}
SQL条件、JOIN、NULL条件、インデックス前提の挙動はこの層で担保します。
Repositoryが重くないならUsecaseに寄せてもよい
Repository層が薄く、実質的に単純なCRUDしか持たない場合は、Repositoryを無理に分けずUsecase側に寄せる選択も実務では有効です。
- repositoryの抽象化コストを減らせる
- ファイル数と配線が減る
- 変更時の追跡が楽になる
ただし次の条件が出たら分離を検討します。
- クエリが複雑化してUsecaseが読みにくい
- 取引境界や再利用単位を切り出したい
- データアクセスの関心事が増えた
最初から厳密に分けるより、重くなった時点で分離する方が失敗しにくいです。
Repository interfaceが効くケース
次の条件があるなら、Repository interfaceは有効です。
- 実装が本当に複数ある
- DB以外の保存先へ差し替える可能性が高い
- UsecaseをDBから強く独立させたい
- 大規模開発で境界強制が必要
この場合は抽象化コストより恩恵が上回ります。
Repositoryの複数実装とは何か
実装が複数ある、とは例えば次のような状態です。
-
MySQLUserRepositoryとPostgresUserRepositoryを運用する - 本番はRDB、検証環境は別保存先を使う
- 同じUsecaseに対して保存先切り替えを継続的に行う
ここで重要なのは、実際に複数実装を運用する見込みが高いかどうかです。
可能性がある、だけなら先に抽象化しても回収できないことが多いです。
DB乗り換えは理由になりうるが、先行抽象化は慎重に
MySQL -> PostgreSQL のような乗り換え可能性は、Repository interfaceの理由になりえます。
ただし実務では、interfaceだけで移行コストを吸収しきれないことが多いです。
乗り換え時に主に効いてくるのは次です。
- SQL方言
- UPSERTやRETURNINGなどの構文差
- ロックとtransactionの特性差
- index設計
- NULLや日付関数
- migration/DDL
つまり、interfaceがあっても移行コストの本体は残ります。
特にSQLそのものが業務ロジックに近い現場では、将来の乗り換え可能性だけを理由に先に抽象化しても実利が小さいことがあります。
効果が薄いケース
次の条件では、Repository interfaceの実利は小さくなりがちです。
- 実装はMySQL + GORMの1つ
- テストは実DB integration test中心
- SQLそのものが品質の中心
- 差し替え予定がほぼない
この構成では、interfaceで隠してもDBの本質は消えません。
- JOIN
- 集計
- 排他
- transaction
- index前提
- NULLや日付条件
複雑さが消えるというより、見えにくくなる副作用が出やすいです。
判断ルール
モックやinterfaceを追加する前に、次の順で確認します。
- 外部境界か
- 複数実装になる見込みが高いか
- 実体テストでは作りにくい失敗パターンが必要か
当てはまらないなら、まずは足さない方が保守しやすくなります。
よくある質問
Q. 依存逆転に反しませんか
A. 教科書的には反する部分があります。ただし本記事は、実装が1つで実DB検証できる現場で、変更コストを優先する判断を扱っています。
Q. integration testが重い現場ではどうするか
A. 全件をintegrationに寄せるのではなく、失敗コストが高い経路を優先して実DBで担保し、他は軽量テストで補います。特にHandler結合テストは重くなりやすいため、Usecase層の結合テストを優先して厚くする方が運用しやすいです。
Q. Repositoryを薄くするとUsecaseが太りませんか
A. 太り始めたら分離のサインです。クエリ複雑化、責務増加、再利用要求が出た段階でRepositoryを切り出します。
まとめ
小中規模の業務アプリでは、実装が1つしかないRepository interfaceは、設計上の満足感に対して実利が弱いことが多いです。
クリーンアーキテクチャを守るために先に入れるより、必要になったら導入する方が現実的です。
モック配置は次の最小構成から始めるのが安全です。
- HandlerはUsecaseだけモック
- Usecaseは外部連携だけモック
- Repositoryは実DBで検証
大事なのは、抽象化を増やすことではなく、変更に強く壊れ方が素直なテスト構成を選ぶことです。