依存関係の寿命を論:DIにおけるライフサイクル管理の実践指針

依存オブジェクトの寿命をどう扱うか

ワインは熟成すれば香りが増すが、ある瞬間を過ぎると急激に劣化する。依存オブジェクトも同様で、使い方を誤るとメモリリークや性能劣化を招く。本章では、DIコンテナやPure DIを通じて、依存関係の寿命を適切に制御する方法を解説する。

Composerが握るライフサイクルの主導権

「Composer」とは、依存オブジェクトを生成・管理する統一的な役割を担うコンポーネントである。DIコンテナでも手動のファクトリでも構わないが、依存の寿命はComposerが決めるという原則は共通する。

// 例:Pure DIでシングルトンとトランジェントを混在させる
public sealed class CommerceComposer
{
    private readonly string _conn;
    private readonly IUserContext _userCtx; // Singleton
    private readonly IRouteCalc _calc;      // Singleton

    public CommerceComposer(string connectionString)
    {
        _conn   = connectionString;
        _userCtx = new WebUserContextAdapter();
        _calc   = new RouteCalculator();
    }

    public HomeController ResolveHomeController()
    {
        // CommerceContextはリクエストごとに新規
        var ctx = new CommerceContext(_conn);
        return new HomeController(
            new ProductService(
                new SqlProductRepository(ctx),
                _userCtx));
    }
}

IDisposable依存を安全に破棄する

実装がアンマネージリソースを扱う場合、Disposeの呼び出し義務が生じる。しかし、インターフェースに IDisposable を露出させるのは設計の臭いである。代わりに Composer が暗黙的にライフサイクルを管理する。

// 一時的なWCFチャネルを隠蔽
public class WcfProductGateway : IProductGateway
{
    private readonly ChannelFactory<IProductSvc> _factory;
    public WcfProductGateway(ChannelFactory<IProductSvc> factory)
    {
        _factory = factory;
    }

    public void Delete(Guid id)
    {
        var ch = _factory.CreateChannel();
        try   { ch.DeleteProduct(id); }
        finally
        {
            try   { ((IDisposable)ch).Dispose(); }
            catch { /* 例外は無視 */ }
        }
    }
}

3つの基本ライフサイクルパターン

パターン説明使用例
Singletonプロセス内で唯一のインスタンスを共有設定オブジェクト、キャッシュ、無状態サービス
Scoped1リクエスト(1ユニット・オブ・ワーク)内で共有Entity Framework DbContext、ユニット・オブ・ワーク
Transient都度新規インスタンスDTO、非スレッドセーフな依存

Scoped ライフサイクルの実装例

public sealed class ConsoleScopeComposer
{
    private readonly string _conn;

    public ConsoleScopeComposer(string conn) => _conn = conn;

    public RatePresenter BuildPresenter()
    {
        // 1分ごとのタイマー実行で毎回新しいスコープ
        var ctx = new CommerceContext(_conn);
        return new RatePresenter(
            new SqlCurrencyRepo(ctx),
            new CurrencyConverter(
                new SqlRateProvider(ctx)));
    }
}

アンチパターン:Captive Dependency

Singleton なサービスに Transient または Scoped 依存を注入すると、依存が本来の寿命より長く拘束され、スレッドセーフでないリソースが複数スレッドから同時に参照される危険がある。

// 悪い例:Scoped DbContext を Singleton サービスに閉じ込める
public sealed class BadComposer
{
    private readonly IOrderRepo _repo;
    public BadComposer(string conn)
    {
        _repo = new SqlOrderRepo(new CommerceContext(conn)); // NG
    }
}

回避策は、Scoped 依存をファクトリメソッドの内部で局所化すること。

漏洩抽象を防ぐ

Lazy<T>IEnumerable<T> をコンストラクタで受け取らせると、ライフサイクル戦略が呼び出し側に漏洩する。代わりに、Composite または Virtual Proxy で包む。

// 複数ロガーを隠蔽する Composite
public sealed class CompositeLogger : ILogger
{
    private readonly ILogger[] _loggers;
    public CompositeLogger(ILogger[] loggers) => _loggers = loggers;

    public void Log(LogEntry e)
    {
        foreach (var l in _loggers)
            try { l.Log(e); } catch { /* 失敗を無視 */ }
    }
}

スレッド固有ライフサイクルの罠

[ThreadStatic] や per-thread ライフサイクルは、async/await によるスレッド移動で意図せず複数スレッドから参照され、競合状態を引き起こす。スコープ境界は「リクエスト」または「操作」単位で設計すべき。

まとめ

  • Composer が依存の寿命を主導し、適切に破棄する。
  • Singleton/Scoped/Transient を使い分け、Captive Dependency を回避。
  • IDisposable は実装側に留め、抽象に漏洩させない。
  • 遅延初期化や複数実装は Proxy/Composite で隠蔽。
  • スレッド固有状態は避け、スコープ境界を明確にする。

タグ: dependency-injection lifetime-management singleton-pattern scoped-pattern transient-pattern

6月14日 20:43 投稿