Blazor ServerのScopedとSingletonの違いを徹底解説|DI・Circuit単位の共有範囲とWASM比較・安全設計ガイド

Blazor Server の依存性注入(DI)で Scoped と Singleton をどう使い分けるべきか——特に「Scoped は Singleton と同じに見える」という情報で混乱しがちな点を、Circuit(ブラウザ タブ単位の接続)の仕組みから丁寧に解説します。WASM(クライアント側 Blazor)との違いや、実装チェックリスト、よくある落とし穴まで一気に整理し、明日からの設計に自信が持てる内容にまとめました。

目次

Blazor Server における Scoped と Singleton の違いまとめ

まずは結論から。以下の表だけで全体像を掴めます。

ライフタイムインスタンスの共有範囲主な用途破棄タイミング
Scoped1 Circuit(=1 ブラウザタブ)内で共有。別タブ・別ユーザーとは共有されないユーザー/タブ固有の状態(ログイン中のユーザー情報、ウィザードの途中経過、ショッピングカートなど)Circuit が終了(タブを閉じる、長時間切断して再接続できない等)
Singletonアプリ全体で共有。すべての Circuit・全ユーザーが同じインスタンスを使う共有キャッシュ、バックグラウンド処理キュー、設定・参照データのリポジトリ、監視・テレメトリアプリケーション停止(プロセス終了/App Pool リサイクルなど)
(参考)WASM の ScopedWASM ではスコープの概念がないため、実質 Scoped = Singleton―タブ(または PWA のインスタンス)が閉じられるまで

要点

  • Scoped ≠ Singleton(Server 側)。同じタブ内では「Singleton 的」に見えるが、Circuit をまたいで共有されることはない。
  • Singleton は本当にアプリ全体共有。ユーザー固有データを入れると情報漏えい・セッション混線の原因になる。

なぜ Scoped は「タブ単位」になるのか:Circuit の仕組み

Blazor Server はブラウザとサーバーが SignalR による永続接続で結ばれ、各ブラウザタブごとに Circuit というセッション様の単位が張られます。DI コンテナはこの Circuit を 1 スコープとして扱い、Scoped は「1 Circuit(=1 タブ)」のライフサイクルに連動します。そのため、同一タブで作成されたコンポーネント群が同じ Scoped インスタンスを共有し、別タブでは別インスタンスになります。

一方で、SignalR の切断が一定時間内に復旧しない場合(ネットワーク断やスリープなど)、Circuit は失効し、Scoped インスタンスは破棄されます。復帰後に同じページへ戻ってきても、新しい Circuit と新しい Scoped が作られる点は設計上の重要ポイントです。

WASM(クライアント側 Blazor)との違い

WASM ではアプリがブラウザ上で自己完結するため、DI コンテナに「サーバー側のスコープ」という概念がありません。結果として Scoped は Singleton と同等に振る舞います。タブごとに別のランタイムが走るため、タブ間では当然共有されませんが、同一タブ内では Singleton と区別がつかないのが基本挙動です。

サーバー プリレンダーとスコープの落とし穴

Blazor Server では、初回表示を高速化するために プリレンダーを使うことがあります。プリレンダーは HTTP リクエストのスコープで動作し、まだ Circuit が確立していない段階でコンポーネントをサーバー側で HTML に描画します。このときに作られるスコープは、Circuit の Scoped とは別物です。ゆえに、_Host.cshtml(ASP.NET Core 6/7)やホストページでサービスを解決して状態を突っ込むと、想定どおりに引き継がれないことがあります。プリレンダーでの状態引き継ぎは PersistentComponentState などの仕組みを使うか、プリレンダー段階で Scoped に依存した初期化をしないのが安全です。

典型ユースケースとアンチパターン

Scoped を使うべき例

  • ログインユーザーの表示名・権限セット・トークンのキャッシュ
  • ウィザード形式フォームの進行状態・入力一時保存
  • ショッピングカート(ユーザー/タブごと)
  • 一時的な UI 状態(フィルター条件、ページング位置など)

Singleton を使うべき例

  • 全ユーザーで共有する参照データのキャッシュ(例:都道府県マスタ)
  • バックグラウンド処理のキューとワーカー
  • アプリケーション設定の読み出し層、機能フラグの取得
  • テレメトリ送信・監視クライアント(スレッドセーフ設計が前提)

アンチパターン

  • Singleton にユーザー別データを持たせる:多重ログインや別ユーザーのデータが混線して表示される危険。
  • 長寿命の DbContext を Scoped に詰める:Blazor Server の Scoped は「タブが開いている間ずっと」になりやすく、DbContext を長期間保持するとトラッキング肥大化・接続枯渇・同時実行問題を招く。IDbContextFactory<TContext> を使い、必要なときに短命で作成・破棄するのが定石。
  • Scoped サービスをバックグラウンドワーカーに渡して保持:Circuit 終了後も参照が残りうる。バックグラウンド側は IServiceScopeFactory で都度スコープを作る。
  • プリレンダー段階で Scoped に副作用:Circuit 開始後に別スコープになり、意図どおりに引き継がれない。

実装チェックリスト

  1. 登録方法の確認(Program.cs)
// ASP.NET Core 7 以前(例)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddServerSideBlazor();

// ユーザー/タブ固有の状態
builder.Services.AddScoped&lt;UserSessionService&gt;();

// 全ユーザー共有の処理キュー
builder.Services.AddSingleton&lt;WorkQueueService&gt;();

// EF Core は短命に作る(推奨)
builder.Services.AddDbContextFactory&lt;AppDbContext&gt;(); // &lt;= これが安全
// builder.Services.AddDbContext&lt;AppDbContext&gt;(); // 長寿命になりやすく非推奨

var app = builder.Build();
app.MapBlazorHub();
app.Run();
// ASP.NET Core 8 以降(例)
var builder = WebApplication.CreateBuilder(args);

// Razor Components + Interactive Server
builder.Services.AddRazorComponents().AddInteractiveServerComponents();

builder.Services.AddScoped&lt;UserSessionService&gt;();
builder.Services.AddSingleton&lt;WorkQueueService&gt;();
builder.Services.AddDbContextFactory&lt;AppDbContext&gt;();

var app = builder.Build();
app.MapRazorComponents&lt;App&gt;().AddInteractiveServerRenderMode();
app.Run();
  1. 動作テスト:Scoped の共有範囲を可視化
public sealed class UserSessionService
{
    public Guid SessionId { get; } = Guid.NewGuid();

    // 例:ユーザー名や権限など Circuit 固有の状態
    public string? DisplayName { get; set; }
}
@* 任意のコンポーネント *@
@inject UserSessionService Session

&lt;p&gt;Scoped の SessionId: @Session.SessionId&lt;/p&gt;

@code {
    protected override void OnInitialized()
    {
        // 同一タブ内の複数コンポーネントで値が一致することを確認
    }
}
  • 同じタブで別コンポーネントを開く:同じ GUID が表示される。
  • もう一つ別タブで同じページを開く:異なる GUID が表示される(別 Circuit)。
  1. スレッドセーフ設計(Singleton 使用時)
public sealed class WorkQueueService
{
    private readonly System.Collections.Concurrent.ConcurrentQueue&lt;Func&lt;CancellationToken, Task&gt;&gt; _queue
        = new();

    public void Enqueue(Func&lt;CancellationToken, Task&gt; work) =&gt; _queue.Enqueue(work);

    public async Task RunAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            if (_queue.TryDequeue(out var work))
                await work(ct);
            else
                await Task.Delay(100, ct);
        }
    }
}
  • スレッド対応コレクション(ConcurrentQueue<T> 等)を使う。
  • 必要最小限のロック(SemaphoreSlim など)。
  • Singleton から Scoped を直接参照しない。必要なら IServiceScopeFactory でスコープを作って取り出す。
  1. _Host.cshtml(またはホストページ)での注入に注意
  • _Host で解決されるサービスはHTTP リクエストスコープ(プリレンダー段階)の可能性がある。
  • ここで状態を設定しても Circuit で別スコープになり、Scoped の挙動を誤解しやすい。
  • プリレンダーで必要なデータは 読み取り専用にし、状態の更新は Circuit 確立後に行う。
  1. SignalR 切断時の考慮
  • 一時的な切断は自動再接続で復旧するが、一定時間を超えると Circuit が失効して Scoped は破棄。
  • 長時間保持が必要なデータは、サーバー側キャッシュ(Singleton+失効戦略)や永続ストア(DB・分散キャッシュ)へ退避。

詳説:各ライフタイムの内部イメージ

Scoped(Blazor Server)

  • 作成:最初のコンポーネントが Circuit 内でサービスを要求した時に生成。
  • 共有:同じ Circuit のコンポーネント間で共有(ページ遷移やコンポーネントの再描画をまたいで保持)。
  • 破棄:Circuit 終了(タブを閉じる/長時間切断)で IDisposable/IAsyncDisposable が呼ばれる。

Singleton

  • 作成:アプリ起動時または初回解決時。
  • 共有:全 Circuit・全ユーザーで共有。
  • 破棄:プロセス終了時に破棄。外部リソース(ソケット、接続プール)を持つ場合は適切に解放。

Transient(参考)

  • 解決のたびに毎回新インスタンスを返す。Blazor Server では UI イベントのたびに大量生成されることもあるため、コストの高いオブジェクトは避ける。
  • 状態保持には不向き。副作用がないサービスや短命の処理オブジェクトに限定。

DbContext と Scoped:なぜ相性が悪いのか(Server)

一般的な ASP.NET Core MVC/API では、AddDbContext の Scoped は「1 HTTP リクエスト」の短命スコープで安全に使えます。しかし Blazor Server の Scoped はタブが開いている間ずっとになりやすく、追跡エンティティの肥大化、長時間トランザクション、接続プール枯渇などを招きがちです。推奨は以下のいずれかです。

  • AddDbContextFactory を使い、必要なときに CreateDbContext で短命に作る(最有力)。
  • どうしても Scoped に置く場合は、各操作のたびに AsNoTracking を使いトラッキング負荷を最低限に抑える(それでも長寿命化のリスクは残る)。
// 推奨:ファクトリから短命に生成して使う
public sealed class OrderRepository
{
    private readonly IDbContextFactory&lt;AppDbContext&gt; _factory;

    public OrderRepository(IDbContextFactory&lt;AppDbContext&gt; factory)
        =&gt; _factory = factory;

    public async Task&lt;Order?&gt; FindAsync(Guid id, CancellationToken ct = default)
    {
        await using var db = await _factory.CreateDbContextAsync(ct);
        return await db.Orders.AsNoTracking().FirstOrDefaultAsync(x =&gt; x.Id == id, ct);
    }
}

プリレンダーから Circuit へ:状態の受け渡し

プリレンダーで取得したデータ(例:初期表示に必要な参照データ)を Circuit 側へ渡す必要がある場合は、PersistentComponentState を使ってシリアライズし、Circuit 開始後に読み戻すのが定石です。Scoped サービスのインスタンスそのものを渡すのではなく、シリアライズ可能なデータに限定することで、スコープ差異によるバグを回避できます。

「Scoped は Singleton と同じ」?——よくある誤解と対処

誤解実際の挙動対処策
Scoped は Singleton と同じ同一 Circuit では同じに見えるが、Circuit 間は別インスタンスユーザー固有データは必ず Scoped(Server)。WASM では Scoped=Singleton であることを理解した上で設計
Singleton ならユーザー情報を安全に持てる全ユーザーで共有されるため情報が混在ユーザー情報は Scoped、または永続ストア。Singleton は参照データやキューなどユーザー非依存に限定
フィールドに値を入れれば維持されるpublic フィールドは再初期化の影響を受けやすいプロパティ({ get; set; })で保持。必要なら永続化/キャッシュと併用

実践サンプル:Circuit の開始・終了をログする

Scoped の生成/破棄を観察するために、CircuitHandler を使ってログを出力します。

public sealed class LoggingCircuitHandler : Microsoft.AspNetCore.Components.Server.Circuits.CircuitHandler
{
    private readonly ILogger&lt;LoggingCircuitHandler&gt; _logger;

    public LoggingCircuitHandler(ILogger&lt;LoggingCircuitHandler&gt; logger)
        =&gt; _logger = logger;

    public override Task OnCircuitOpenedAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        _logger.LogInformation("Circuit Opened: {Id}", circuit.Id);
        return Task.CompletedTask;
    }

    public override Task OnCircuitClosedAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        _logger.LogInformation("Circuit Closed: {Id}", circuit.Id);
        return Task.CompletedTask;
    }
}
// Program.cs
builder.Services.AddSingleton&lt;CircuitHandler, LoggingCircuitHandler&gt;();

さらに、Scoped サービスに IDisposable を実装して破棄タイミングを記録すれば、タブを閉じた瞬間に破棄されるわけではなく、切断猶予経過後に破棄されることも実地で確認できます。

HTTP コンテキストと認証:Blazor Server 独特の注意点

  • コンポーネント実行中は「HTTP リクエスト中」ではないため、IHttpContextAccessor.HttpContext は常に有効とは限らない。
  • ユーザー情報は AuthenticationStateProvider 経由で取得するのが原則。ユーザー固有の派生サービスは Scoped に置く。
  • 認証更新や権限変化が起きる可能性に備えて、権限チェックの結果を Singleton にキャッシュしない(混線の温床)。

テスト観点:品質を崩さないためのチェックポイント

  • タブ間隔離テスト:Chrome プロファイルを 2 つに分け、同一ユーザー/別ユーザーの両パターンで Scoped が混線しないか確認。
  • 切断復帰テスト:Wi-Fi を切り替える、サスペンド・レジュームを行う等で Circuit の再接続を模擬。
  • 高負荷テスト:Singleton のキュー処理やキャッシュがボトルネックにならないか、ロック待ちや GC 圧力を計測。
  • 寿命テスト:数時間〜数日放置後にタブを触ってみて、状態が自然に再確立される設計か(必要なデータは永続化されているか)。

設計パターン集

ユーザーセッション集約(Scoped)+永続ストア

タブ内の一時状態は Scoped(UserSessionService)に集約し、長時間保持が必要なデータは DB/分散キャッシュへ退避。UI は Scoped の状態を直接バインドし、保存時にリポジトリへ反映する。

読取中心キャッシュ(Singleton)+失効戦略

参照マスタや設定値は Singleton にキャッシュ。失効は「時間+イベント(管理画面から更新通知)」のハイブリッド。更新通知は IHostApplicationLifetime や簡単な Pub/Sub(DB テーブルのバージョン番号等)で実装できる。

バックグラウンドキュー(Singleton)+スコープ境界

UI から非同期処理を投げ、キューのワーカーは IServiceScopeFactory でスコープを切って DB などにアクセス。Scoped のサービスをワーカーに渡して保持しない。

コード断片:安全なユーザーセッションサービス

public sealed class UserSessionService : IAsyncDisposable
{
    public Guid SessionId { get; } = Guid.NewGuid();

    // 表示名など、UI が頻繁に参照する値はプロパティで管理
    public string? DisplayName { get; set; }

    // ページングやフィルタ条件など UI 状態
    public int PageSize { get; set; } = 20;
    public string? Filter { get; set; }

    // Circuit 終了時の後片付け
    public ValueTask DisposeAsync()
    {
        // ここで外部リソース解放があれば実施
        return ValueTask.CompletedTask;
    }
}

トラブルシューティング

  • 別ユーザーのデータがちらつく:Singleton にユーザー依存データを持っていないかを点検。ログで Circuit ID とユーザー ID を出す。
  • たまに保存できない/古い値が残る:Scoped に長時間保持されたオブジェクトが壊れている可能性。保存直前に新しい短命オブジェクトにマッピングして永続化する。
  • パフォーマンス劣化:Singleton のロック競合や大規模キャッシュがボトルネック。分割キャッシュ、シャーディング、非同期 I/O の徹底。
  • プリレンダー後に値が消える:プリレンダーのスコープと Circuit のスコープが別である点を再確認。PersistentComponentState または再取得ロジックを実装。

セキュリティ視点の要点

  • Singleton に資格情報を置かない。ユーザー固有のトークンは Scoped または安全なトークンストレージに。
  • 多要素認証・権限更新が起きたら Scoped の状態をリセットできるように設計。
  • 個人情報は 短命+必要最小限の原則。Circuit 失効時に確実に破棄されるよう IDisposable/IAsyncDisposable を活用。

性能・メモリ運用のヒント

  • Scoped はタブ数に比例して増えるため、大きなオブジェクトを抱えさせない(大きなリストは都度取得+ページング)。
  • Singleton キャッシュはサイズ・期限・キーの設計が命。スライディング期限と絶対期限を使い分け、起動直後のウォームアップを用意。
  • GC 圧力の高い処理は ValueTask やプールを検討。非同期 I/O を徹底してスレッドプール詰まりを回避。

まとめ

  • Scoped(Server)= Circuit 単位。同一タブ内で共有、タブをまたいで共有されない。
  • Singleton=アプリ全体。ユーザー依存データは入れない。
  • WASM では Scoped=Singleton。タブ(アプリ インスタンス)ごとに独立。
  • プリレンダーと Circuit のスコープ差に注意。状態はデータとして渡し、インスタンスは渡さない。
  • DbContext は短命生成(IDbContextFactory 推奨)。
  • バックグラウンド処理は Singleton+スコープ切り出しで安全に。

クイックリファレンス(コピペ向け)

// Program.cs(要点のみ)
builder.Services.AddScoped&lt;UserSessionService&gt;();   // ユーザー/タブ固有
builder.Services.AddSingleton&lt;WorkQueueService&gt;();  // 全ユーザー共有
builder.Services.AddDbContextFactory&lt;AppDbContext&gt;(); // 長寿命 DbContext を避ける
// Scoped の動作検証
@inject UserSessionService Session
&lt;p&gt;SessionId: @Session.SessionId&lt;/p&gt;
// 同タブ=同値、別タブ=別値
// Singleton でキュー処理(スレッドセーフ)
public sealed class WorkQueueService
{
    private readonly ConcurrentQueue&lt;Func&lt;CancellationToken, Task&gt;&gt; _q = new();
    public void Enqueue(Func&lt;CancellationToken, Task&gt; work) =&gt; _q.Enqueue(work);
    public async Task RunAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            if (_q.TryDequeue(out var w)) await w(ct);
            else await Task.Delay(100, ct);
        }
    }
}

付録:設計判断のためのチェック票

質問Yes の場合No の場合
データはユーザーまたはタブ固有か?Scoped を検討Singleton または Transient
長時間保持が必要か?永続ストアへ退避し、Scoped/Singleton は参照にとどめる短命で済むなら Transient も可
状態は大きいか(大量のリストなど)?ページング・ストリーミング・軽量化を検討現状のままでも可
バックグラウンド処理が必要か?Singleton キュー+スコープ切り出しUI 内で完結

最後に

Blazor Server の Scoped は「Singleton と同じではなく、Circuit 単位」という一点を理解すれば、設計の迷いは大きく減ります。ユーザー固有の体験は Scoped と永続化で堅実に、アプリ全体の共通関心事は Singleton で効率的に——この役割分担を軸に、テストと監視を重ねていけば、スケールしても壊れない UI とサーバーを両立できます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次