Blazor を .NET 9 の既定テンプレートへ移植すると、Client と Server に Program.cs が分かれていて「AddScoped はどっち?」「Init はどこで?」と迷いがちです。本記事では、複数レンダーモードとプリレンダリングの仕組みから、DI 登録を安全に設計する実践パターンを整理します。
なぜ .NET 9 の Blazor で「サービス登録の置き場」が悩ましくなるのか
.NET 9 の既定テンプレート(複数レンダーモード前提の Blazor 構成)では、アプリがサーバーでもクライアント(WebAssembly)でも動き得るように設計されています。つまり、同じ Razor コンポーネントが次のように「場所を変えて」実行されることがあります。
- 初回表示はサーバーで HTML を生成(プリレンダリング)
- その後、接続状況や指定したレンダーモードに応じて Server Interactive で対話継続、または WebAssembly に切り替えて対話継続
ここで重要なのが、DI(依存性注入)のコンテナは Server と Client で別物という点です。以前の「WASM 用 Program.cs が 1 つ」だった頃は、AddScoped<IBrowserService, BrowserService>() の登録先に迷いませんでした。しかし今は、コンポーネントがサーバー側で起動するタイミングがあるため、サーバー側の DI コンテナにも登録が必要になるケースが出てきます。
要点:「そのサービスを Inject するコンポーネントが、どこで実行され得るか」を先に決めると、登録場所の迷いが消えます。
結論:サービスは「実行されうる場所(Server/Client)」それぞれに登録が要る
質問の核心である「サービス登録は Client / Server どちらに書くべきか」問題は、次の一文で整理できます。
そのサービスが解決(Inject)されるコンポーネントが、Server で実行される可能性があるなら Server にも登録する。WASM で実行される可能性があるなら Client にも登録する。
つまり、.NET 9 のテンプレート(複数レンダーモード想定)では「実行されうる場所それぞれに DI 登録が要る」のがポイントです。
レンダーモード別:どこでコンポーネントが動くか(=どの DI が使われるか)
| 状況/レンダーモード | 初回表示(プリレンダリング) | 対話処理(イベント/状態更新) | DI 登録が必要な場所 | 起きやすい問題 |
|---|---|---|---|---|
| Server Interactive | サーバー | サーバー | Server | JS 連携の初期化を早すぎるタイミングで実行して例外 |
| WebAssembly(Client) | (構成による)サーバーで事前描画することがある | ブラウザ(WASM) | Server と Client の両方(事前描画がある場合) | Server 側で未登録 → 事前描画で Inject 失敗 |
| Auto / 複合(複数レンダーモード) | サーバー | 状況により Server または WASM | Server と Client の両方 | 「どっちで動いてるか」を忘れて実装が片寄る |
「登録漏れ」の典型的な症状
Server 側の DI に登録していないサービスをプリレンダリング中に Inject すると、ページ表示時点で落ちます。例外メッセージは環境で多少違いますが、典型的には次のような内容です。
InvalidOperationException: Unable to resolve service for type '...IBrowserService...' while attempting to activate '...Component...'
「Client では動くのに、初回だけエラーになる」「環境によって再現性が低い」という場合は、まずこの DI 登録漏れを疑うのが近道です。
「Client に全部まとめた」場合、Server 側にも何か入れる必要がある?
結論は次のとおりです。
- Server 側で動くフェーズ(プリレンダリング/Server Interactive)で Inject されるなら、Server 側にも同じインターフェイスが解決できるようにする必要があります。
- ただし、Server 側にClient の実装そのものを持ち込む必要はありません(むしろ避けたい)。
理由はシンプルで、Server で動くコンポーネントは Server の DI コンテナに対して解決を試みます。ここで未登録だと実行時例外になり、画面自体が描画できません。
実務的に安全な設計:インターフェイスは共有、実装は環境ごとに分ける
ブラウザサイズ取得や Window Resize 監視のような JS Interop 前提のサービスは、サーバーでは同じ実装を動かせません。そこで定番になるのが次の形です。
| 置き場所 | 入れるもの | 目的 | ポイント |
|---|---|---|---|
| Shared(共有プロジェクト/クラスライブラリ) | Interfaces / Models / DTO | Server と Client の両方から参照できる「契約」を置く | Server が Client 実装に依存しにくくなり、保守が楽 |
| Client | ブラウザ依存(JS Interop)実装 | WASM 実行時に本来の機能を提供 | JS 初期化は「ブラウザで実行できるタイミング」に遅延させる |
| Server | NoOp(ダミー)実装 or Server 向け代替実装 | プリレンダリングや Server Interactive で DI 解決を成立させる | 落ちないことが最優先。必要な情報は接続後に更新する |
BlazorWindowResize 系サンプルを .NET 9 に移植する具体例
ここからは、よくある「ブラウザのリサイズを検知して UI を更新したい」ケースを例に、.NET 9 の構成へ寄せた実装イメージを示します。ポイントは登録場所と初期化タイミングです。
Shared:インターフェイスとモデルを切り出す
まずは Server/Client のどちらからも参照できるように、インターフェイスとデータ構造を Shared に置きます。
namespace MyApp.Shared.Browser;
public sealed record BrowserSize(int Width, int Height);
public interface IBrowserService
{
/// ブラウザ側のイベント購読など、JS 連携の初期化を行う
ValueTask InitializeAsync();
/// <summary>現在のウィンドウサイズを取得する</summary>
ValueTask<BrowserSize> GetSizeAsync();
/// <summary>リサイズ時の通知(Client 実装のみ発火)</summary>
event Action<BrowserSize>? Resized;
}
Client:本実装(JS Interop)を作る
Client 側では JS を呼べるので、Resize 監視の登録や window サイズ取得を実装できます。重要なのは、InitializeAsync を勝手に早期実行しないことです(理由は後述)。
using Microsoft.JSInterop;
using MyApp.Shared.Browser;
namespace MyApp.Client.Browser;
public sealed class BrowserService : IBrowserService, IAsyncDisposable
{
private readonly IJSRuntime _js;
private IJSObjectReference? _module;
private DotNetObjectReference? _dotNetRef;
public event Action<BrowserSize>? Resized;
public BrowserService(IJSRuntime js)
{
_js = js;
}
public async ValueTask InitializeAsync()
{
if (_module is not null) return;
// JS モジュールを遅延ロード(必要になったときだけ)
_module = await _js.InvokeAsync<IJSObjectReference>(
"import", "./js/browserService.js");
_dotNetRef = DotNetObjectReference.Create(this);
await _module.InvokeVoidAsync("init", _dotNetRef);
}
public async ValueTask<BrowserSize> GetSizeAsync()
{
await InitializeAsync();
return await _module!.InvokeAsync<BrowserSize>("getSize");
}
[JSInvokable]
public void NotifyResized(int width, int height)
=> Resized?.Invoke(new BrowserSize(width, height));
public async ValueTask DisposeAsync()
{
try
{
if (_module is not null)
{
await _module.InvokeVoidAsync("dispose");
await _module.DisposeAsync();
}
}
finally
{
_dotNetRef?.Dispose();
}
}
}
JS 側(例)は次のようなイメージです。
// wwwroot/js/browserService.js
let dotnetRef;
export function init(ref) {
dotnetRef = ref;
window.addEventListener("resize", onResize);
onResize(); // 初回通知(任意)
}
export function getSize() {
return { width: window.innerWidth, height: window.innerHeight };
}
function onResize() {
if (!dotnetRef) return;
dotnetRef.invokeMethodAsync("NotifyResized", window.innerWidth, window.innerHeight);
}
export function dispose() {
window.removeEventListener("resize", onResize);
dotnetRef = null;
}
Server:NoOp(ダミー)実装を用意する
Server 側で同じインターフェイスを解決できるように、機能を持たない実装を用意します。プリレンダリング中に JS を呼ぶことはできないので、「落とさない」ことを優先します。
using MyApp.Shared.Browser;
namespace MyApp.Server.Browser;
public sealed class NullBrowserService : IBrowserService
{
public event Action? Resized;
public ValueTask InitializeAsync()
=> ValueTask.CompletedTask;
public ValueTask<BrowserSize> GetSizeAsync()
// 事前描画中は実サイズが分からないため、0 や既定値にしておく
=> ValueTask.FromResult(new BrowserSize(0, 0));
}
「サイズが 0 だと困る」という場合は、レスポンシブ CSS に寄せる、もしくは初回はプレースホルダーを表示して後から更新する、といった UI 設計にしておくと事故が減ります。
Program.cs には何を書くべきか(Server / Client それぞれの登録例)
ここが一番迷うポイントなので、貼り替えやすい形で例を示します。テンプレートやプロジェクト名で多少変わりますが、「登録の考え方」は同じです。
Client の Program.cs:WASM 実行時に使う実装を登録
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MyApp.Client.Browser;
using MyApp.Shared.Browser;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// Client(WASM)で動くときの実装
builder.Services.AddScoped();
await builder.Build().RunAsync();
Server の Program.cs:プリレンダリング/Server Interactive で解決できるように登録
using MyApp.Server.Browser;
using MyApp.Shared.Browser;
var builder = WebApplication.CreateBuilder(args);
// Razor Components / レンダーモード関連の登録(テンプレート標準)
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
// Server 側で動くフェーズ用(NoOp)
builder.Services.AddScoped();
var app = builder.Build();
app.MapRazorComponents()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode();
app.Run();
登録を散らかさない小技:拡張メソッドでまとめる
Program.cs が肥大化してくると、どのサービスを登録しているか追いにくくなります。そこで、登録を拡張メソッドに寄せると保守が楽になります。
Client 側(Client プロジェクト内)
using Microsoft.Extensions.DependencyInjection;
using MyApp.Client.Browser;
using MyApp.Shared.Browser;
namespace MyApp.Client;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddClientAppServices(this IServiceCollection services)
{
services.AddScoped();
return services;
}
}
Server 側(Server プロジェクト内)
using Microsoft.Extensions.DependencyInjection;
using MyApp.Server.Browser;
using MyApp.Shared.Browser;
namespace MyApp.Server;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddServerAppServices(this IServiceCollection services)
{
services.AddScoped();
return services;
}
}
こうしておくと Program.cs は次のように読みやすくできます。
// Client Program.cs
builder.Services.AddClientAppServices();
// Server Program.cs
builder.Services.AddServerAppServices();
Init() を Program.cs で即実行しないほうがいい理由
BlazorWindowResize 系のサンプルでは、従来「Program.cs の最後で Init() を呼ぶ」形を見かけます。しかし .NET 9 の複数レンダーモード+プリレンダリングでは、この方式が不安定になりがちです。
- プリレンダリング中はブラウザが存在しない(サーバーで HTML を作っているだけ)
- JS Interop は、基本的にブラウザと接続できてからでないと成功しない
- 結果として「初回表示だけ例外」「環境によって再現性が低い」などの不具合が起きる
そこで安全策として、「ブラウザで実行できるタイミング」に初期化を遅延させます。代表的には OnAfterRenderAsync(bool firstRender) で firstRender のときだけ InitializeAsync を呼びます。
コンポーネント側:OnAfterRenderAsync で初期化する例
@using MyApp.Shared.Browser
@implements IDisposable
@inject IBrowserService Browser
現在の幅: @(_size.Width)
@code {
private BrowserSize _size = new(0, 0);
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
// ブラウザで動けるタイミングになってから初期化
await Browser.InitializeAsync();
_size = await Browser.GetSizeAsync();
Browser.Resized += OnResized;
StateHasChanged();
}
private void OnResized(BrowserSize size)
{
_size = size;
_ = InvokeAsync(StateHasChanged);
}
public void Dispose()
{
Browser.Resized -= OnResized;
}
}
どのサービスを「両方登録」にするべきか判断するチェックリスト
移植作業では、サービスごとに「Server と Client のどちらで解決される可能性があるか」を先に分類すると迷いが減ります。
| サービスの性質 | 例 | 推奨する配置 | 登録先 | 補足 |
|---|---|---|---|---|
| 純粋なビジネスロジック(環境非依存) | 計算、バリデーション、変換 | Shared / クラスライブラリ | Server と Client の両方 | 同一実装を共有しやすい |
| ブラウザ API / JS 依存 | window サイズ、localStorage、clipboard | Interface は Shared、実装は Client | Client は本実装、Server は NoOp | 事前描画中は値が取れない前提で UI を設計 |
| サーバー資源に依存 | DB、ファイル、サーバー側キャッシュ | Server | Server のみ | WASM から直接は呼べない。API 経由に寄せる |
| HTTP API 呼び出し | 外部 API、社内 API | 共有しつつ環境ごとに設定 | Server と Client の両方 | ベース URL や認証の仕組みが環境で変わる |
DI のライフタイムでハマらないための注意点
「AddScoped を使えば OK」と思いがちですが、Blazor は実行環境でライフタイムの意味が変わります。移植時に一度だけ押さえておくと、原因不明の状態共有バグを避けやすくなります。
| ライフタイム | Blazor Server(Server Interactive) | Blazor WebAssembly(Client) | 実務的な目安 |
|---|---|---|---|
| Scoped | ユーザー接続(Circuit)単位で共有 | ブラウザタブ単位で実質的に長生きしやすい | UI 状態を持つサービスはまず Scoped から検討 |
| Singleton | サーバー全体で 1 つ(全ユーザー共有) | ブラウザ内で 1 つ(タブ内共有) | ユーザーごとの状態を入れると事故りやすい |
| Transient | 解決のたびに新規 | 解決のたびに新規 | 軽量なユーティリティ向き |
今回のようなブラウザイベント購読サービスは、ユーザーごとに購読状態を持つため、まずは Scoped が扱いやすいです。
「片方だけ登録したい」場合に考えるべきこと
どうしても「IBrowserService は Client だけでいい」「Server に NoOp を置きたくない」という場合は、そのサービスを Inject するコンポーネントがサーバーで動かないように設計する必要があります。具体的には次の方向性があります。
- そのコンポーネントを WebAssembly 専用にし、プリレンダリングを無効化する(初回表示の SEO/体感速度は落ちやすい)
- サーバーで動く間は、そのサービスを使う UI を表示しない(「接続後に有効化」する)
プリレンダリングを無効化するイメージ(構文はプロジェクトにより差があるため、意図が伝わる形で記載します)
@using Microsoft.AspNetCore.Components.Web
@* WebAssembly でのみ動かし、事前描画はしない *@
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
ただしこの方法は「初回 HTML が薄くなる」「初回に JS が必須になる」などのトレードオフがあるため、SEO や初速を重視する場合は NoOp 実装のほうが無難です。
まとめ:移植で一番大事なのは「どこで実行されるか」を先に決めること
.NET 9 の Blazor では、テンプレートが最初から複数レンダーモードを意識しているため、「サービス登録の場所」は単なる好みではなく実行環境の設計になります。
- プリレンダリングや Server Interactive で Inject されるなら、Server の DI にも登録する
- WASM で使うなら、Client の DI にも登録する
- JS Interop の初期化は Program.cs で即実行せず、OnAfterRenderAsync などで遅延実行する
- Interfaces / Models は Shared(クラスライブラリ)に切り出すと依存関係が安定する
- Server 側は NoOp 実装で「落ちない」状態を作り、必要な処理は接続後に有効化する
この方針で移植を進めると、BlazorWindowResize のような「ブラウザと密接な機能」でも、.NET 9 の既定テンプレートに無理なく馴染ませられます。

コメント