はじめに
モダンなWebアプリケーション開発において、保守性・拡張性・テスタビリティを確保するためのアーキテクチャ設計は重要な課題です。 この記事では Clean Architecture について、実際のGoプロジェクトGREE BizCenの実装を通じて詳しく解説します。
GREE BizCenのアーキテクチャ概要
GREE BizCenプロジェクトは、Clean Architectureの原則に従って以下の4つの主要レイヤーで構成されています。
bizcen-backend/
├── cmd/ # アプリケーションエントリーポイント
├── internal/
│ ├── entity/ # エンティティ層(最内層)
│ ├── usecase/ # ユースケース層
│ ├── repo/ # リポジトリ層
│ └── controller/ # コントローラー層(最外層)
├── pkg/ # 外部インターフェース・ユーティリティ
└── config/ # 設定管理
Clean Architectureとは?
Clean Architectureとは、ビジネスロジックを外部の詳細(データベース、フレームワーク、UI等)から独立させることで、 システム全体の保守性と拡張性を向上させるアーキテクチャパターンです。 これにより、循環参照を防ぐと共にUnitTextが書きやすくなります。
依存関係の方向性
Clean Architectureの核心は、依存関係が常に内側(ビジネスロジック)に向かうことです。
Controller → UseCase → Entity
外側の層は内側の層に依存しますが、内側の層は外側の層を知りません。 これにより、循環参照を防ぐことができます。 Repositoryは特殊な位置にあるため、後述します。
インターフェースを通した呼び出し
Clean Architectureの最も重要な特徴の一つは、すべての層間の呼び出しがインターフェースを通して行われることです。 これにより、以下のような利点が得られます。
1. 抽象化による疎結合
// ユースケース層では具象実装ではなくインターフェースに依存
type UseCase struct {
repo repo.AccountRepo // インターフェース
}
// 具象実装の詳細を知らない
func (uc *UseCase) Get(ctx context.Context, id string) (*entity.Account, error) {
return uc.repo.Get(ctx, id) // インターフェースメソッドを呼び出し
}
2. 実装の交換可能性
データベースをSpannerからMySQLに変更する場合も、インターフェースを満たす新しい実装を提供するだけで対応可能:
// 同じインターフェースを実装する異なる永続化層
type AccountRepoSpanner struct { ... } // Spanner実装
type AccountRepoMySQL struct { ... } // MySQL実装
type AccountRepoMemory struct { ... } // インメモリ実装(テスト用)
3. テスタビリティの向上
インターフェースにより、テスト時に簡単にモックオブジェクトを注入できます:
// テストではモック実装を注入
mockRepo := mocks.NewMockAccountRepo(ctrl)
useCase := account.New(mockRepo) // 同じインターフェースのモック実装
この設計により、ビジネスロジック(ユースケース層)は永続化の詳細(データベースの種類やORMの選択)から完全に独立し、 変更に強いシステムが構築できます。
エンティティ層(Entity Layer)
エンティティ層は、アプリケーションの最も内側に位置し、ビジネスルールとドメインオブジェクトを定義します。
Account エンティティの実装
// internal/entity/account.go
package entity
type Account struct {
ID string `json:"id"`
Name string `json:"name"`
...
}
エンティティ層の設計原則
- 純粋なデータ構造: 外部依存を持たない
- ビジネスルールの表現: ドメインの概念を正確に表現
- 不変性の重視: 可能な限り不変なオブジェクトとして設計
ユースケース層(Use Case Layer)
ユースケース層は、アプリケーション固有のビジネスロジックを実装し、エンティティを操作してビジネス要件を満たします。
インターフェース設計
まず、依存性の逆転原理に従って、ユースケースのインターフェースを定義します:
// internal/usecase/contracts.go
package usecase
type Account interface {
Get(ctx context.Context, id string) (*entity.Account, error)
}
Account ユースケースの具体実装
// internal/usecase/account/account.go
package account
func (uc *UseCase) Get(ctx context.Context, id string) (*entity.Account, error) {
// リポジトリ呼び出し
account, err := uc.repo.Get(ctx, id)
if err != nil {
span.RecordError(err)
return nil, fmt.Errorf("AccountUseCase - Get - uc.repo.Get: %w", err)
}
return account, nil
}
ユースケース層の特徴
- ビジネスロジックの集約: アプリケーション固有のルールを実装
- 依存性の逆転: インターフェースを通じてリポジトリにアクセス
- テスタビリティ: モックを使った単体テストが容易
リポジトリ層(Repository Layer)
リポジトリ層は、データの永続化ロジックを抽象化し、ユースケース層からデータアクセスの詳細を隠蔽します。
リポジトリインターフェースの定義
// internal/repo/contracts.go
package repo
type AccountRepo interface {
Get(ctx context.Context, id string) (*entity.Account, error)
}
Spanner実装の具体例
GREE BizCenでは、Google Cloud Spannerを使用したリポジトリ実装を提供しています。
// internal/repo/account/account.go
package account
type AccountRepoSpanner struct {
Spanner spanner.SpannerClient
}
func (r *AccountRepoSpanner) Get(ctx context.Context, id string) (*entity.Account, error) {
// パラメータ化クエリでSQLインジェクション対策
stmt := gospanner.Statement{
SQL: `SELECT id, name
FROM account
WHERE id = @id`,
Params: map[string]interface{}{
"id": id,
},
}
// クエリ実行
iter := r.Spanner.Query(ctx, stmt,...)
defer iter.Stop()
...
return &account, nil
}
ここで注意したいのは、interface は AccountRepo ですが、実装は AccountRepoSpanner である点です。 AccountRepoSpanner は、AccountRepo interface を実装したもので、アプリケーション開始時に、以下のように注入されています。
// internal/app/app.go
accountSpannerRepo := accountRepo.NewAccountRepoSpanner(spannerDB)
accountUseCase := account.New(accountSpannerRepo)
AccountRepo interface を満たす AccountRepoMySQL を作って、ここで注入すれば、Usecaseを変更せずにMySQLに差し替える事ができます。
Clean Architectureの説明のとき、Repositoryは特殊と述べました。 Repository実装は副作用を起こすため、Clean Architectureの玉葱の外側に位置します。 そのため、内側にあるUsecaseから参照できません。 しかし、Repository InterfaceをUsecaseの一部と考えることで、Usecaseから参照可能と考えるようにします。 正直無理筋のように思えますが、こう考えると上手くいくので、信じるようにしています。
コントローラー層(Controller Layer)
コントローラー層は、HTTP APIエンドポイントを提供し、外部からのリクエストをユースケースに橋渡しします。
コントローラーの実装例
// internal/controller/http/v1/account.go
type accountRoutes struct {
a usecase.Account
}
func (r *accountRoutes) getAccount(c *fiber.Ctx) error {
// パラメータ取得
id := c.Params("id")
// ユースケース呼び出し
account, err := r.a.Get(c.UserContext(), id)
return c.JSON(account)
}
Clean Architectureと関係がないため割愛しますが、実際は fiber の middleware を使って、様々な処理を行なっています。
外部インターフェース(Frameworks & Drivers)
pkg
パッケージでは、外部システムとの統合を抽象化しています。
ここはSDKを叩くだけの薄いラッパーとして実装しています。
ロジックが無いので、UnitTest不要です。
パッケージのinterfaceを定義することで、これを使うRepositoryのMockが自動生成可能となり、UnitTestが書きやすくなります。
Spannerクライアントの抽象化
// pkg/spanner/interface.go
type SpannerClient interface {
Query(ctx context.Context, stmt spanner.Statement) *spanner.RowIterator
}
依存性注入(Dependency Injection)
アプリケーションの起動時に、すべての依存関係を組み立てます:
// internal/app/app.go
func Run(cfg *config.Config) {
// 外部サービスの初期化
spannerDB, err := spannerPkg.New(cfg.Spanner.DatabasePath, opts...)
// リポジトリ層の初期化
accountSpannerRepo := accountRepo.NewAccountRepoSpanner(spannerDB)
// ユースケース層の初期化
accountUseCase := account.New(accountSpannerRepo)
// HTTP サーバーとルーターの初期化
httpServer := httpserver.New(httpserver.Port(cfg.HTTP.Port))
http.NewRouter(httpServer.App, cfg, accountUseCase, ...)
httpServer.Start()
}
この依存性注入により、各層は具象型ではなくインターフェースに依存し、テスタビリティと拡張性が向上します。
テスタビリティ - Clean Architectureの真価
Clean Architectureの最大の利点の一つは、優れたテスタビリティです。 GREE BizCenプロジェクトでは、internal/ 以下の各層のUnitTestを実装しています。
モック生成の自動化
Go言語のgo generate
とMockgenを使用して、インターフェースから自動的にモックを生成しています。
mockgen -source=contracts.go -destination=./mocks_usecase_test.go -package=usecase_test
これにより、インターフェースが変更されても、コマンド一つでモックを更新できます。
UnitTest実装例
ユースケース層では、リポジトリをモック化して純粋なビジネスロジックをテストします。
// internal/usecase/account/account_test.go
func TestAccountUseCase_Get(t *testing.T) {
// テストセットアップ
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mocks.NewMockAccountRepo(ctrl)
useCase := account.New(mockRepo)
t.Run("Success", func(t *testing.T) {
// テストデータの準備
accountID := uuid.New().String()
expectedAccount := &entity.Account{
ID: accountID,
Name: "Test Account"
}
// モックの期待値設定
mockRepo.EXPECT().
Get(gomock.Any(), accountID).
Return(expectedAccount, nil)
// メソッド実行
ctx := context.Background()
result, err := useCase.Get(ctx, accountID)
// アサーション
assert.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, expectedAccount.ID, result.ID)
assert.Equal(t, expectedAccount.Name, result.Name)
})
}
統合テストの実装
統合テストでは、実際のデータベースやエミュレーターを使用してエンドツーエンドの動作を検証しています。 統合テストは時間がかかるため、デプロイ前のみ自動実行しています。
// integration-test/account_test.go
func TestAccountIntegration(t *testing.T) {
// テスト用のSpannerエミュレーター接続
spannerClient := setupTestSpannerClient(t)
defer spannerClient.Close()
// 実際のリポジトリとユースケースを使用
repo := account.NewAccountRepoSpanner(spannerClient)
useCase := account.New(repo)
t.Run("CreateAndGetAccount", func(t *testing.T) {
testAccount := entity.Account{
ID: uuid.New().String(),
Name: "Integration Test Account"
}
// アカウント作成
ctx := context.Background()
err := useCase.Create(ctx, testAccount)
assert.NoError(t, err)
// 作成されたアカウントの取得
retrievedAccount, err := useCase.Get(ctx, testAccount.ID)
assert.NoError(t, err)
assert.NotNil(t, retrievedAccount)
assert.Equal(t, testAccount.Name, retrievedAccount.Name)
})
}
実际の開発での利点と課題
最後に利点と課題について、まとめてみます。
利点
1. 保守性の向上
- 明確な責任分離: 各層の役割が明確で、変更時の影響範囲が限定的
- 疎結合: インターフェースを通じた依存により、実装変更が他層に与える影響を最小化
- 一貫性: アーキテクチャパターンが統一されており、新機能追加時の設計判断が容易
2. テスタビリティの劇的向上
- 単体テスト: 各層を独立してテスト可能
- モックの活用: 外部依存をモック化して高速なテストを実現
- テストカバレッジ: 80%以上のカバレッジを維持
3. チーム開発の効率化
- 並行開発: 各層を異なる開発者が同時に作業可能
- スキル分離: フロントエンド/バックエンド/インフラの専門性を活かした開発
- コードレビュー: 層ごとの明確な責任により、レビューポイントが明確
4. 技術的負債の軽減
- 段階的リファクタリング: 一つの層のみを変更して技術的改善が可能
- レガシーコードの置き換え: インターフェースを保持したまま実装を段階的に更新
課題と解決策
1. 初期開発コストの増加
課題: アーキテクチャ設計とインターフェース定義に時間が必要
解決策: テンプレート の活用
2. 学習コストの高さ
課題: チームメンバーのClean Architectureへの理解が必要
解決策: 明確なコメントとドキュメントの整備
まとめ
GREE BizCenプロジェクトの実装を通じて、Clean ArchitectureをGoで実装する際のベストプラクティスを紹介しました。 Clean Architectureは、初期投資は必要ですが、長期的な保守性・拡張性・テスタビリティの向上により、開発チームの生産性を大幅に向上させます。 GREE BizCenの事例が、皆さんのプロジェクトでのClean Architecture導入の参考になれば幸いです。