.NET 9 Blazor Web AppのDIサービス登録はClient/Serverどっち?Program.cs分割とプリレンダリング対策

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サーバーサーバーServerJS 連携の初期化を早すぎるタイミングで実行して例外
WebAssembly(Client)(構成による)サーバーで事前描画することがあるブラウザ(WASM)Server と Client の両方(事前描画がある場合)Server 側で未登録 → 事前描画で Inject 失敗
Auto / 複合(複数レンダーモード)サーバー状況により Server または WASMServer と 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 / DTOServer と Client の両方から参照できる「契約」を置くServer が Client 実装に依存しにくくなり、保守が楽
Clientブラウザ依存(JS Interop)実装WASM 実行時に本来の機能を提供JS 初期化は「ブラウザで実行できるタイミング」に遅延させる
ServerNoOp(ダミー)実装 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、clipboardInterface は Shared、実装は ClientClient は本実装、Server は NoOp事前描画中は値が取れない前提で UI を設計
サーバー資源に依存DB、ファイル、サーバー側キャッシュServerServer のみ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 の既定テンプレートに無理なく馴染ませられます。

この記事を書いた人

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

コメント

コメントする

目次