Blazor Server の依存性注入(DI)で Scoped と Singleton をどう使い分けるべきか——特に「Scoped は Singleton と同じに見える」という情報で混乱しがちな点を、Circuit(ブラウザ タブ単位の接続)の仕組みから丁寧に解説します。WASM(クライアント側 Blazor)との違いや、実装チェックリスト、よくある落とし穴まで一気に整理し、明日からの設計に自信が持てる内容にまとめました。
Blazor Server における Scoped と Singleton の違いまとめ
まずは結論から。以下の表だけで全体像を掴めます。
| ライフタイム | インスタンスの共有範囲 | 主な用途 | 破棄タイミング |
|---|---|---|---|
| Scoped | 1 Circuit(=1 ブラウザタブ)内で共有。別タブ・別ユーザーとは共有されない | ユーザー/タブ固有の状態(ログイン中のユーザー情報、ウィザードの途中経過、ショッピングカートなど) | Circuit が終了(タブを閉じる、長時間切断して再接続できない等) |
| Singleton | アプリ全体で共有。すべての Circuit・全ユーザーが同じインスタンスを使う | 共有キャッシュ、バックグラウンド処理キュー、設定・参照データのリポジトリ、監視・テレメトリ | アプリケーション停止(プロセス終了/App Pool リサイクルなど) |
| (参考)WASM の Scoped | WASM ではスコープの概念がないため、実質 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 開始後に別スコープになり、意図どおりに引き継がれない。
実装チェックリスト
- 登録方法の確認(Program.cs)
// ASP.NET Core 7 以前(例)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddServerSideBlazor();
// ユーザー/タブ固有の状態
builder.Services.AddScoped<UserSessionService>();
// 全ユーザー共有の処理キュー
builder.Services.AddSingleton<WorkQueueService>();
// EF Core は短命に作る(推奨)
builder.Services.AddDbContextFactory<AppDbContext>(); // <= これが安全
// builder.Services.AddDbContext<AppDbContext>(); // 長寿命になりやすく非推奨
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<UserSessionService>();
builder.Services.AddSingleton<WorkQueueService>();
builder.Services.AddDbContextFactory<AppDbContext>();
var app = builder.Build();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run();
- 動作テスト:Scoped の共有範囲を可視化
public sealed class UserSessionService
{
public Guid SessionId { get; } = Guid.NewGuid();
// 例:ユーザー名や権限など Circuit 固有の状態
public string? DisplayName { get; set; }
}
@* 任意のコンポーネント *@
@inject UserSessionService Session
<p>Scoped の SessionId: @Session.SessionId</p>
@code {
protected override void OnInitialized()
{
// 同一タブ内の複数コンポーネントで値が一致することを確認
}
}
- 同じタブで別コンポーネントを開く:同じ GUID が表示される。
- もう一つ別タブで同じページを開く:異なる GUID が表示される(別 Circuit)。
- スレッドセーフ設計(Singleton 使用時)
public sealed class WorkQueueService
{
private readonly System.Collections.Concurrent.ConcurrentQueue<Func<CancellationToken, Task>> _queue
= new();
public void Enqueue(Func<CancellationToken, Task> work) => _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でスコープを作って取り出す。
- _Host.cshtml(またはホストページ)での注入に注意
- _Host で解決されるサービスはHTTP リクエストスコープ(プリレンダー段階)の可能性がある。
- ここで状態を設定しても Circuit で別スコープになり、Scoped の挙動を誤解しやすい。
- プリレンダーで必要なデータは 読み取り専用にし、状態の更新は Circuit 確立後に行う。
- 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<AppDbContext> _factory;
public OrderRepository(IDbContextFactory<AppDbContext> factory)
=> _factory = factory;
public async Task<Order?> FindAsync(Guid id, CancellationToken ct = default)
{
await using var db = await _factory.CreateDbContextAsync(ct);
return await db.Orders.AsNoTracking().FirstOrDefaultAsync(x => 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<LoggingCircuitHandler> _logger;
public LoggingCircuitHandler(ILogger<LoggingCircuitHandler> logger)
=> _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<CircuitHandler, LoggingCircuitHandler>();
さらに、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<UserSessionService>(); // ユーザー/タブ固有
builder.Services.AddSingleton<WorkQueueService>(); // 全ユーザー共有
builder.Services.AddDbContextFactory<AppDbContext>(); // 長寿命 DbContext を避ける
// Scoped の動作検証
@inject UserSessionService Session
<p>SessionId: @Session.SessionId</p>
// 同タブ=同値、別タブ=別値
// Singleton でキュー処理(スレッドセーフ)
public sealed class WorkQueueService
{
private readonly ConcurrentQueue<Func<CancellationToken, Task>> _q = new();
public void Enqueue(Func<CancellationToken, Task> work) => _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 とサーバーを両立できます。

コメント