• システム開発に関わる内容をざっくりと書いていく

ASP.NET Core DI のスコープとライフサイクル(Singleton / Scoped / Transient)

ASP.NET Core の DI(Dependency Injection)で Singleton / Scoped / Transient を選ぶとき、「いつ作られて、いつ破棄されるか」がわからないとハマりやすい。

この記事では、各スコープのライフサイクルWeb API での実務的な使い分けをざっくり整理する。


先に結論

  • Transient … 注入のたびに新規生成。軽量・ステートレス向け
  • Scopedスコープ(Web なら 1 リクエスト)内で 1 インスタンス。DbContext の定番
  • Singleton … アプリ起動〜終了まで1 インスタンス。キャッシュ・設定向け
  • Web API では Scoped が「1 リクエスト = 1 スコープ」が基本イメージ
  • Singleton が Scoped / Transient を直接注入しない(Captive Dependency)。IServiceScopeFactory で都度スコープを切る
  • バックグラウンド処理は HTTP スコープがない。明示的に CreateScope() する

3 つのスコープとは

スコープ        寿命(ざっくり)              典型例
────────────────────────────────────────────────────────────
Transient     注入・GetService の都度 1 個   軽いサービス、Mapper
Scoped        1 スコープ内で 1 個            DbContext、UnitOfWork
Singleton     アプリ全体で 1 個              設定、MemoryCache、HttpClientFactory

登録例

builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddSingleton<IClock, SystemClock>();

省略時: Add<T>()TransientAddDbContextScoped(プール利用時も Scoped)。

Web API でのライフサイクル

ASP.NET Core の HTTP リクエストでは、だいたい次の流れ。

リクエスト開始
  └─ Request Scope 作成
       ├─ Scoped サービスをこの Scope 用に生成・共有
       ├─ Controller / Minimal API が DI で解決
       └─ Transient は依存解決のたびに new
リクエスト終了
  └─ Scope 破棄 → Scoped の Dispose / AsyncDispose
  • 同じリクエスト内で同じ Scoped 型を 2 回注入 → 同一インスタンス
  • 別リクエスト → Scoped は別インスタンス
  • Singleton は全リクエストで同じインスタンス

Minimal API でも Controller でも、Middleware パイプライン上では同じ「リクエストスコープ」の考え方。

使い分け早見表

向いているもの                    おすすめスコープ
──────────────────────────────────────────────────
EF Core DbContext                  Scoped
リポジトリ(DbContext 利用)       Scoped
HTTP リクエスト単位の状態           Scoped
軽い変換・送信(状態なし)         Transient
バリデーション(状態なし)         Transient
アプリ設定・Feature Flag           Singleton
IMemoryCache / 共有クライアント    Singleton(Factory 経由)
ログ(ILogger<T>)                フレームワークが適切に管理

迷ったら: DB 触る → Scopedリクエストをまたぐ状態 → Singleton は慎重に、特に理由なし → Transient か Scoped。


依存関係のルール(Captive Dependency)

長い寿命のサービスが、短い寿命のサービスをフィールドで保持してはいけない。Singleton → Scoped 注入が典型。

NG 例: Singleton に DbContext

// NG: DbContext は Scoped なのに Singleton が保持
public class BadCacheService
{
    public BadCacheService(AppDbContext db) { ... }
}

builder.Services.AddSingleton<BadCacheService>();

起動時に検証が効く場合もあるが、ObjectDisposedException別リクエストのデータ混在につながる。

OK 例: スコープを都度作成

public class GoodWorker
{
    private readonly IServiceScopeFactory _scopeFactory;

    public GoodWorker(IServiceScopeFactory scopeFactory)
        => _scopeFactory = scopeFactory;

    public async Task RunAsync(CancellationToken ct)
    {
        await using var scope = _scopeFactory.CreateAsyncScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        // この Scope 内だけ DbContext を使う
    }
}

Singleton / HostedService から Scoped を使うときの定石。

許される方向

Singleton  → Singleton   OK
Singleton  → Transient   OK(都度 new される)
Scoped     → Scoped      OK(同一 Scope 内)
Scoped     → Transient   OK
Scoped     → Singleton   OK

Singleton  → Scoped      NG(Captive Dependency)
Singleton  → DbContext   NG

Dispose / AsyncDispose

  • IDisposable / IAsyncDisposable を実装したサービスは、スコープ終了時に破棄される
  • Scoped … リクエスト(スコープ)終了で Dispose
  • Singleton … アプリ終了時に Dispose
  • Transient … コンテナが追跡しない場合、Dispose されないことも。ラップが必要なら IServiceScope 内で使う
  • DbContext は Scoped なので、リクエスト終了で接続が返る

await using var scope = ...CreateAsyncScope() でスコープ破棄を明示すると安全。

BackgroundService / バッチでの注意

HostedService はSingleton 相当。HTTP リクエストスコープは自動では作られない。

public class OrderSyncWorker : BackgroundService
{
    private readonly IServiceScopeFactory _scopes;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await using var scope = _scopes.CreateAsyncScope();
            var repo = scope.ServiceProvider
                .GetRequiredService<IOrderRepository>();
            await repo.SyncPendingAsync(stoppingToken);

            await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
        }
    }
}
  • 1 回の処理 = 1 Scope が基本(ループの外で Scope を作りっぱなしにしない)
  • Channel / Queue 処理も同様。メッセージ 1 件ごとに Scope を切ることが多い
  • IServiceProvider を Singleton に注入して GetRequiredService<DbContext>() 直叩きは NG

明示的なスコープ作成

  • IServiceScopeFactory.CreateScope() / CreateAsyncScope()
  • テストの WebApplicationFactory 内で Scope を切る
  • Console アプリで ServiceProvider から手動 Scope
  • 並列処理で「スレッド / タスクごとに DbContext」を分けたいとき
await using (var scope = scopeFactory.CreateAsyncScope())
{
    var sp = scope.ServiceProvider;
    var a = sp.GetRequiredService<IMyScoped>();
    var b = sp.GetRequiredService<IMyScoped>();
    // a と b は同一インスタンス(この Scope 内)
}

よくある症状と原因

  • ObjectDisposedException(DbContext) … Scope 外で Context を使っている、Singleton に保持している
  • 別ユーザーのデータが見える … Scoped を Singleton で使い回している疑い
  • Cannot consume scoped service from singleton … 起動時の DI 検証で検出(ValidateScopes
  • Unable to resolve service … 登録漏れ。別問題だがスコープ登録ミスと混同しやすい
  • テストで DB が汚れる … テストごとに Scope / DB を分けていない

開発環境では builder.Host.UseDefaultServiceProvider(o => o.ValidateScopes = true) を有効にしておくと、Captive Dependency に早く気づける(本番相当の Host 設定はプロジェクトに合わせる)。


ざっくりまとめ

  • Web API … 1 リクエスト = 1 Scoped スコープ
  • DbContext / リポジトリ … Scoped
  • アプリ全体で 1 つ … Singleton(Scoped を直接注入しない)
  • バックグラウンド … IServiceScopeFactory + CreateAsyncScope
  • 寿命の短いものを長いものが掴む … Captive Dependency でバグる

設計時に「このサービスは何リクエスト / 何秒生きるか」を一言メモしておくと、スコープ選びで迷いにくい。