Alternative Architecture DOJO

オルターブースのクラウドネイティブ特化型ブログです。

拡張性と保守性を高めるMediatR入門

こんにちは、オルターブース花岡です。@karyu721

本記事はオルターブース Advent Calendar 2024の11日目の記事です。

adventar.org

はじめに

ここ最近冷え込んでいますね。ダウンジャケットやコートが活躍する場面が増え、ますます本格的な冬が近づいているなあと感じるこの頃です。

さて、今回は.NETのライブラリMediatRを使ってCQS(コマンドクエリ分離の原則)やインプロセス内でのメッセージング処理について学んだので、コードを交えて解説します。

MediatRとは

github.com

MediatRはデザインパターンの一つであるMediatorパターンを.NETで実装したライブラリです。 Mediatorは日本語で"仲介者”という意味で、Mediatorパターンはオブジェクト間の通信を仲介者(Mediator)オブジェクトに委ねることで、オブジェクト同士の直接的な依存関係を減らし、システムの柔軟性と保守性を向上させるデザインパターンです。

Mediatorパターンのイメージ

MediatRはインプロセス内でのオブジェクトのメッセージング処理を非常に得意としており、近年話題となっているCQSやCQRSを.NETで実現する上で非常に重要なライブラリの一つとなっています。 MediatRを使うことにより、インプロセス内でのオブジェクト間のメッセージング処理が容易になります。

ハンドラー

MediatRはハンドラーと呼ばれる特定のリクエストを処理するためのクラスまたはインターフェースがあります。MediatRでは、IRequestHandlerインターフェースを実装することにより、リクエストを処理するハンドラーを定義します。

/// <summary>
/// Defines a handler for a request
/// </summary>
/// <typeparam name="TRequest">The type of request being handled</typeparam>
/// <typeparam name="TResponse">The type of response from the handler</typeparam>
public interface IRequestHandler<in TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    /// <summary>
    /// Handles a request
    /// </summary>
    /// <param name="request">The request</param>
    /// <param name="cancellationToken">Cancellation token</param>
    /// <returns>Response from the request</returns>
    Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken);
}

github.com

下記は簡単なハンドラーの定義実装例です。

public class FooHandler : IRequestHandler<Foo, Bar>
{
    public Task<Bar> Handle(Foo request, CancellationToken cancellationToken)
    {
        // リクエストに対する処理
        // データベースへのアクセスやビジネスロジックの実行など行う
        return Task.FromResult(new Bar { Message = "Handled" });
    }
}

ハンドラーを通してリクエストを処理することにより、リクエスト単位のテスト容易性、ハンドラーの再利用性、既存のコードに影響を与えることなく新機能の追加が行える拡張性などメンテナンス性と開発効率の向上が見込めるようになります。

ショッピングカートを例にした実装サンプル

ここまでMediatRの概要の説明をしました。ここからは、簡単なショッピングカートをMinimalAPIで実装した例を元にどのように実装していくのかを解説します。

サンプルコードは下記リポジトリをご参照ください。

github.com

今回のサンプルではフォルダでアプリケーション層、ドメイン層、インフラストラクチャー層といったレイヤードアーキテクチャのような階層構造にしています。本来であれば、プロジェクトごとに分けるべきですが、今回は簡易的な実装例のため実施しておりません。

MediatRの追加

既存のASP.NET CoreにMediatRを追加します。

dotnet add package MediatR

次にMediatRの依存関係の注入をProgram.csに以下のコード記述します。

builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssemblyContaining<Program>());

これでMediatRのハンドラーを書く準備ができました。

ショッピングカートのドメインモデル

ショッピングカートと商品のモデルを定義します。 カートの商品追加、削除など基本的な機能があるようにしています。

カート

public class Cart
{
    public Guid Id { get; init; }
    public IList<CartItem> Items { get; init; }

    // Parameterless constructor
    public Cart()
    {
        Id = Guid.NewGuid();
        Items = new List<CartItem>();
    }

    // Constructor with parameters
    public Cart(Guid id, IList<CartItem> items)
    {
        Id = id;
        Items = items;
    }


    public Cart AddItem(CartItem item)
    {
        Items.Add(item);
        return new Cart(Id, Items);
    }

    public Cart RemoveItem(Guid productId)
    {
        var item = FindCartItem(productId);
        if (item == null)
        {
            return this;
        }
        Items.Remove(item);
        return new Cart(Id, Items);
    }

    public Cart UpdateItem(CartItem item)
    {
        var index = Items.ToList().FindIndex(x => x.ProductId == item.ProductId);
        if (index < 0)
        {
            throw new InvalidOperationException("Item not found");
        }

        var newItems = Items.ToList();
        newItems[index] = item;
        return new Cart(Id, newItems);
    }
 // 中略
}

商品

public class CartItem
{
    public Guid Id { get; private set; } = Guid.NewGuid();
    public Guid ProductId { get; set; }
    public int Quantity { get; private set; }
    
    public decimal Price { get; set; } = 100;

    // Parameterless constructor for EF Core
    private CartItem() { }

    public CartItem(Guid productId, int quantity)
    {
        ProductId = productId;
        Quantity = quantity;
    }

    public CartItem UpdateQuantity(int quantity)
    {
        return new CartItem(ProductId, quantity);
    }
    
    public decimal TotalPrice()
    {
        return Price * Quantity;
    }

データの操作を行うためのリポジトリインターフェース

public interface ICartRepository
{
    Task<Carts.Cart?> GetCartByIdAsync(Guid cartId);
    Task<Guid> AddCartAsync(Carts.Cart cart);
    Task<bool> RemoveCartAsync(Guid userId);
    Task<IList<Cart>> AllCartsAsync();
}

サンプルではインメモリDBを使っていますが、ドメイン層から技術的な関心事から分離するためインターフェースのみを定義しています。

コマンドハンドラーの実装

今回、CQSやCQRSの考えに基づき、状態の変更を行うリクエスト(追加、更新、削除)をコマンド、データの読み取りだけを行うリクエストをクエリと分けています。 そうすることによって各操作の責務が明確に分離され、コードの理解と保守性の向上につながります。

追加を例にコマンドハンドラーの実装のコードを書いていきます。

カートの追加コマンド

public record AddCartItemCommand(Guid CartId, Guid ProductId, int Quantity) : IRequest<Guid>;

IRequestはリクエストのデータの型を表すためのマーカーインターフェースでこれを元に各ハンドラーへの処理の通知を行います。

カートの商品追加を行うハンドラー

public class AddCartItemCommandHandler(ICartRepository repository) : IRequestHandler<AddCartItemCommand, Guid>
{
    public async Task<Guid> Handle(AddCartItemCommand request, CancellationToken cancellationToken)
    {
        var cart = await repository.GetCartByIdAsync(request.CartId) ?? new Cart();
        cart.AddItem(new CartItem(request.ProductId, request.Quantity));
        await repository.AddCartAsync(cart);
        return cart.Id;
    }
}

コマンドハンドラークラスはコマンドに基づき、タスクベースの処理を行います。一連の処理はトランザクション単位となり、結果は正常に処理されるか、例外が返されるかのどちらかとなります。

上記の例は単純化のためデータベースにないカートは新規カートとして扱っています。

次にハンドラーを実装したため、カートに商品を追加するAPIをMinimal APIで実装します。

// Configure the HTTP request pipeline.
app.MapPost("/carts/items", async (IMediator mediator, [FromBody] AddCartItemCommand command) =>
{
    var cartId = await mediator.Send(command);
    return Results.Created($"/carts/{cartId}", cartId);
});

MediatRにコマンドを渡して該当するハンドラーに処理を依頼する流れになっています。 ハンドラーのおかげで非常に薄いAPIのエンドポイント実装になっています。

クエリハンドラーの実装

次に、クエリを実行するためのハンドラーの実装を見ていきます。

カートのコンテンツ取得クエリ

public record GetCartContentsQuery(Guid CartId) : IRequest<CartResponse>;

カートIDに基づき、レスポンスにCartResponseオブジェクトが返ってくることを表明しています。

レスポンスクラス

public record CartResponse
{
    public Guid Id { get; set; }
    public List<CartItemResponse> Items { get; set; } = new();
    public decimal TotalAmount { get; set; }
}

public record CartItemResponse(Guid ProductId, int Quantity);

クエリハンドラークラス

public class GetCartContentsQueryHandler(ICartRepository repository): IRequestHandler<GetCartContentsQuery, CartResponse>
{
    public async Task<CartResponse> Handle(GetCartContentsQuery request, CancellationToken cancellationToken)
    {
        var cart = await repository.GetCartByIdAsync(request.CartId);
        if (cart == null) return new CartResponse();

        return new CartResponse
        {
            Id = cart.Id,
            Items = cart.Items.Select(item => new CartItemResponse
            (item.ProductId, item.Quantity))
                .ToList()
        };
    }
}

コマンドハンドラークラスと同じようにクエリに基づいた処理を行います。今回はリポジトリパターンを使用していますが、別途ドメインモデルとは違う読み取り専用のモデル(リードモデル)からレスポンスを返すこともできます。それにより最適なデータベースやインデックスを使用でき、読み取りと書き込みのパフォーマンスを個別に最適化できます。

APIの実装

app.MapGet("/carts/{id}", async (IMediator mediator, Guid id) =>
{
    var cart = await mediator.Send(new GetCartContentsQuery(id));
    return Results.Ok(cart);
});

クエリハンドラーにおいても非常に薄いAPIのエンドポイント実装となっています。

おわりに

今回簡易的ではありましたが、MediatRを使ったWebAPI実装をやってみて非常に拡張性や保守性、変更容易性の高いアプリケーションができそうだとだなと感触を得ました。 実用的なコードにするには、エラーハンドリングやテストの部分を勉強する必要がありますが、GitHubでスターが11kを超える非常に人気の高いライブラリである理由も納得できるものでした。引き続き勉強を続けてDDDやCQRSの実装まで落とし込めるようにしたいです。