zukucode
主にWEB関連の情報を技術メモとして発信しています。

ASP.NET Core 種類ごとの処理をまとめて条件分岐の修正漏れを減らす

注文や申請の種類が増えると、入力チェック、確認画面、保存処理などに、同じ種類を判定するifやswitchが増えることがあります。

1か所の分岐は短くても、「新しい種類を追加するときに、どの分岐も修正しなければならない」状態になると、変更箇所を探す負担が大きくなります。

今回は、ASP.NET Coreで注文の種類を扱う例を使って、同じ種類の変更で一緒に直す処理を、近くにまとめる方法を紹介します。

配送あり・配送なしの注文について、入力チェックと表示作成を種類ごとのクラスへまとめます。呼び出す側は、登録されたクラスから注文の種類に合うものを選びます。

条件分岐の数より、変更箇所の散らばりを見る

たとえば、「配送ありの注文では配送先が必要」というルールがあるとします。

入力チェックのメソッドでは配送先の必須条件を判定し、別の表示用メソッドでは配送先を確認画面へ追加します。

処理ごとに分けた構成の例
入力チェック
    配送ありなら、配送先を確認する
    配送なしなら、別の条件を確認する

確認画面の作成
    配送ありなら、配送先を表示する
    配送なしなら、別の内容を表示する

ここへ「店舗受け取り」を追加すると、入力チェックと画面作成の両方へ分岐を追加します。片方だけ直すと、受け取り店舗を入力できるのに確認画面へ出ない、といった不整合につながります。

そこで、種類ごとに処理をまとめます。

クラスまとめる処理
配送あり注文のルール配送先の入力チェック、配送先を含む表示作成
配送なし注文のルール配送なしで必要な入力チェックと表示作成
ルールの登録表注文の種類から使うクラスを選ぶ

すべてのswitchをなくすことが目的ではありません。種類を追加するときに、関連するルールをまとめて読めるようにすることが目的です。

共通の呼び出し方を決める

まず、各クラスが持つ処理をinterfaceで定義します。interfaceは、呼び出し側から利用できる機能を決めるものです。

IOrderRule.cs
public record OrderInput(string? ShippingAddress);

public interface IOrderRule
{
    string Kind { get; }
    string? Validate(OrderInput input);
    string CreateBody(OrderInput input);
}

Kindは注文の種類、Validateは入力チェック、CreateBodyは確認画面の説明文を作る処理です。

この例では、Validateがnullを返せば問題なし、文字列を返せばエラーメッセージとします。実際の注文で必要な金額や商品情報などは省略しています。

DIの基本については、以下の記事でも紹介しています。

DIでサービスをControllerから使用する方法

種類ごとのクラスを作る

配送ありの注文では、配送先を必須にします。

ShippingOrderRule.cs
public sealed class ShippingOrderRule : IOrderRule
{
    public string Kind => "shipping";

    public string? Validate(OrderInput input)
    {
        return string.IsNullOrWhiteSpace(input.ShippingAddress)
            ? "配送先を入力してください。"
            : null;
    }

    public string CreateBody(OrderInput input)
    {
        return $"配送先:{input.ShippingAddress}";
    }
}

配送なしの注文では、配送先を指定できないことにします。

DigitalOrderRule.cs
public sealed class DigitalOrderRule : IOrderRule
{
    public string Kind => "digital";

    public string? Validate(OrderInput input)
    {
        return string.IsNullOrWhiteSpace(input.ShippingAddress)
            ? null
            : "配送なしの注文には配送先を指定できません。";
    }

    public string CreateBody(OrderInput input)
    {
        return "配送はありません。";
    }
}

配送先の扱いを変更するときは、対応するクラスの入力チェックと表示を一緒に確認できます。

ここでまとめているのは、その種類の入力と説明に関する処理です。データベースへの保存やメール送信まで、同じクラスへ集める必要はありません。

種類からクラスを選ぶ登録表を作る

次に、Kindから対応するクラスを探す仕組みを作ります。このような登録表を、ここではRegistryと呼びます。

OrderRuleRegistry.cs
using System;
using System.Collections.Generic;
using System.Linq;

public sealed class OrderRuleRegistry
{
    private readonly Dictionary<string, IOrderRule> rules;

    public OrderRuleRegistry(IEnumerable<IOrderRule> rules)
    {
        this.rules = rules.ToDictionary(
            rule => rule.Kind,
            StringComparer.Ordinal);
    }

    public bool TryFind(string kind, out IOrderRule? rule)
    {
        return rules.TryGetValue(kind, out rule);
    }
}

Dictionaryは、キーから値を取り出すための型です。ここでは、shippingなどの文字列をキーにして、対応するルールを取り出します。

ToDictionaryは、受け取ったルールの一覧から辞書を作ります。同じKindが複数あると例外になるため、重複した登録を黙って上書きすることはありません。ただし、検出されるのはこのクラスを作成した時点です。必ずアプリの起動時に検出されるとは限らないため、登録内容の確認も必要です。

DIへ登録する

ASP.NET CoreのProgram.csへ、ルールと登録表を登録します。以下は.NET 8以降のASP.NET Coreで使える例です。

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddTransient<IOrderRule, ShippingOrderRule>();
builder.Services.AddTransient<IOrderRule, DigitalOrderRule>();
builder.Services.AddTransient<OrderRuleRegistry>();

var app = builder.Build();
app.MapControllers();
app.Run();

同じIOrderRuleとして複数のクラスを登録し、IEnumerable<IOrderRule>でまとめて受け取ります。複数登録の扱いは、Microsoft LearnのDIの説明でも確認できます。

AddTransientは、DIから要求されるたびにインスタンスを作る登録方法です。この例では、ルールと登録表をこの方法で登録します。

Controllerから利用する

Controllerでは、種類を探してから、そのクラスの処理を順に呼びます。

OrderPreviewController.cs
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/order-previews")]
public sealed class OrderPreviewController : ControllerBase
{
    private readonly OrderRuleRegistry registry;

    public OrderPreviewController(OrderRuleRegistry registry)
    {
        this.registry = registry;
    }

    [HttpPost("{kind}")]
    public IActionResult Create(string kind, [FromBody] OrderInput input)
    {
        if (!registry.TryFind(kind, out var rule) || rule is null)
        {
            return BadRequest(new { message = "未対応の注文種類です。" });
        }

        var error = rule.Validate(input);
        if (error is not null)
        {
            return BadRequest(new { message = error });
        }

        return Ok(new { body = rule.CreateBody(input) });
    }
}

入力チェックに成功した場合だけ、説明文を作成します。未知の種類を配送ありなどへ置き換えることはせず、エラーとして返します。

ここで示したAPIは説明文を返すための学習用です。注文の確定、利用者の認証、承認結果の保存は含めていません。

新しい種類を追加したときの変更箇所

「店舗受け取り」を追加するときは、主に次の場所を変更します。

  1. 必要なら、入力データへ受け取り店舗などの項目を追加する。
  2. 店舗受け取り用のクラスに、入力チェックと表示作成を書く。
  3. DIへそのクラスを登録する。
  4. 画面の選択肢と、種類ごとの動作確認を追加する。

Controllerの処理順序は、そのまま使えます。ただし、「クラスを1つ追加すればすべて完了する」とは限りません。入力形式や画面の選択肢も、機能追加に合わせて変更します。

応用として、画面の入力項目を表す定義や、「管理画面からだけ使える」といった利用条件も種類ごとのクラスへ持たせる方法があります。登録表から画面向けの選択肢を作れば、対応する種類の一覧を別々に管理する手間を減らせます。

一方、画面の項目定義と入力チェックの数値が、すべて自動で一致するわけではありません。たとえば、画面側の最大文字数とサーバー側の最大文字数を別々に書く場合は、まとめた後も両方の確認が必要です。

クラスを分けない方がよい場合もある

種類が2つだけで、分岐も短いメソッド1つに収まっているなら、switchの方が追いやすい場合があります。

この構成では、種類ごとのクラスに加え、interface、登録表、DI設定を読む必要があります。ファイル数を増やす負担と引き換えに、種類ごとの変更をまとめています。

目安になるのは分岐の行数よりも、同じ種類を追加・修正するたびに、離れた場所を何か所も直しているかです。

また、今回の例は入力データを1つにまとめています。種類によって項目が大きく異なり、使わない項目が増え始めたら、入力の型も種類ごとに分けることを検討します。その場合、JSONの読み取りなど、種類を問わず同じ処理だけを共通化する方法もあります。

修正漏れを見つける確認項目

クラスの分割だけでは、ルールや登録の正しさまでは保証できません。次のように、種類の選択とルールを分けて確認します。

確認すること期待する結果
配送ありで配送先が空入力エラーになる
配送なしで配送先がある入力エラーになる
配送ありの説明文を作る指定した配送先が含まれる
未登録の種類を送るエラーになり、別の種類として処理されない
同じ種類を二重に登録する登録表の作成時に検出できる
新しい種類をDIから取得する登録表で選択できる

利用できる画面やAPIを種類ごとに制限する場合は、その条件も確認項目へ追加します。

保守しやすくするためには、共通処理を増やすだけでなく、変更の理由が同じ処理を近くへ置くことも有効です。入力チェックと表示作成を同時に直すことが多いなら、種類ごとにまとめる構成を検討してみてください。


関連記事