Goにおける依存性注入:パターンとベストプラクティス

テスト可能なGoコードのための依存性注入(DI)パターンを習得する

目次

依存性注入 (DI) は、Go アプリケーションにおいてクリーンでテスト可能、かつメンテナンス性の高いコードを促進する基本的なデザインパターンです。

REST API の構築、マルチテナントデータベースパターン の実装、または ORM ライブラリ の使用を問わず、依存性注入を理解することは、コードの品質を大幅に向上させることになります。

go-dependency-injection

依存性注入とは?

依存性注入とは、コンポーネントが依存関係を内部で作成するのではなく、外部ソースから受け取るデザインパターンです。このアプローチによりコンポーネントが分離され、コードはよりモジュール化され、テスト可能で、メンテナンスしやすくなります。

Go では、言語のインターフェースベースのデザイン哲学により、依存性注入は特に強力な効果を持ちます。Go の暗黙的なインターフェース実装により、既存のコードを変更することなく、実装を簡単に切り替えることができます。

なぜ Go で依存性注入を使うのか?

テスト可能性の向上: 依存性を注入することで、本番の実装をモックやテストダブルに簡単に置き換えることができます。これにより、データベースや API などの外部サービスが必要なく、高速で孤立したユニットテストを書くことができます。

保守性の向上: 依存関係がコード内で明示的になります。コンストラクタ関数を見ると、コンポーネントが何が必要か一目でわかります。これにより、コードベースの理解と修正が容易になります。

疎結合: コンポーネントは具体的な実装ではなく、抽象化(インターフェース)に依存します。これにより、依存するコードに影響を与えることなく実装を変更できます。

柔軟性: ビジネスロジックを変更することなく、異なる環境(開発、テスト、本番)に対して異なる実装を構成できます。

コンストラクタインジェクション: Go の方法

Go で依存性注入を実装する最も一般的で慣用的な方法は、コンストラクタ関数を通じたものです。これらは通常 NewXxx と命名され、依存関係をパラメータとして受け取ります。

基本的な例

以下は、コンストラクタインジェクションを示すシンプルな例です:

// リポジトリのインターフェースを定義
type UserRepository interface {
    FindByID(id int) (*User, error)
    Save(user *User) error
}

// サービスはリポジトリインターフェースに依存する
type UserService struct {
    repo UserRepository
}

// コンストラクタが依存性を注入する
func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

// メソッドは注入された依存性を使用する
func (s *UserService) GetUser(id int) (*User, error) {
    return s.repo.FindByID(id)
}

このパターンにより、UserServiceUserRepository を必要とすることが明確になります。リポジトリを提供しない限り UserService を作成できないため、依存性の欠如によるランタイムエラーを防ぐことができます。

複数の依存関係

コンポーネントに複数の依存関係がある場合、それらをコンストラクタのパラメータとして追加するだけです:

type EmailService interface {
    Send(to, subject, body string) error
}

type Logger interface {
    Info(msg string)
    Error(msg string, err error)
}

type OrderService struct {
    repo        OrderRepository
    emailSvc    EmailService
    logger      Logger
    paymentSvc  PaymentService
}

func NewOrderService(
    repo OrderRepository,
    emailSvc EmailService,
    logger Logger,
    paymentSvc PaymentService,
) *OrderService {
    return &OrderService{
        repo:       repo,
        emailSvc:   emailSvc,
        logger:     logger,
        paymentSvc: paymentSvc,
    }
}

依存性注入のためのインターフェース設計

依存性注入を実装する際の重要な原則の一つは、依存性逆転の原則 (DIP) です:高レベルモジュールは低レベルモジュールに依存してはならず、両者は抽象化に依存すべきです。

Go では、これはコンポーネントが実際に必要とするもののみを表す、小さく焦点を絞ったインターフェースを定義することを意味します:

// 良い例: 小さく焦点を絞ったインターフェース
type PaymentProcessor interface {
    ProcessPayment(amount float64) error
}

// 悪い例: 不要なメソッドを含む大規模なインターフェース
type PaymentService interface {
    ProcessPayment(amount float64) error
    RefundPayment(id string) error
    GetPaymentHistory(userID int) ([]Payment, error)
    UpdatePaymentMethod(userID int, method PaymentMethod) error
    // ... より多くのメソッド
}

より小さなインターフェースはインターフェース分離の原則に従います——クライアントは使用しないメソッドに依存すべきではありません。これにより、コードはより柔軟でテストしやすくなります。

現実世界の例: データベースの抽象化

Go アプリケーションでデータベースを扱う際には、データベース操作を抽象化する必要があることがよくあります。以下は、依存性注入がどのように役立つかの例です:

// データベースインターフェース - 高レベルな抽象化
type DB interface {
    Query(ctx context.Context, query string, args ...interface{}) (Rows, error)
    Exec(ctx context.Context, query string, args ...interface{}) (Result, error)
    BeginTx(ctx context.Context) (Tx, error)
}

// リポジトリは抽象化に依存する
type UserRepository struct {
    db DB
}

func NewUserRepository(db DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
    query := "SELECT id, name, email FROM users WHERE id = $1"
    rows, err := r.db.Query(ctx, query, id)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    
    // ... rows をパース
}

このパターンは、異なるデータベース実装や接続戦略の間で切り替える必要がある マルチテナントデータベースパターン を実装する際に特に有用です。

コンポジションルートのパターン

コンポジションルートは、アプリケーションのエントリポイント(通常は main)で全ての依存を組み立てる場所です。これにより依存関係の構成が一元化され、依存関係グラフが明確になります。

func main() {
    // インフラストラクチャ依存性を初期化
    db := initDatabase()
    logger := initLogger()
    
    // リポジトリを初期化
    userRepo := NewUserRepository(db)
    orderRepo := NewOrderRepository(db)
    
    // 依存性を持つサービスを初期化
    emailSvc := NewEmailService(logger)
    paymentSvc := NewPaymentService(logger)
    userSvc := NewUserService(userRepo, logger)
    orderSvc := NewOrderService(orderRepo, emailSvc, logger, paymentSvc)
    
    // HTTP ハンドラを初期化
    userHandler := NewUserHandler(userSvc)
    orderHandler := NewOrderHandler(orderSvc)
    
    // ルートを接続
    router := setupRouter(userHandler, orderHandler)
    
    // サーバーを開始
    log.Fatal(http.ListenAndServe(":8080", router))
}

このアプローチにより、アプリケーションがどのように構成され、依存関係がどこから来るかが明確になります。複数の依存関係の層を調整する必要がある Go での REST API の構築において特に価値があります。

依存性注入フレームワーク

複雑な依存関係グラフを持つ大規模なアプリケーションでは、依存関係を手動で管理することは煩雑になります。Go には、これを支援するいくつかの DI フレームワークがあります:

Google Wire (コンパイル時 DI)

Wire は、コードを生成するコンパイル時の依存性注入ツールです。型安全であり、ランタイムオーバーヘッドはありません。

インストール:

go install github.com/google/wire/cmd/wire@latest

例:

// wire.go
//go:build wireinject
// +build wireinject

package main

import "github.com/google/wire"

func InitializeApp() (*App, error) {
    wire.Build(
        NewDB,
        NewUserRepository,
        NewUserService,
        NewUserHandler,
        NewApp,
    )
    return &App{}, nil
}

Wire はコンパイル時に依存性注入コードを生成し、型安全性を確保し、ランタイムリフレクションのオーバーヘッドを排除します。

Uber Dig (ランタイム DI)

Dig はリフレクションを使用するランタイム依存性注入フレームワークです。より柔軟ですが、ランタイムコストがかかります。

例:

import "go.uber.org/dig"

func main() {
    container := dig.New()
    
    // プロバイダを登録
    container.Provide(NewDB)
    container.Provide(NewUserRepository)
    container.Provide(NewUserService)
    container.Provide(NewUserHandler)
    
    // 依存関係が必要な関数を呼び出す
    err := container.Invoke(func(handler *UserHandler) {
        // ハンドラを使用
    })
    if err != nil {
        log.Fatal(err)
    }
}

フレームワークを使用するタイミング

フレームワークを使用すべき場合:

  • 依存関係グラフが複雑で、相互依存するコンポーネントが多い場合
  • 構成に基づいて選択される必要がある同じインターフェースの複数の実装がある場合
  • 自動的な依存関係解決が必要な場合
  • 手動での配線が誤りになりやすい大規模なアプリケーションを構築している場合

手動 DI を使用するべき場合:

  • アプリケーションが小〜中規模である場合
  • 依存関係グラフが単純で追従しやすい場合
  • 依存関係を最小限かつ明示的に保ちたい場合
  • 生成されたコードよりも明示的なコードを好む場合

依存性注入を用いたテスト

依存性注入の主要な利点の一つは、テスト可能性の向上です。DI がテストをどのように容易にするかを示します:

ユニットテストの例

// テスト用のモック実装
type mockUserRepository struct {
    users map[int]*User
    err   error
}

func (m *mockUserRepository) FindByID(id int) (*User, error) {
    if m.err != nil {
        return nil, m.err
    }
    return m.users[id], nil
}

func (m *mockUserRepository) Save(user *User) error {
    if m.err != nil {
        return m.err
    }
    m.users[user.ID] = user
    return nil
}

// モックを使用してテスト
func TestUserService_GetUser(t *testing.T) {
    mockRepo := &mockUserRepository{
        users: map[int]*User{
            1: {ID: 1, Name: "John", Email: "john@example.com"},
        },
    }
    
    service := NewUserService(mockRepo)
    
    user, err := service.GetUser(1)
    assert.NoError(t, err)
    assert.Equal(t, "John", user.Name)
}

このテストは高速に実行され、データベースを必要とせず、ビジネスロジックを孤立してテストします。Go での ORM ライブラリ を使用する際には、データベースの設定なしでサービスロジックをテストするためにモックリポジトリを注入できます。

一般的なパターンとベストプラクティス

1. インターフェース分離を使用する

インターフェースを小さく保ち、クライアントが実際に必要としていることに焦点を当てます:

// 良い例: クライアントはユーザーの読み取りのみを必要とする
type UserReader interface {
    FindByID(id int) (*User, error)
    FindByEmail(email string) (*User, error)
}

// 書き込み用には別のインターフェース
type UserWriter interface {
    Save(user *User) error
    Delete(id int) error
}

2. コンストラクタからエラーを返す

コンストラクタは依存関係を検証し、初期化に失敗した場合はエラーを返すべきです:

func NewUserService(repo UserRepository) (*UserService, error) {
    if repo == nil {
        return nil, errors.New("user repository cannot be nil")
    }
    return &UserService{repo: repo}, nil
}

3. リクエストスコープの依存関係に Context を使用する

データベーストランザクションなどのリクエスト固有の依存関係については、コンテキストを介して渡します:

type ctxKey string

const dbKey ctxKey = "db"

func WithDB(ctx context.Context, db DB) context.Context {
    return context.WithValue(ctx, dbKey, db)
}

func DBFromContext(ctx context.Context) (DB, bool) {
    db, ok := ctx.Value(dbKey).(DB)
    return db, ok
}

4. 過剰な注入を避ける

真に内部的な実装詳細である依存関係は注入しないでください。コンポーネントが独自のヘルパーオブジェクトを作成して管理している場合は、それで問題ありません:

// 良い例: 内部的なヘルパーは注入を必要としない
type UserService struct {
    repo UserRepository
    // 内部的なキャッシュ - 注入は不要
    cache map[int]*User
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{
        repo:  repo,
        cache: make(map[int]*User),
    }
}

5. 依存関係を文書化する

コメントを使用して、依存関係が必要な理由や制約を文書化します:

// UserService はユーザー関連のビジネスロジックを処理します。
// データアクセスには UserRepository、エラー追跡には Logger が必要です。
// リポジトリは並行コンテキストで使用される場合はスレッドセーフである必要があります。
func NewUserService(repo UserRepository, logger Logger) *UserService {
    // ...
}

依存性注入を使うべきでない場合

依存性注入は強力なツールですが、常に必要というわけではありません:

DI をスキップすべき場合:

  • シンプルな値オブジェクトやデータ構造
  • 内部的なヘルパー関数やユーティリティ
  • 使い捨てのスクリプトや小さなユーティリティ
  • 直接インスタンス化の方が明確で簡単である場合

DI を使わない例:

// シンプルな構造体 - DI は不要
type Point struct {
    X, Y float64
}

func NewPoint(x, y float64) Point {
    return Point{X: x, Y: y}
}

// シンプルなユーティリティ - DI は不要
func FormatCurrency(amount float64) string {
    return fmt.Sprintf("$%.2f", amount)
}

Go エコシステムとの統合

依存性注入は、他の Go パターンやツールとシームレスに動作します。Go の標準ライブラリによるウェブスクレイピングPDF レポートの生成 を使用したアプリケーションを構築する際には、これらのサービスをビジネスロジックに注入できます:

type PDFGenerator interface {
    GenerateReport(data ReportData) ([]byte, error)
}

type ReportService struct {
    pdfGen PDFGenerator
    repo   ReportRepository
}

func NewReportService(pdfGen PDFGenerator, repo ReportRepository) *ReportService {
    return &ReportService{
        pdfGen: pdfGen,
        repo:   repo,
    }
}

これにより、PDF 生成の実装を切り替えたり、テスト中にモックを使用したりできます。

結論

依存性注入は、メンテナンス性が高くテスト可能な Go コードを書くための基盤です。この記事で概説したパターン——コンストラクタインジェクション、インターフェースベースの設計、およびコンポジションルートパターン——に従うことで、理解しやすく、テスト可能で、修正しやすいアプリケーションを作成できます。

小〜中規模のアプリケーションには手動のコンストラクタインジェクションから始め、依存関係グラフが成長するにつれて Wire や Dig などのフレームワークを検討してください。目標は複雑さそのものではなく、明確さとテスト可能性であることを覚えておいてください。コマンドとクエリハンドラを中心にアプリケーションを構成している場合、Go での CQRS の実装 では、同じコンストラクタインジェクションアプローチが ApplicationCommandsQueries 構造体をどのようにクリーンに、かつ余儀ないことなく配線するかを示しています。

さらに Go 開発リソースについては、Go シンタックスや一般的なパターンのクイックリファレンスとして Go チートシート をチェックしてください。

有用なリンク

外部リソース

購読する

システム、インフラ、AIエンジニアリングの新記事をお届けします。