ドメインモデルをLINQで構築する(その5)

検証ロジック

前回空のお買い上げ(Order)を登録するシナリオを作成しましたが、実際に空のお買い上げを登録しては問題になります。お買い上げを登録する場合には検証が行い問題が無いか確認する必要があります。
テストケースを書き直し空のお買い上げの場合にはエラーが発生することを確認するようにします。

[TestMethod]
public void TestNewEmptyOrder()
{
    using (var db = new BurgerShopDataContext())
    {
        db.Log = Console.Out;
        var shops = db.GetTable();
        var orders = db.GetTable();
        var shop = shops.Single(s => s.Name == "OSAKA");
        var myOrder = shop.CreateOrder();
        orders.Add(myOrder);
        try
        {
            db.SubmitChanges();
            Assert.Fail();
        }
        catch (BurgerShopApplicationExcpetion ex)
        {
            Assert.AreEqual("ErrorEmptyOrder", ex.MessageCode);
        }
    }

}

エラーが発生した場合には BurgerShopApplicationExcpetionを発生させ、発生したエラー情報をコードとして返すようにします。例外クラスの作成する方式としてエラー種類毎に例外クラスを作成する方法がありますが繁雑になりがちです。LOBアプリであれば汎用的なアプリケーション例外を1つ用意して、エラーの種類はプロパティで判断できるようにすればほとんど場合事足ります。

[Serializable]
public class BurgerShopApplicationExcpetion
    : ApplicationException
{
    public List Errors { get; private set; }
    public string MessageCode
    {
        get 
        {
            return Errors != null && Errors.Count > 0 ? Errors[0].MessageCode : null;
        }
    }
    public BurgerShopApplicationExcpetion(ApplicationError error) 
        : base(error.Message)
    {
        if (error == null) throw new ArgumentNullException("error");
        Errors = new List();
        Errors.Add(error);
    }

    public BurgerShopApplicationExcpetion(List errors)
        : base(errors != null && errors.Count > 0 ? errors[0].Message : string.Empty)
    {
        if (errors == null) throw new ArgumentNullException("errors");
        if (errors.Count == 0) throw new ArgumentOutOfRangeException("errors");
        Errors = errors;
    }    

    public BurgerShopApplicationExcpetion(SerializationInfo info, StreamingContext context)
        : base(info, context)
    {
        this.Errors = (List)info.GetValue("Errors", typeof(List));
    }

    public override void GetObjectData(SerializationInfo info, StreamingContext context)
    {
        base.GetObjectData(info, context);
        info.AddValue("Errors", this.Errors);
    }
}

[Serializable]
public class ApplicationError
{
    public string MessageCode { get; private set; }
    public object AdditionalInfo { get; private set; }

    public ApplicationError(string code, params object info)
    {
        this.MessageCode = code;
        this.AdditionalInfo = info;
    }
    public string Message 
    {
        get {
            return string.Format(
                    Messages.ResourceManager.GetString(MessageCode),
                    AdditionalInfo);
        }

    }
}

public static class AppUtil
{
    public static void RaiseError(string code, params object[] info)
    {
        throw new BurgerShopApplicationExcpetion(new ApplicationError(code, info));
    }
}


検証メソッド(OnValidateメソッド)

LINQ to SQLでは、(ARみたいに)クラスにOnValidateメソッドを定義すると保存時に自動的に呼び出される仕組みが用意されています。このOnValidateは partialメソッドとして定義されており、もし検証に失敗した場合は例外をスローします。

partial class Order
{

    public Order(Shop shop) : this()...

    partial void OnValidate()
    {
        if (this.OrderItems.Count == 0) AppUtil.RaiseError("ErrorEmptyOrder");
    }
}


検証処理のリファクタリング

現行の場合でも十分簡潔ですが、もう少し仕様が伝わる読みやすいコードにするために、空のオーダーであるという判断をメソッドにし、意図が伝わるようにIsEmptyと命名します。

    public bool IsEmpty()
    {
        return this.OrderItems.Count == 0;
    }

    partial void OnValidate()
    {
        if (IsEmpty()) AppUtil.RaiseError("ErrorEmptyOrder");
    }

コメント入れる前にコメントしようとしている個所をメソッドやプロパティとして切り出して意味のある名前が付けれないか考えることは重要です。特にビジネス用語や仕様として使われている用語の対応付けは設計以降のコードの記述段階でも積極的に行います。こんなことを言うとコーディングの難度があがるように思われるかもしれませんが、対象がドメインモデルのコードに絞り込まれているのと、このような名前付けを意識する処理はほとんど導出項目と検証処理のビジネスルールの実装に限られるので思っているほど難しくありません。

検証フレームワークの追加

さらに検証処理を充実したいのでLINQ to SQLの検証メカニズムをもう少し拡張したいと思います。Tester-Doerパターンでの検証を可能にするためにIsValidメソッドを追加しエンティティの状態をいつでも確認できるようにします。拡張方法として直ぐに思いつくのが共通機能をベースクラスとして実装し継承する方法ですが、LINQ to SQLもわざわざ避けたベースクラスの導入はしたくありません。2つ目の方法は、インタフェースを定義し実行のためのヘルパークラスを用意する方法です。各クラスがインタフェースを実装しヘルパーに処理を委任をすることで同じような処理の記述を最小限にできリーズナブルな方法です。しかし、C#3.0であれば拡張メソッドを利用すると委任よりも簡単にMIXINのような実装をすることができます。

public interface IValidator
{
    List GetErrors();
}

public static class IValidatorExtender
{
    public static bool IsValid(this IValidator validator)
    {
        return validator.GetErrors().Count == 0;
    }

    public static void Validate(this IValidator validator)
    {
        List errors = validator.GetErrors();
        if (errors.Count != 0) throw new BurgerShopApplicationExcpetion(errors);
    }
}

バリエーションの必要なメソッドをインタフェースで定義し、そのインタフェースを利用した処理を拡張メソッドに記述します。これで、IValidatorを実装している処理すべてにIValidatorExtenderで実装した処理が組み込まれることになります。

Orderクラスに組み込むも簡単です。インターフェースを宣言しバリエーション部を実装すると拡張メソッドを利用して処理を記述できます。これでOrderクラスにおいて現在の検証がどのようになるか常に判断できるようになります。

partial class Order : IValidator
{

    partial void OnValidate()
    {
        this.Validate();
    }

    public List GetErrors()
    {
        List errors = new List();
        if (IsEmpty()) errors.Add(new ApplicationError("ErrorEmptyOrder"));
        return errors;
    }

テストケースにIsValidメソッドを含めてパスすることを確認します。

[TestMethod]
public void TestNewEmptyOrder()
{
    using (var db = new BurgerShopDataContext())
    {
        db.Log = Console.Out;
        var shops = db.GetTable();
        var orders = db.GetTable();
        var shop = shops.Single(s => s.Name == "OSAKA");
        var myOrder = shop.CreateOrder();
        Assert.IsFalse(myOrder.IsValid());
        orders.Add(myOrder);
        try
        {
            db.SubmitChanges();
            Assert.Fail();
        }
        catch (BurgerShopApplicationExcpetion ex)
        {
            Console.WriteLine(ex.ToString());
            Assert.AreEqual("ErrorEmptyOrder", ex.MessageCode);
        }
    }
}


アイテムの追加処理

検証のための仕組みがほぼそろったので、ドメインモデルの構築を進めていきます。次の目標はお買い上げに商品を明細アイテムとして追加するシナリオです。

[TestMethod]
public void TestSimpleOrder()
{
    using (var db = new BurgerShopDataContext())
    {
        db.Log = Console.Out;
        var shops = db.GetTable();
        var orders = db.GetTable();
        var products = db.GetTable();

        var shop = shops.Single(s => s.Name == "OSAKA");
        var coffeeM = (SaleProduct)products.Single(
                                p => p.Title == "CoffeeM");

        var myOrder = shop.CreateOrder();
        var item1 = myOrder.AddItem(coffeeM);

        Assert.AreEqual(200m, myOrder.TotalPrice);
        db.SubmitChanges();
    }
}

商品(Order)のAddItemメソッドを作成していくのですが、その前に少しお買い上げ(Product)ドメインモデル全体を改訂します。これはお買い上げエンティティはイベント(こと)であり事実を表します。イベントは事象が発生した際の記録であってエンティティ生成時に値が決定します。したがって、外部から値が直接変更されることはなく、プロパティはRead Onlyで十分です。dbmlで各プロパティに対してRead Onlyを指定することができますので、より堅牢な構造とするためすべてのプロパティをRead Onlyに設定するようにします。なお、クラス内ではRead Onlyに指定したプロパティもアンダースコア(_)付きで書き込みアクセスすることは可能です。

準備が整ったのでAddItemメソッドを作成します。特に説明する必要はないと思いますが、Orderの導出項目だけれども保存対象となるTotalPriceについては更新用のUpdateTotalPriceを用意し必要に応じて呼び出すような構造になっています。

partial class Order : IValidator
{

    public OrderItem AddItem(SaleProduct aProduct)
    {
        return AddItem(aProduct, 1);
    }

    public OrderItem AddItem(SaleProduct aProduct, decimal amount)
    {
        var aItem = new OrderItem(this, aProduct, amount);
        this.UpdateTotalPrice();
        return aItem;
    }


    internal void UpdateTotalPrice()
    {
        this._TotalPrice = OrderItems.Sum(i => i.Amount * i.UnitPrice);
    }
    ...


partial class OrderItem
{
    public OrderItem(Order aOrder, SaleProduct aProduct, decimal amount)
        : this()
    {
        this.Order = aOrder;
        this._ProductID = aProduct.ProductID;
        this._Amount = amount;
        this._UnitPrice = aProduct.Price;
    }

}


商品選択処理のリファクタリング

テストケースの商品タイトルにより検索の箇所についてLINQによって1行で記述できているのですが、今後も何度も利用しそうなコードなのでより簡単に記述できるように、ヘルパーとして定義します。ヘルパー処理は拡張メソッドを利用して実装すると簡単です。

public static class ProductsTableExtender
{
    public static SaleProduct FindSaleProduct(this Table table, string title)
    {
        return (SaleProduct)table.Single(p => p.Title == title && p is SaleProduct);
    }
}

このメソッドを使うとテストケースが少し読みやすくできます。FindSaleProductはもう少し短い名前にしたかったのですが...名前付けはいつも頭を悩まします。

[TestMethod]
public void TestSimpleOrder()
{
    using (var db = new BurgerShopDataContext())
    {
        db.Log = Console.Out;
        var shops = db.GetTable();
        var orders = db.GetTable();
        var products = db.GetTable();

        var shop = shops.Single(s => s.Name == "OSAKA");
        var myOrder = shop.CreateOrder();
        var item1 = myOrder.AddItem(products.FindSaleProduct("CoffeeM"));

        Assert.AreEqual(200m, myOrder.TotalPrice);
        db.SubmitChanges();
    }
}