Start Debugging

.NET 11 で MediatR からシンプルな依存性注入へ移行する

MediatR 12-14 を削除し、IRequest ハンドラー、ISender、pipeline behavior、INotification をシンプルなサービスクラスとコンストラクター注入で置き換えるためのステップバイステップのチェックリスト。

.NET 11 のコードベースから MediatR を削除するのは、書き直しではなく機械的なリファクタリングです。ハンドラーが 50-150 個ある典型的なサービスでは、半日から 1 日を見込んでください。ほとんどのハンドラーはシンプルなサービスクラスのメソッドへ 1 対 1 で畳み込まれ、ISender.Send の呼び出しは直接のインターフェース呼び出しになり、設計上の検討を要する唯一の部分はパイプラインです。壊れるのは、runtime でのハンドラー解決や、すべてのリクエストを IPipelineBehavior<,> で包む仕組みに依存していたものすべてです。それらはコンストラクター注入とデコレーターになります。これを行う価値があるのは、Send しか呼び出していない場合、MediatR の Community エディションの $5,000,000 の売上ラインを超えていて商用ライセンスを購入したくない場合、あるいは Native AOT と trimming への適合性が欲しい場合です。pipeline behavior が数百のリクエスト型にわたって不可欠である場合は、行う価値はありません。

参照するバージョン: このガイドは MediatR 12.5.0(Apache 2.0 ライセンス下の最後のリリース)、13.0(2025-07-02 リリース、Lucky Penny Software による Reciprocal Public License 1.5 / 商用のデュアルモデル初のリリース)、および現行の 14.x 系の削除を扱います。置き換えるコードは <TargetFramework>net11.0</TargetFramework> を .NET 11 SDK と C# 14 で対象とし、デコレーターには Scrutor 6.x を使います。移行すべきかどうかをまだ検討中であれば、まず MediatR 対シンプルなサービスクラス(2026 年) を読んでください。この記事は決定済みであることを前提とします。

なぜチームは今まさに MediatR を削除しているのか

何が壊れるか

領域変更深刻度
ISender / IMediator の注入直接注入される具体的なサービスインターフェースに置き換え
IRequest<T> + IRequestHandler<,>1 つのインターフェース + 実装メソッドに畳み込まれる
IPipelineBehavior<,>汎用的なラップ地点なし。インターフェースごとにデコレーターまたは middleware で置き換え
INotification + INotificationHandler<>fan-out 用の注入された IEnumerable<IHandler> またはイベントアグリゲーターで置き換え
AddMediatR(...) 登録明示的な AddScoped 呼び出し(または 1 回の Scrutor スキャン)で置き換え
behavior 内の RequestHandlerDelegate<T>等価物なし。“next” チェーンは消える
ISender.CreateStream (IStreamRequest)IAsyncEnumerable<T> を返すメソッドで置き換え
ISender をモックするユニットテスト具体的なインターフェースのモックへ向け直す

事前チェックリスト

# .NET 11 - inventory MediatR usage before touching anything
grep -rn "IRequestHandler\|IRequest<\|: IRequest" src/      # handlers + requests
grep -rn "IPipelineBehavior" src/                            # the hard part
grep -rn "INotification" src/                                # fan-out
grep -rn "ISender\|IMediator\|\.Send(\|\.Publish(" src/      # call sites

移行手順

  1. 各リクエストとハンドラーをサービスインターフェースとメソッドへ変換する。

MediatR のリクエストはメッセージ型と別個のハンドラーです。両方を 1 つのインターフェースと 1 つの実装で置き換えます。古いリクエストごとにインターフェースを作るのではなく、関連するリクエストを 1 つのサービスにまとめてください。

// .NET 11, C# 14, MediatR 14.x - BEFORE
using MediatR;

public record GetOrderById(int OrderId) : IRequest<OrderDto>;

public sealed class GetOrderByIdHandler(AppDbContext db)
    : IRequestHandler<GetOrderById, OrderDto>
{
    public async Task<OrderDto> Handle(GetOrderById request, CancellationToken ct)
    {
        var order = await db.Orders.FindAsync([request.OrderId], ct)
            ?? throw new OrderNotFoundException(request.OrderId);
        return order.ToDto();
    }
}
// .NET 11, C# 14 - AFTER: plain service, no MediatR reference
public interface IOrderService
{
    Task<OrderDto> GetByIdAsync(int orderId, CancellationToken ct);
}

public sealed class OrderService(AppDbContext db) : IOrderService
{
    public async Task<OrderDto> GetByIdAsync(int orderId, CancellationToken ct)
    {
        var order = await db.Orders.FindAsync([orderId], ct)
            ?? throw new OrderNotFoundException(orderId);
        return order.ToDto();
    }
}

検証: 新しいファイルに using MediatR; がなく、メソッドシグネチャを除いてハンドラーの本体がバイト単位で同一であること。リクエストの record のパラメーターは、同じ順序でメソッドのパラメーターになります。

  1. ISender.Send の呼び出し箇所を直接のインターフェース呼び出しで置き換える。

ISender または IMediator を注入していた各呼び出し元は、特定のサービスインターフェースを注入してメソッドを呼ぶようになります。

// .NET 11, C# 14 - BEFORE
public async Task<OrderDto> Get(int id, ISender sender, CancellationToken ct)
    => await sender.Send(new GetOrderById(id), ct);

// AFTER
public async Task<OrderDto> Get(int id, IOrderService orders, CancellationToken ct)
    => await orders.GetByIdAsync(id, ct);

検証: この手順の後、grep -rn "ISender\|IMediator" src/ が返すのは、意図的にまだ移行していない行だけになります。件数はゼロへ向かって進むはずです。

  1. サービスを明示的に登録する。

AddMediatR(...) を削除し、各サービスを登録します。明示的な登録は trimming に対して安全で、起動時に素早く失敗します。これはまさに Unable to resolve service for type while attempting to activate の背後にある失敗モードです。

// .NET 11, C# 14 - BEFORE
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssemblyContaining<GetOrderById>());

// AFTER - explicit, or one Scrutor scan if you prefer convention
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<ICustomerService, CustomerService>();

MediatR のリフレクションモデルなしで規約ベースの登録が欲しい場合、Scrutor はインターフェース名でスキャンします。

// .NET 11, C# 14, Scrutor 6.x - register every *Service against its interface
builder.Services.Scan(scan => scan
    .FromAssemblyOf<OrderService>()
    .AddClasses(c => c.Where(t => t.Name.EndsWith("Service")))
    .AsImplementedInterfaces()
    .WithScopedLifetime());

検証: アプリが起動します。実行して移行済みのエンドポイントにアクセスしてください。登録の欠落は、最初のリクエストではなく起動時にスローされるようになります。singleton から scoped を取得する誤りが紛れ込んでいないことを確認します。これは Cannot consume scoped service from singleton で扱われているバグのクラスです。

  1. pipeline behavior をデコレーターまたは middleware で置き換える。

これは判断を要する唯一の手順です。IPipelineBehavior<,> はすべてのリクエストを 1 か所で包んでいました。誠実な置き換えは 2 つあります。

純粋にサービスごとの関心事(ログ出力、キャッシュ、リトライ)には、Scrutor のデコレーターを使います。

// .NET 11, C# 14, Scrutor 6.x - BEFORE was a generic ValidationBehavior<TRequest,TResponse>
public sealed class LoggingOrderService(
    IOrderService inner,
    ILogger<LoggingOrderService> logger) : IOrderService
{
    public async Task<OrderDto> GetByIdAsync(int orderId, CancellationToken ct)
    {
        logger.LogInformation("Fetching order {OrderId}", orderId);
        return await inner.GetByIdAsync(orderId, ct);
    }
}

// Registration: wrap the real implementation
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.Decorate<IOrderService, LoggingOrderService>();

実は HTTP の関心事である関心事(リクエストのログ出力、例外から ProblemDetails への対応付け、認証)については、アプリケーション層から完全に取り出して ASP.NET Core middleware またはエンドポイントフィルターへ移してください。例外を捕捉してレスポンスを整形していた behavior は、デコレーターではなく ASP.NET Core 11 のグローバル例外フィルター と同じ場所に属します。

ValidationBehavior 内で FluentValidation を使っていた場合は、バリデーターを残し、1 つのデコレーターまたは minimal API のエンドポイントフィルターから呼び出します。

// .NET 11, C# 14 - a validation decorator replacing ValidationBehavior for one interface
public sealed class ValidatingOrderService(
    IOrderService inner,
    IValidator<CreateOrder> validator) : IOrderService
{
    public async Task<OrderDto> CreateAsync(CreateOrder cmd, CancellationToken ct)
    {
        await validator.ValidateAndThrowAsync(cmd, ct);
        return await inner.CreateAsync(cmd, ct);
    }

    public Task<OrderDto> GetByIdAsync(int orderId, CancellationToken ct)
        => inner.GetByIdAsync(orderId, ct);
}

検証: 横断的な関心事がまだ実行されることを表明するテストを書くか残します。たとえば、無効なコマンドがデータベースに触れる前に ValidationException をスローすること。dotnet test を実行し、behavior のテストが通ることを確認します。

  1. INotification の fan-out を置き換える。

MediatR の Publish はすべての INotificationHandler<T> を呼び出していました。あなたが定義する小さなハンドラーインターフェースの注入された IEnumerable<T> と、薄いパブリッシャーで置き換えます。

// .NET 11, C# 14 - BEFORE: INotification + handlers, AFTER: explicit fan-out
public interface IOrderPlacedHandler
{
    Task HandleAsync(OrderPlaced evt, CancellationToken ct);
}

public sealed class OrderEventPublisher(IEnumerable<IOrderPlacedHandler> handlers)
{
    public async Task PublishAsync(OrderPlaced evt, CancellationToken ct)
    {
        foreach (var handler in handlers)
            await handler.HandleAsync(evt, ct);
    }
}

// Registration - each handler registered against the interface
builder.Services.AddScoped<IOrderPlacedHandler, SendConfirmationEmail>();
builder.Services.AddScoped<IOrderPlacedHandler, UpdateInventory>();
builder.Services.AddScoped<OrderEventPublisher>();

コンテナーは登録済みの各ハンドラーを IEnumerable<IOrderPlacedHandler> で渡してくれるので、ハンドラーの追加は登録 1 行で済みます。これは MediatR が提供していたのと同じ使い勝手です。多くのイベント型を発行する場合は、イベントごとに 1 つではなく、汎用の IEventPublisher<T> でパブリッシャーを生成してください。

検証: イベントの発行が登録済みのすべてのハンドラーを呼び出すことを表明します。どちらも呼び出しを記録する 2 つの偽ハンドラーを使うテストで十分です。

  1. パッケージと最後の参照を削除する。

呼び出し箇所がなくなったら、依存関係を外します。

# .NET 11 - remove the package from every project that referenced it
dotnet remove package MediatR
grep -rn "MediatR" src/ || echo "clean"

検証: grep -rn "MediatR" src/clean を表示します。ビルドに未解決の using MediatR; がなく、dotnet build -c Release が欠落パッケージに関する警告なしで成功します。

検証: 移行後のスモークテスト

このリストを上から下まで実行し、パフォーマンスの行を飛ばさないでください。

ロールバック計画

この移行はコミット単位で可逆ですが、一括で元に戻すのは面倒なので、段階的に進めてください。6 つの手順それぞれを別個のコミットとして保持し、ソリューション全体を一度にではなく、垂直スライス(1 つのサービスインターフェースとその呼び出し元)を一度に 1 つずつ移行します。移行中、MediatR とシンプルなサービスは共存できます。残りを変換する間、未移行のハンドラー用に AddMediatR を登録したままにしてください。スライスが誤動作したら、そのスライスのコミットを元に戻せば、アプリの残りは影響を受けません。ここにはデータやスキーマの変更がないため、ロールバックは元に戻すべき migration のない純粋なコードの revert です。

私たちがはまった落とし穴

IPipelineBehavior の順序はデコレーターへきれいに対応付かない。 MediatR は 1 つのハンドラーの周りで登録順に behavior を実行します。Scrutor のデコレーターは Decorate を呼ぶ順序で入れ子になり、最後に登録したデコレーターが最も外側のラッパーです。順序を誤ると、ログ出力のデコレーターがバリデーションのデコレーターの周りではなく内側で実行されます。デコレートしたインターフェースごとに順序のテストを 1 つ書いてください。

別のハンドラーを呼ぶハンドラー内の ISender は直接のサービス呼び出しになり、それが循環を露呈させることがある。 MediatR の間接化はハンドラー間の依存関係を隠していました。OrderServiceICustomerService を注入し、CustomerServiceIOrderService を注入すると、コンテナーは循環依存により起動時にスローします。これは移行が、MediatR が覆い隠していた設計上の問題を表面化させているのです。共有ロジックを 3 つ目のサービスに抽出して循環を断ち切ってください。

ストリーミングリクエストには Task<T> ではなく IAsyncEnumerable<T> が必要。 IStreamRequest<T>CreateStream を使っていた場合、置き換えるメソッドは IAsyncEnumerable<T> を返し、yield return を使います。Task<List<T>> に畳み込まないでください。それはストリーミングのセマンティクスを変え、大きな結果でメモリを使い果たすことがあります。これは .NET 11 で大きな CSV をメモリ不足にならずに読む で説明されているのと同じ罠です。

ISender をモックしていたテストは、何もないものに対して静かに通る。 sender.Send(...) が値を返すよう設定したテストは、ISender がなくなるとコンパイルエラーになります。これは良いことです。しかし、本物の IMediator を注入してそれを通して振る舞いを表明していたテストは、具体的なインターフェースへ向け直す必要があります。スイート全体を再実行し、グリーンのビルドだけを信用しないでください。

これは、System.Text.Json 対 Newtonsoft.Json(2026 年) で組み込みのシリアライザーを選ぶのと同じ形で報われる依存関係の合理化です。ホットパス上のサードパーティライブラリが 1 つ減り、ライセンスの問いが 1 つ減り、新しいチームメイトがまずディスパッチの規約を学ばずに辿れるコードになります。

関連記事

参考資料

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る