ASP.NET Core 種類ごとの処理をまとめて条件分岐の修正漏れを減らす
注文や申請の種類が増えると、入力チェック、確認画面、保存処理などに、同じ種類を判定するifやswitchが増えることがあります。
1か所の分岐は短くても、「新しい種類を追加するときに、どの分岐も修正しなければならない」状態になると、変更箇所を探す負担が大きくなります。
今回は、ASP.NET Coreの承認機能の実装をもとに、同じ種類の変更で一緒に直す処理を、近くにまとめる方法を紹介します。
元の実装では、操作の種類ごとに入力チェックと表示データの作成をまとめ、登録されたクラスから対象を選んでいます。ここでは、配送あり・配送なしの注文を題材に、C#の短い例へ置き換えて説明します。実際の障害や改修前のコードを再現したものではありません。
条件分岐の数より、変更箇所の散らばりを見る
たとえば、「配送ありの注文では配送先が必要」というルールがあるとします。
入力チェックのメソッドでは配送先の必須条件を判定し、別の表示用メソッドでは配送先を確認画面へ追加します。
処理ごとに分けた構成の例入力チェック
配送ありなら、配送先を確認する
配送なしなら、別の条件を確認する
確認画面の作成
配送ありなら、配送先を表示する
配送なしなら、別の内容を表示するここへ「店舗受け取り」を追加すると、入力チェックと画面作成の両方へ分岐を追加します。片方だけ直すと、受け取り店舗を入力できるのに確認画面へ出ない、といった不整合につながります。
そこで、種類ごとに処理をまとめます。
| クラス | まとめる処理 |
|---|---|
| 配送あり注文のルール | 配送先の入力チェック、配送先を含む表示作成 |
| 配送なし注文のルール | 配送なしで必要な入力チェックと表示作成 |
| ルールの登録表 | 注文の種類から使うクラスを選ぶ |
すべてのswitchをなくすことが目的ではありません。種類を追加するときに、関連するルールをまとめて読めるようにすることが目的です。
共通の呼び出し方を決める
まず、各クラスが持つ処理をinterfaceで定義します。interfaceは、呼び出し側から利用できる機能を決めるものです。
IOrderRule.cspublic record OrderInput(string? ShippingAddress);
public interface IOrderRule
{
string Kind { get; }
string? Validate(OrderInput input);
string CreateBody(OrderInput input);
}Kindは注文の種類、Validateは入力チェック、CreateBodyは確認画面の説明文を作る処理です。
この例では、Validateがnullを返せば問題なし、文字列を返せばエラーメッセージとします。実際の注文で必要な金額や商品情報などは省略しています。
DIの基本については、以下の記事でも紹介しています。
種類ごとのクラスを作る
配送ありの注文では、配送先を必須にします。
ShippingOrderRule.cspublic 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.cspublic 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.csusing 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.csvar 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.csusing 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は説明文を返すための学習用です。注文の確定、利用者の認証、承認結果の保存は含めていません。
新しい種類を追加したときの変更箇所
「店舗受け取り」を追加するときは、主に次の場所を変更します。
- 必要なら、入力データへ受け取り店舗などの項目を追加する。
- 店舗受け取り用のクラスに、入力チェックと表示作成を書く。
- DIへそのクラスを登録する。
- 画面の選択肢と、種類ごとの動作確認を追加する。
Controllerの処理順序は、そのまま使えます。ただし、「クラスを1つ追加すればすべて完了する」とは限りません。入力形式や画面の選択肢も、機能追加に合わせて変更します。
題材の実装では、さらに、画面の入力項目を表す定義や、その種類を利用できる作成経路も種類ごとのクラスが持っています。登録表は、その情報を使って画面向けの一覧を作っています。
一方、画面の項目定義と入力チェックの数値が、すべて自動で一致するわけではありません。たとえば、画面側の最大文字数とサーバー側の最大文字数を別々に書く場合は、まとめた後も両方の確認が必要です。
クラスを分けない方がよい場合もある
種類が2つだけで、分岐も短いメソッド1つに収まっているなら、switchの方が追いやすい場合があります。
この構成では、種類ごとのクラスに加え、interface、登録表、DI設定を読む必要があります。ファイル数を増やす負担と引き換えに、種類ごとの変更をまとめています。
目安になるのは分岐の行数よりも、同じ種類を追加・修正するたびに、離れた場所を何か所も直しているかです。
また、今回の例は入力データを1つにまとめています。種類によって項目が大きく異なり、使わない項目が増え始めたら、入力の型も種類ごとに分けることを検討します。題材の実装では、種類ごとの入力型を使い、JSONの読み取りなどの共通部分を別の基底クラスへまとめています。
修正漏れを見つける確認項目
クラスの分割だけでは、ルールや登録の正しさまでは保証できません。次のように、種類の選択とルールを分けて確認します。
| 確認すること | 期待する結果 |
|---|---|
| 配送ありで配送先が空 | 入力エラーになる |
| 配送なしで配送先がある | 入力エラーになる |
| 配送ありの説明文を作る | 指定した配送先が含まれる |
| 未登録の種類を送る | エラーになり、別の種類として処理されない |
| 同じ種類を二重に登録する | 登録表の作成時に検出できる |
| 新しい種類をDIから取得する | 登録表で選択できる |
題材の実装にも、種類ごとの入力条件や、利用できる作成経路を確認するテストがあります。上の表は学習用の例に合わせた確認項目です。
保守しやすくするためには、共通処理を増やすだけでなく、変更の理由が同じ処理を近くへ置くことも有効です。入力チェックと表示作成を同時に直すことが多いなら、種類ごとにまとめる構成を検討してみてください。