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

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

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

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

今回は、ASP.NET Coreの承認機能の実装をもとに、同じ種類の変更で一緒に直す処理を、近くにまとめる方法を紹介します。

元の実装では、操作の種類ごとに入力チェックと表示データの作成をまとめ、登録されたクラスから対象を選んでいます。ここでは、配送あり・配送なしの注文を題材に、C#の短い例へ置き換えて説明します。実際の障害や改修前のコードを再現したものではありません。

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

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

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

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

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

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

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

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

すべての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から取得する登録表で選択できる

題材の実装にも、種類ごとの入力条件や、利用できる作成経路を確認するテストがあります。上の表は学習用の例に合わせた確認項目です。

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


関連記事

  • ASP.NET Core 承認履歴に結果だけでなく表示内容も残す設計

    承認履歴に何を保存すればよいのかを、承認機能の実装をもとに解説します。注文IDだけでは足りない場面、説明文を保存する理由、同じ表示を再利用する工夫、小さく実装するための判断基準を紹介します。


  • vscode C#のusingを自動で設定するショートカット

    vscodeでC#のusing文を自動で設定するショートカットを紹介します。「The type or namespace name XXX could not be found (are you mi...


  • C# yyyyMMdd形式の日付文字列をyyyy/MM/dd形式に変換する

    yyyyMMdd形式の日付文字列をスラッシュ区切りのyyyy/MM/dd形式に変換する方法を紹介します。以下のような共通ファンクションを作成します。文字列の長さが8桁でない場合は変換は行わない仕様とし...


  • C# YYYYMMDD書式の文字列が日付かどうか判定する

    C#で、YYYYMMDD書式の文字列が日付として妥当かどうかを判定します。以下のように、Date.TryParseExactを使用して、日付型の文字列に変換可能かどうかで判定しています。時刻も同じ方法...


  • C# UTC時刻の日付をJST時刻に変換する

    C#のUTC時刻の日付DateTimeを、JST時刻の日付に変換する方法を紹介します。単純に時刻を+9時間するのと同じですが、ここではタイムゾーンの仕様に基づいて変換します。以下のように変換します。T...


  • C# TryParseで文字列を安全に数値へ変換する

    テキストボックスやCSVから受け取った値は文字列です。数値として使いたい場合、int.Parseは変換できない入力で例外を投げます。入力が誤っていることを通常の分岐として扱うなら、TryParseが向...


  • C# TimeProviderで有効期限のテストを安定させる

    有効期限を判定する処理でDateTimeOffset.UtcNowを直接使用すると、テスト中の時刻を固定できません。実際に時間が過ぎるのを待つテストは遅く、境界付近で結果が不安定になります。今回は.N...


  • C# 型付きIDで異なるIDの渡し間違いを防ぐ

    注文IDと顧客IDがどちらもGuidの場合、引数の順序を間違えてもコンパイルできます。メソッドの引数も両方Guidなら、値を逆に渡しても型エラーになりません。今回はIDごとに専用の型を作り、渡し間違い...


  • C# 文字列を数値に安全に変換する

    C#で、string型の文字列を、intやdecimalなどの数値型に、例外が発生しないように安全に変換する方法を紹介します。以下のように、TryParseを使用します。int(またはdecimal)...


  • C# Json形式の文字列をクラスオブジェクトに変換する

    System.Text.Jsonで、Json型の文字列をクラスオブジェクトに変換する方法を紹介します。Jsonを扱うライブラリといえばJson.NET(Newtonsoft.Json)を使用することが...