web-dev-qa-db-ja.com

検証レイヤーからサービスレイヤーを分離する

現在、ASP.NETサイトの記事 サービスレイヤーで検証 に基づいたサービスレイヤーがあります。

this の回答によると、サービスロジックが単一責任の原則に違反する検証ロジックと混合されているため、これは悪いアプローチです。

提供されている代替手段が本当に好きですが、コードのリファクタリング中に、解決できない問題に遭遇しました。

次のサービスインターフェイスについて考えてみます。

interface IPurchaseOrderService
{
    void CreatePurchaseOrder(string partNumber, string supplierName);
}

リンクされた回答に基づいて、次の具体的な実装を行います。

public class PurchaseOrderService : IPurchaseOrderService
{
    public void CreatePurchaseOrder(string partNumber, string supplierName)
    {
        var po = new PurchaseOrder
        {
            Part = PartsRepository.FirstOrDefault(p => p.Number == partNumber),
            Supplier = SupplierRepository.FirstOrDefault(p => p.Name == supplierName),
            // Other properties omitted for brevity...
        };

        validationProvider.Validate(po);
        purchaseOrderRepository.Add(po);
        unitOfWork.Savechanges();
    }
}

バリデーターに渡されるPurchaseOrderオブジェクトには、他に2つのエンティティPartSupplierも必要です(この例では、POには1つの部分しかないと仮定します)。

ユーザーが指定した詳細がデータベース内のエンティティに対応していない場合、PartオブジェクトとSupplierオブジェクトの両方がnullになる可能性があり、バリデーターが例外をスローする必要があります。

私が抱えている問題は、この段階でバリデーターがコンテキスト情報(部品番号とサプライヤー名)を失ったため、ユーザーに正確なエラーを報告できないことです。私が提供できる最良のエラーは、 "注文書には関連するパーツが必要です"の行に沿ったものですが、ユーザーには意味がありません。部品番号を指定します(データベースに存在しないだけです)。

ASP.NETの記事のサービスクラスを使用して、次のようなことをしています。

public void CreatePurchaseOrder(string partNumber, string supplierName)
{
    var part = PartsRepository.FirstOrDefault(p => p.Number == partNumber);
    if (part == null)
    {
        validationDictionary.AddError("", 
            string.Format("Part number {0} does not exist.", partNumber);
    }

    var supplier = SupplierRepository.FirstOrDefault(p => p.Name == supplierName);
    if (supplier == null)
    {
        validationDictionary.AddError("",
            string.Format("Supplier named {0} does not exist.", supplierName);
    }

    var po = new PurchaseOrder
    {
        Part = part,
        Supplier = supplier,
    };

    purchaseOrderRepository.Add(po);
    unitOfWork.Savechanges();
}

これにより、はるかに優れた検証情報をユーザーに提供できますが、検証ロジックがサービスクラスに直接含まれ、単一責任の原則に違反します(コードもサービスクラス間で重複します)。

両方の世界を最大限に活用する方法はありますか?同じレベルのエラー情報を提供しながら、サービスレイヤーを検証レイヤーから分離できますか?

32
Benjamin Gale

短い答え:

あなたは間違ったことを検証しています。

非常に長い答え:

PurchaseOrderを検証しようとしていますが、これは実装の詳細です。代わりに、検証する必要があるのは操作自体です。この場合は、partNumberおよびsupplierNameパラメーターです。

これらの2つのパラメーターを単独で検証するのは厄介ですが、これは設計が原因であり、抽象化が欠落しています。

簡単に言うと、問題はIPurchaseOrderServiceインターフェースにあります。 2つの文字列引数ではなく、1つの引数(a パラメータオブジェクト )を使用する必要があります。このパラメータオブジェクトをCreatePurchaseOrderと呼びましょう:

public class CreatePurchaseOrder
{
    public string PartNumber;
    public string SupplierName;
}

変更されたIPurchaseOrderServiceインターフェースを使用:

interface IPurchaseOrderService
{
    void CreatePurchaseOrder(CreatePurchaseOrder command);
}

CreatePurchaseOrderパラメータオブジェクトは元の引数をラップします。このパラメータオブジェクトは、発注書の作成の意図を説明するメッセージです。言い換えれば:それはコマンドです

このコマンドを使用すると、適切な部品サプライヤの存在の確認やユーザーフレンドリーなエラーメッセージの報告など、すべての適切な検証を実行できるIValidator<CreatePurchaseOrder>実装を作成できます。

しかし、なぜIPurchaseOrderServiceが検証に責任があるのでしょうか? 検証は横断的関心事であり、ビジネスロジックとの混合を防ぐ必要があります。代わりに、このためのデコレータを定義できます。

public class ValidationPurchaseOrderServiceDecorator : IPurchaseOrderService
{
    private readonly IValidator<CreatePurchaseOrder> validator;
    private readonly IPurchaseOrderService decoratee;

    ValidationPurchaseOrderServiceDecorator(
        IValidator<CreatePurchaseOrder> validator,
        IPurchaseOrderService decoratee)
    {
        this.validator = validator;
        this.decoratee = decoratee;
    }

    public void CreatePurchaseOrder(CreatePurchaseOrder command)
    {
        this.validator.Validate(command);
        this.decoratee.CreatePurchaseOrder(command);
    }
}

このようにして、実際のPurchaseOrderServiceをラップするだけで検証を追加できます。

var service =
    new ValidationPurchaseOrderServiceDecorator(
        new CreatePurchaseOrderValidator(),
        new PurchaseOrderService());

もちろん、このアプローチの問題は、システム内のサービスごとにそのようなデコレータクラスを定義するのが本当に厄介なことです。それは深刻なコード公開を引き起こすでしょう。

しかし、問題は欠陥によって引き起こされます。特定のサービス(IPurchaseOrderServiceなど)ごとにインターフェースを定義することは、通常、問題があります。 CreatePurchaseOrderを定義したので、すでにそのような定義があります。これで、システム内のすべての業務に対して単一の抽象化を定義できます。

public interface ICommandHandler<TCommand>
{
    void Handle(TCommand command);
}

この抽象化により、PurchaseOrderServiceを次のようにリファクタリングできます。

public class CreatePurchaseOrderHandler : ICommandHandler<CreatePurchaseOrder>
{
    public void Handle(CreatePurchaseOrder command)
    {
        var po = new PurchaseOrder
        {
            Part = ...,
            Supplier = ...,
        };

        unitOfWork.Savechanges();
    }
}

この設計により、単一の汎用デコレータを定義して、システム内のすべてのビジネスオペレーションのすべての検証を処理できるようになりました。

public class ValidationCommandHandlerDecorator<T> : ICommandHandler<T>
{
    private readonly IValidator<T> validator;
    private readonly ICommandHandler<T> decoratee;

    ValidationCommandHandlerDecorator(
        IValidator<T> validator, ICommandHandler<T> decoratee)
    {
        this.validator = validator;
        this.decoratee = decoratee;
    }

    void Handle(T command)
    {
        var errors = this.validator.Validate(command).ToArray();

        if (errors.Any())
        {
            throw new ValidationException(errors);
        }

        this.decoratee.Handle(command);
    }
}

このデコレータが以前に定義されたValidationPurchaseOrderServiceDecoratorとほぼ同じであるが、現在はジェネリッククラスであることに注意してください。このデコレータは、新しいサービスクラスにラップすることができます。

var service =
    new ValidationCommandHandlerDecorator<PurchaseOrderCommand>(
        new CreatePurchaseOrderValidator(),
        new CreatePurchaseOrderHandler());

ただし、このデコレータは汎用であるため、システム内のすべてのコマンドハンドラにラップすることができます。うわー! DRYになるのはどうですか?

この設計により、後で横断的関心事を追加することも非常に簡単になります。たとえば、あなたのサービスは現在、作業単位でSaveChangesを呼び出す責任があるようです。これは横断的関心事と見なすこともでき、デコレータに簡単に抽出できます。このようにして、テストするコードが少なくなり、サービスクラスがはるかにシンプルになります。

CreatePurchaseOrderバリデーターは次のようになります。

public sealed class CreatePurchaseOrderValidator : IValidator<CreatePurchaseOrder>
{
    private readonly IRepository<Part> partsRepository;
    private readonly IRepository<Supplier> supplierRepository;

    public CreatePurchaseOrderValidator(
        IRepository<Part> partsRepository,
        IRepository<Supplier> supplierRepository)
    {
        this.partsRepository = partsRepository;
        this.supplierRepository = supplierRepository;
    }

    protected override IEnumerable<ValidationResult> Validate(
        CreatePurchaseOrder command)
    {
        var part = this.partsRepository.GetByNumber(command.PartNumber);

        if (part == null)
        {
            yield return new ValidationResult("Part Number", 
                $"Part number {command.PartNumber} does not exist.");
        }

        var supplier = this.supplierRepository.GetByName(command.SupplierName);

        if (supplier == null)
        {
            yield return new ValidationResult("Supplier Name", 
                $"Supplier named {command.SupplierName} does not exist.");
        }
    }
}

そして、次のようなコマンドハンドラー:

public class CreatePurchaseOrderHandler : ICommandHandler<CreatePurchaseOrder>
{
    private readonly IUnitOfWork uow;

    public CreatePurchaseOrderHandler(IUnitOfWork uow)
    {
        this.uow = uow;
    }

    public void Handle(CreatePurchaseOrder command)
    {
        var order = new PurchaseOrder
        {
            Part = this.uow.Parts.Get(p => p.Number == partNumber),
            Supplier = this.uow.Suppliers.Get(p => p.Name == supplierName),
            // Other properties omitted for brevity...
        };

        this.uow.PurchaseOrders.Add(order);
    }
}

コマンドメッセージはドメインの一部になることに注意してください。ユースケースとコマンドの間には1対1のマッピングがあり、エンティティを検証する代わりに、それらのエンティティが実装の詳細になります。コマンドがコントラクトになり、検証が行われます。

コマンドにできるだけ多くのIDが含まれていると、おそらく作業がはるかに楽になることに注意してください。したがって、システムは次のようにコマンドを定義することでメリットが得られます。

public class CreatePurchaseOrder
{
    public int PartId;
    public int SupplierId;
}

これを行う場合、指定された名前のパーツが存在するかどうかを確認する必要はありません。プレゼンテーション層(または外部システム)からIDが渡されたため、その部分の存在を検証する必要はありません。もちろん、そのIDの部分がない場合、コマンドハンドラーは失敗するはずですが、その場合、プログラミングエラーまたは同時実行の競合が発生します。どちらの場合でも、表現力豊かなユーザーフレンドリーな検証エラーをクライアントに返す必要はありません。

ただし、これにより、正しいIDを取得する問題がプレゼンテーション層に移動します。プレゼンテーション層では、ユーザーはリストからパーツを選択して、そのパーツのIDを取得する必要があります。しかし、それでも私はこれを経験して、システムをはるかに簡単でスケーラブルにしました。

また、参照している記事のコメントセクションに記載されている、次のような問題のほとんどを解決します。

  • コマンドは簡単にシリアル化してモデルをバインドできるため、エンティティのシリアル化の問題は解消されます。
  • DataAnnotation属性はコマンドに簡単に適用でき、これによりクライアント側(Javascript)の検証が可能になります。
  • デコレータは、データベーストランザクションで完全な操作をラップするすべてのコマンドハンドラに適用できます。
  • これにより、コントローラーとサービスレイヤーの間の循環参照が(コントローラーのModelStateを介して)削除され、コントローラーがサービスクラスを新規作成する必要がなくなります。

このタイプのデザインについてもっと知りたい場合は、絶対にチェックしてください この記事

54
Steven