PythonでDependency Injectorを使ったDependency Injection:大規模プロジェクトのためのプロフェッショナルなコード設計

Python tutorial - IT technology blog
Python tutorial - IT technology blog

3ヶ月後にコードが「腐り始める」とき

以前、注文データを処理するPythonプロジェクトを担当していた。最初はモジュールが3つだけで、気楽に書いてもサクサク動いていた。ところが3ヶ月後にはモジュールが20以上に膨れ上がり、新機能を追加するたびに…深呼吸が必要になった。原因はシンプルで、すべてのクラスが自分の依存関係をクラス内部で生成していたのだ。こんな具合に:

class OrderService:
    def __init__(self):
        self.db = DatabaseConnection(host="localhost", port=5432)  # Hard-coded!
        self.email = EmailSender(smtp_server="smtp.gmail.com")      # Hard-coded!
        self.logger = Logger(level="INFO")                          # Hard-coded!

その結果:テストができない(どのテストも本物のデータベースが必要)、設定を差し替えられない(staging環境に変えるには15箇所を修正しなければならない)、そしてシニアがPRをレビューして…しばらく沈黙した後に「needs refactoring」とコメントしてきた。

本当の問題はどこにあるのか?

コードは正しく動いていた――ロジックのバグではない。問題はtight couplingだ:OrderServiceは注文を処理する方法だけでなく、データベース接続の作り方、SMTPへの接続方法、loggerの設定方法まで知っている必要がある。一つのクラスが責務を抱え込みすぎているのだ。

この落とし穴はこうだ:小さなプロジェクトや10万件のレコードを一度だけ処理するスクリプトなら、このパターンは全く問題なく、誰も何も感じない。問題が露呈するのは、プロジェクトが大きくなり、本格的なテストが必要になったとき、またはチームに2人目が加わったときだ。具体的には:

  • Unit testOrderServiceをテストするにはデータベースが必要――遅く、環境によって失敗しやすい
  • 複数環境:dev/staging/prodで設定が異なる → コードを修正するか、あちこちに環境変数をばらまかなければならない
  • 実装の差し替え:PostgreSQLからMySQLに変更する → DatabaseConnectionのインスタンス化箇所をすべて探して修正しなければならない
  • 循環インポート:モジュールAがモジュールBをインポートし、BがAをインポートする → Pythonがロード時にエラーを投げる

Dependency Injectionとは何か――初めての人のための解説

Dependency Injection(DI)をシンプルに言えば:クラスが自分で依存関係を生成するのではなく、外部から依存関係を渡すということだ。

# DIを使わない場合(悪い例)
class OrderService:
    def __init__(self):
        self.db = DatabaseConnection()  # 自前でインスタンス化 — tightly coupled

# DIを使う場合(良い例)
class OrderService:
    def __init__(self, db: DatabaseConnection, email: EmailSender):
        self.db = db        # 外部から受け取る
        self.email = email  # 外部から受け取る

これでOrderServiceは「dbemail senderが必要だ」ということだけ知っていればよい。誰が提供するか、どんな種類かは関知しない。

3つの解決策:シンプルから洗練へ

方法1:手動DI――自分で注入する

オブジェクトを生成するときに、各依存関係を自分でinjectする:

db = DatabaseConnection(host=os.getenv("DB_HOST"), port=5432)
email = EmailSender(smtp_server=os.getenv("SMTP_SERVER"))
service = OrderService(db=db, email=email)

メリット:シンプルで、ライブラリを追加する必要がない。デメリット:プロジェクトが大きくなると、この「wiring」部分がmain.pyに50行もの複雑なコードとして積み上がってしまう。

方法2:Factory / Service Locator

オブジェクト生成を管理するregistryを作る方法だが、これは実質的にService Locator patternであり、anti-patternとみなされている。クラスがfactoryに依存しているのは変わらず、隠蔽されているだけだ。テストが楽になるどころか、むしろ難しくなる。

方法3:Dependency Injectorライブラリ

dependency-injectorライブラリは、依存関係を明示的かつtype-safeに宣言・wireし、テスト時に簡単にoverrideできるContainerシステムを提供する。

Dependency Injectorで実践する

インストール

pip install dependency-injector

サンプルプロジェクトの構成

myapp/
├── containers.py           # DI Container — すべてのdependenciesを宣言する
├── services/
│   ├── order_service.py
│   └── notification_service.py
├── repositories/
│   └── order_repository.py
└── main.py

ステップ1 — DIを意識せずにクラスをシンプルに書く

# repositories/order_repository.py
class OrderRepository:
    def __init__(self, db_url: str):
        self.db_url = db_url

    def get_order(self, order_id: int) -> dict:
        # query database...
        return {"id": order_id, "status": "pending"}


# services/notification_service.py
class NotificationService:
    def __init__(self, smtp_host: str, smtp_port: int):
        self.smtp_host = smtp_host
        self.smtp_port = smtp_port

    def send_email(self, to: str, subject: str, body: str):
        print(f"[EMAIL] To: {to} | {subject}")


# services/order_service.py
class OrderService:
    def __init__(self, repo: OrderRepository, notifier: NotificationService):
        self.repo = repo
        self.notifier = notifier

    def process_order(self, order_id: int, customer_email: str) -> dict:
        order = self.repo.get_order(order_id)
        self.notifier.send_email(
            to=customer_email,
            subject=f"Order #{order_id} confirmed",
            body="Your order has been processed.",
        )
        return order

ステップ2 — すべてのdependenciesを宣言するContainerを作る

# containers.py
from dependency_injector import containers, providers
from repositories.order_repository import OrderRepository
from services.notification_service import NotificationService
from services.order_service import OrderService


class Container(containers.DeclarativeContainer):

    config = providers.Configuration()

    # Repository — Singleton: 一度作成したら使い回す
    order_repository = providers.Singleton(
        OrderRepository,
        db_url=config.database.url,
    )

    # Notification service — Singleton
    notification_service = providers.Singleton(
        NotificationService,
        smtp_host=config.smtp.host,
        smtp_port=config.smtp.port,
    )

    # Order service — Factory: 必要に応じて毎回新規作成
    order_service = providers.Factory(
        OrderService,
        repo=order_repository,
        notifier=notification_service,
    )

ステップ3 — main.pyで使用する

# main.py
from containers import Container


def main():
    container = Container()
    container.config.from_dict({
        "database": {"url": "postgresql://user:pass@localhost/mydb"},
        "smtp": {"host": "smtp.gmail.com", "port": 587},
    })

    # Dependenciesはcontainerから自動的にinjectされる
    order_service = container.order_service()
    result = order_service.process_order(
        order_id=12345,
        customer_email="[email protected]",
    )
    print(f"Processed order: {result}")


if __name__ == "__main__":
    main()

Singleton vs Factory――どちらを選ぶべきか?

  • providers.Singleton:一度作成して使い回す――database connection、config、cache clientに使用。実際のユースケースの約80%を占める。
  • providers.Factory:呼び出しごとに新規作成――request handler、job processorに使用
  • providers.Resource:明確なライフサイクルを持つ――必要時にinit、完了後にcleanup(file handles、connection pools)

テストを書いてすぐわかるメリット

正直に言うと、DIを使う理由は美しいアーキテクチャのためではなく、テストのためだ。OrderServiceをテストするのに、本物のデータベースもSMTPサーバーも必要ない:

# tests/test_order_service.py
from unittest.mock import MagicMock
from containers import Container


def test_process_order_sends_notification():
    container = Container()

    mock_repo = MagicMock()
    mock_repo.get_order.return_value = {"id": 1, "status": "pending"}

    mock_notifier = MagicMock()

    # mockでdependenciesをオーバーライド — クリーン、副作用なし
    with container.order_repository.override(mock_repo):
        with container.notification_service.override(mock_notifier):
            service = container.order_service()
            service.process_order(order_id=1, customer_email="[email protected]")

    mock_notifier.send_email.assert_called_once_with(
        to="[email protected]",
        subject="Order #1 confirmed",
        body="Your order has been processed.",
    )

テストは本物のデータベースを待つ2〜3秒ではなく、約5msで完了する。環境が原因で失敗することもなく、pytestを実行するためだけにDockerをセットアップする必要もない。ハードコードで書いていたころに知っておきたかった、と心から思う。

FastAPIとの統合

FastAPIとの統合は、@injectデコレータを追加するだけで完了する:

# api/routes.py
from fastapi import APIRouter, Depends
from dependency_injector.wiring import inject, Provide
from containers import Container
from services.order_service import OrderService

router = APIRouter()


@router.post("/orders/{order_id}/process")
@inject
async def process_order(
    order_id: int,
    customer_email: str,
    order_service: OrderService = Depends(Provide[Container.order_service]),
):
    result = order_service.process_order(order_id, customer_email)
    return {"status": "ok", "order": result}

FastAPIがcontainerを自動的に呼び出して、完全なdependenciesを持ったorder_serviceを取得する――各リクエストで手動で渡す必要はない。

使うべき場面、不要な場面

すべてのプロジェクトにDIを使うことを勧めるわけではない。数年間の経験から得た私のRule of thumbを紹介する:

  • 使うべき場合:5つ以上のサービスがあるプロジェクト、本格的なunit testが必要な場合、2人以上のチーム、複数の環境(dev/staging/prod)での実行が必要な場合
  • 不要な場合:200行以下の小さなスクリプト、一度だけ使うツール、アイデアを素早く検証するプロトタイプ

50行のスクリプトにDI Containerを導入するのは時間の無駄だ。しかし、本番で何ヶ月も動き続けるバックエンドサービスでDIを使わないのは、自分で技術的負債を積み上げているようなものだ。

まとめ

DIは魔法ではない――シンプルな原則だ:クラスに自分の依存関係を生成させるな。dependency-injectorライブラリはそれを体系的に実現する。Containerで宣言し、自動でwireし、テスト時はクリーンにoverrideできる。

Pythonプロジェクトのテストが難しく、機能を追加するたびに不安を感じていないか?技術的負債がコードベース全体に広がる前にDIを導入しよう――私の経験上、これはROIが最も高いリファクタリングだ。

Share: