依存関係の注入: Python的なアプローチ

テスト可能なコードのためにPythonでのDIパターン

目次

依存性注入(DI)は、Pythonアプリケーションにおけるクリーンでテスト可能で保守可能なコードを促進する基本的な設計パターンです。

REST APIの構築にFastAPIを使用する場合、ユニットテストの実装、またはAWS Lambda関数の作業に関わらず、依存性注入を理解することは、コード品質を大幅に向上させます。

python packages

依存性注入とは?

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

Pythonでは、言語の動的性とプロトコル、抽象基底クラス、ダックタイピングへのサポートにより、依存性注入は特に強力です。Pythonの柔軟性により、DIパターンを重いフレームワークなしで実装できますが、必要に応じてフレームワークも利用可能です。

Pythonで依存性注入を使う理由

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

保守性の向上:依存性がコード内で明示的になります。コンストラクタを見て、コンポーネントが何を必要とするかすぐにわかります。これにより、コードベースがより理解しやすく、変更しやすくなります。

疎結合:コンポーネントは具体的な実装(プロトコルやABC)ではなく抽象化に依存します。これにより、実装を変更しても依存するコードに影響を与えません。

柔軟性:開発、テスト、本番環境など、さまざまな環境で異なる実装を構成できますが、ビジネスロジックを変更する必要はありません。これは、AWS Lambdaや伝統的なサーバーなど、さまざまなプラットフォームにPythonアプリケーションを展開する際特に役立ちます。

コンストラクタ注入:Pythonの方法

Pythonで依存性注入を実装する最も一般的でイディオミックな方法は、コンストラクタ注入です。__init__メソッドで依存性をパラメータとして受け取ります。

基本的な例

コンストラクタ注入を示す簡単な例です:

from typing import Protocol
from abc import ABC, abstractmethod

# リポジトリのプロトコルを定義
class UserRepository(Protocol):
    def find_by_id(self, user_id: int) -> 'User | None':
        ...
    
    def save(self, user: 'User') -> 'User':
        ...

# リポジトリプロトコルに依存するサービス
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
    
    def get_user(self, user_id: int) -> 'User | None':
        return self.repo.find_by_id(user_id)

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

複数の依存性

コンポーネントが複数の依存性を持つ場合、コンストラクタパラメータとしてそれらを追加します:

class EmailService(Protocol):
    def send(self, to: str, subject: str, body: str) -> None:
        ...

class Logger(Protocol):
    def info(self, msg: str) -> None:
        ...
    
    def error(self, msg: str, err: Exception) -> None:
        ...

class OrderService:
    def __init__(
        self,
        repo: OrderRepository,
        email_svc: EmailService,
        logger: Logger,
        payment_svc: PaymentService,
    ):
        self.repo = repo
        self.email_svc = email_svc
        self.logger = logger
        self.payment_svc = payment_svc

プロトコルと抽象基底クラスの使用

依存性注入を実装する際の重要な原則の一つは、**依存性逆転の原則(DIP)**です:高レベルモジュールは低レベルモジュールに依存してはいけません。どちらも抽象化に依存すべきです。

Pythonでは、**プロトコル(構造的型付け)または抽象基底クラス(ABC)(名前付き型付け)**を使用して抽象化を定義できます。

プロトコル(Python 3.8+)

プロトコルは構造的型付けを使用します。オブジェクトが必要なメソッドを持っている場合、プロトコルを満たします:

from typing import Protocol

class PaymentProcessor(Protocol):
    def process_payment(self, amount: float) -> bool:
        ...

# process_paymentメソッドを持つ任意のクラスはこのプロトコルを満たします
class CreditCardProcessor:
    def process_payment(self, amount: float) -> bool:
        # クレジットカードロジック
        return True

class PayPalProcessor:
    def process_payment(self, amount: float) -> bool:
        # PayPalロジック
        return True

# PaymentProcessorを引数に受け取るサービス
class OrderService:
    def __init__(self, payment_processor: PaymentProcessor):
        self.payment_processor = payment_processor

抽象基底クラス(ABC)

ABCは名前付き型付けを使用します。クラスはABCから明示的に継承する必要があります:

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def process_payment(self, amount: float) -> bool:
        pass

class CreditCardProcessor(PaymentProcessor):
    def process_payment(self, amount: float) -> bool:
        return True

プロトコルとABCの使用タイミング:構造的型付けと柔軟性が必要な場合はプロトコルを使用してください。継承階層を強制したり、デフォルト実装を提供したりする必要がある場合はABCを使用してください。

実際の例:データベースの抽象化

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

from typing import Protocol, Optional
from contextlib import contextmanager

class Database(Protocol):
    @contextmanager
    def transaction(self):
        ...
    
    def execute(self, query: str, params: dict) -> None:
        ...
    
    def fetch_one(self, query: str, params: dict) -> Optional[dict]:
        ...

# 抽象化に依存するリポジトリ
class UserRepository:
    def __init__(self, db: Database):
        self.db = db
    
    def find_by_id(self, user_id: int) -> Optional['User']:
        result = self.db.fetch_one(
            "SELECT * FROM users WHERE id = :id",
            {"id": user_id}
        )
        if result:
            return User(**result)
        return None

このパターンにより、リポジトリコードを変更することなく、データベース実装(PostgreSQL、SQLite、MongoDBなど)を交換できます。

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

コンポジションルートとは、アプリケーションのエントリポイント(通常はmain.pyまたはアプリケーションファクトリ)ですべての依存性を組み立てる場所です。これにより、依存性グラフが明確になります。

def create_app() -> FastAPI:
    app = FastAPI()
    
    # インフラストラクチャの依存性を初期化
    db = init_database()
    logger = init_logger()
    
    # リポジトリを初期化
    user_repo = UserRepository(db)
    order_repo = OrderRepository(db)
    
    # 依存性とともにサービスを初期化
    email_svc = EmailService(logger)
    payment_svc = PaymentService(logger)
    user_svc = UserService(user_repo, logger)
    order_svc = OrderService(order_repo, email_svc, logger, payment_svc)
    
    # HTTPハンドラを初期化
    user_handler = UserHandler(user_svc)
    order_handler = OrderHandler(order_svc)
    
    # ルートを接続
    app.include_router(user_handler.router)
    app.include_router(order_handler.router)
    
    return app

このアプローチにより、アプリケーションの構造と依存性の出所が明確になります。これは、クリーンアーキテクチャの原則に従ってアプリケーションを構築する際特に価値があります。

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

複雑な依存性グラフを持つ大規模なアプリケーションでは、依存性を手動で管理するのではなく、PythonにはいくつかのDIフレームワークが存在します:

依存性注入(Dependency Injector)

依存性注入は、コンテナベースのアプローチを提供する人気のあるフレームワークです。

インストール:

pip install dependency-injector

例:

from dependency_injector import containers, providers
from dependency_injector.wiring import inject, Provide

class Container(containers.DeclarativeContainer):
    # 設定
    config = providers.Configuration()
    
    # データベース
    db = providers.Singleton(
        Database,
        connection_string=config.database.url
    )
    
    # リポジトリ
    user_repository = providers.Factory(
        UserRepository,
        db=db
    )
    
    # サービス
    user_service = providers.Factory(
        UserService,
        repo=user_repository
    )

# 使用
container = Container()
container.config.database.url.from_env("DATABASE_URL")
user_service = container.user_service()

インジェクター(Injector)

インジェクターは、GoogleのGuiceにインスパイアされた軽量ライブラリで、シンプルさに焦点を当てています。

インストール:

pip install injector

例:

from injector import Injector, inject, Module, provider

class DatabaseModule(Module):
    @provider
    def provide_db(self) -> Database:
        return Database(connection_string="...")

class UserModule(Module):
    @inject
    def __init__(self, repo: UserRepository):
        self.repo = repo

injector = Injector([DatabaseModule()])
user_service = injector.get(UserService)

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

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

  • 依存性グラフが複雑で、多くの相互依存コンポーネントがある
  • 同じインターフェースの複数の実装があり、構成に基づいて選択する必要がある
  • 依存性の自動解決を望む
  • 手動でワイヤリングするのではなく、大規模なアプリケーションを構築したい

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

  • アプリケーションが小規模または中規模
  • 依存性グラフが単純で、わかりやすい
  • 依存性を最小限かつ明示的に保つ
  • フレームワークのマジックよりも明示的なコードを好む

依存性注入を使用したテスト

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

ユニットテストの例

from unittest.mock import Mock
import pytest

# テスト用のモック実装
class MockUserRepository:
    def __init__(self):
        self.users = {}
        self.error = None
    
    def find_by_id(self, user_id: int) -> Optional['User']:
        if self.error:
            raise self.error
        return self.users.get(user_id)
    
    def save(self, user: 'User') -> 'User':
        if self.error:
            raise self.error
        self.users[user.id] = user
        return user

# モックを使用してテスト
def test_user_service_get_user():
    mock_repo = MockUserRepository()
    mock_repo.users[1] = User(id=1, name="John", email="john@example.com")
    
    service = UserService(mock_repo)
    
    user = service.get_user(1)
    assert user is not None
    assert user.name == "John"

このテストは迅速に実行され、データベースを必要とせず、ビジネスロジックを孤立してテストできます。Pythonでのユニットテストを使用する際、依存性注入によりテストダブルを作成し、相互作用を検証するのが簡単になります。

pytestのFixtureを使用

pytestのfixtureは、依存性注入と非常にうまく機能します:

@pytest.fixture
def mock_user_repository():
    return MockUserRepository()

@pytest.fixture
def user_service(mock_user_repository):
    return UserService(mock_user_repository)

def test_user_service_get_user(user_service, mock_user_repository):
    user = User(id=1, name="John", email="john@example.com")
    mock_user_repository.users[1] = user
    
    result = user_service.get_user(1)
    assert result.name == "John"

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

1. インタフェースのセグレゲーションを使用

クライアントが実際に必要なものだけに焦点を当て、プロトコルやインターフェースを小さく保つ:

# 良い例:クライアントはユーザーを読み取るだけが必要
class UserReader(Protocol):
    def find_by_id(self, user_id: int) -> Optional['User']:
        ...
    
    def find_by_email(self, email: str) -> Optional['User']:
        ...

# 書き込み用の別のインターフェース
class UserWriter(Protocol):
    def save(self, user: 'User') -> 'User':
        ...
    
    def delete(self, user_id: int) -> bool:
        ...

2. コンストラクタで依存性を検証

コンストラクタは、初期化に失敗した場合に明確なエラーを発生させるために依存性を検証する必要があります:

class UserService:
    def __init__(self, repo: UserRepository):
        if repo is None:
            raise ValueError("user repository cannot be None")
        self.repo = repo

3. 型ヒントを使用

型ヒントは、依存性を明示的にし、IDEサポートや静的型チェックを助けます:

from typing import Protocol, Optional

class UserService:
    def __init__(
        self,
        repo: UserRepository,
        logger: Logger,
        email_service: EmailService,
    ) -> None:
        self.repo = repo
        self.logger = logger
        self.email_service = email_service

4. 過剰な注入を避ける

本当に内部実装の詳細である依存性を注入しないでください。コンポーネントが自身のヘルパーを生成し管理する場合は、問題ありません:

# 良い例:内部ヘルパーは注入を必要としない
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
        # 内部キャッシュ - 注入を必要としない
        self._cache: dict[int, User] = {}

5. 依存性を文書化

docstringを使用して、依存性が必要な理由と制約を文書化します:

class UserService:
    """UserServiceはユーザー関連のビジネスロジックを処理します。
    
    Args:
        repo: データアクセス用のUserRepository。並行コンテキストで使用する場合、スレッドセーフである必要があります。
        logger: エラートレーキングとデバッグ用のLogger。
    """
    def __init__(self, repo: UserRepository, logger: Logger):
        self.repo = repo
        self.logger = logger

FastAPIでの依存性注入

FastAPIは、Dependsメカニズムを通じて依存性注入をサポートしています:

from fastapi import FastAPI, Depends
from typing import Annotated

app = FastAPI()

def get_user_repository() -> UserRepository:
    db = get_database()
    return UserRepository(db)

def get_user_service(
    repo: Annotated[UserRepository, Depends(get_user_repository)]
) -> UserService:
    return UserService(repo)

@app.get("/users/{user_id}")
def get_user(
    user_id: int,
    service: Annotated[UserService, Depends(get_user_service)]
):
    user = service.get_user(user_id)
    if not user:
        raise HTTPException(status_code=404)
    return user

FastAPIの依存性注入システムは、依存性グラフを自動的に処理し、クリーンで保守可能なAPIを簡単に構築できます。

依存性注入を使用しないべき場合

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

DIをスキップすべきケース:

  • 簡単な値オブジェクトやデータクラス
  • 内部のヘルパー関数やユーティリティ
  • 一時的なスクリプトや小さなユーティリティ
  • 直接のインスタンス化が明確で簡単な場合

DIを使用しない例:

# 簡単なデータクラス - DIは不要
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

# 簡単なユーティリティ - DIは不要
def format_currency(amount: float) -> str:
    return f"${amount:.2f}"

Pythonエコシステムとの統合

依存性注入は、他のPythonのパターンやツールとシームレスに動作します。Pythonパッケージユニットテストフレームワークを使用するアプリケーションを構築する際、ビジネスロジックにこれらのサービスを注入できます:

class ReportService:
    def __init__(
        self,
        pdf_generator: PDFGenerator,
        repo: ReportRepository,
        logger: Logger,
    ):
        self.pdf_generator = pdf_generator
        self.repo = repo
        self.logger = logger
    
    def generate_report(self, report_id: int) -> bytes:
        report_data = self.repo.get_by_id(report_id)
        pdf = self.pdf_generator.generate(report_data)
        self.logger.info(f"Generated report {report_id}")
        return pdf

これにより、テスト中に実装を交換したり、モックを使用したりできます。

非同期の依存性注入

Pythonのasync/await構文は、依存性注入と非常にうまく動作します:

from typing import Protocol
import asyncio

class AsyncUserRepository(Protocol):
    async def find_by_id(self, user_id: int) -> Optional['User']:
        ...
    
    async def save(self, user: 'User') -> 'User':
        ...

class AsyncUserService:
    def __init__(self, repo: AsyncUserRepository):
        self.repo = repo
    
    async def get_user(self, user_id: int) -> Optional['User']:
        return await self.repo.find_by_id(user_id)
    
    async def get_users_batch(self, user_ids: list[int]) -> list['User']:
        tasks = [self.repo.find_by_id(uid) for uid in user_ids]
        results = await asyncio.gather(*tasks)
        return [u for u in results if u is not None]

結論

依存性注入は、保守性とテスト性の高いPythonコードを書くための基盤です。この記事で述べたパターンに従うことで—コンストラクタ注入、プロトコルベースの設計、コンポジションルートパターン—理解しやすく、テストしやすく、変更しやすいアプリケーションを作成できます。

小規模から中規模のアプリケーションでは手動のコンストラクタ注入から始め、依存性グラフが大きくなるにつれて、dependency-injectorやinjectorなどのフレームワークを検討してください。目標は明確さとテスト性であり、複雑さのためではありません。

Python開発のためのより多くのリソースについては、Pythonチートシートを確認してください。Python構文と一般的なパターンに関するクイックリファレンスが含まれています。

有用なリンク

外部リソース