Отделение уровня обслуживания от уровня проверки

В настоящее время у меня есть сервисный уровень, основанный на статье Проверка с сервисным уровнем с сайта ASP.NET.

Согласно этому ответу, это плохой подход, потому что логика обслуживания смешана с логикой валидации, которая нарушает принцип единой ответственности.

Мне очень нравится предлагаемая альтернатива, но во время перефакторинга моего кода я столкнулся с проблемой, которую не могу решить.

Рассмотрим следующий интерфейс сервиса:

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 объект, который передается в валидатор, также требует двух других объектов, Part а также Supplier (давайте предположим для этого примера, что PO имеет только одну часть).

Оба Part а также Supplier объекты могут быть нулевыми, если детали, предоставленные пользователем, не соответствуют объектам в базе данных, которые требуют, чтобы валидатор выдал исключение.

У меня проблема в том, что на этом этапе валидатор потерял контекстную информацию (номер детали и имя поставщика), поэтому не может сообщить точную ошибку пользователю. Лучшая ошибка, которую я могу предоставить, - это "заказ на поставку должен иметь связанную деталь", который не имеет смысла для пользователя, потому что он предоставил номер детали (он просто не существует в базе данных).

Используя класс обслуживания из статьи 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();
}

Это позволяет мне предоставлять гораздо лучшую информацию о валидации пользователю, но означает, что логика валидации содержится непосредственно в классе обслуживания, что нарушает принцип единой ответственности (код также дублируется между классами обслуживания).

Есть ли способ получить лучшее из обоих миров? Могу ли я отделить уровень обслуживания от уровня проверки, в то же время предоставляя информацию об ошибках того же уровня?

1 ответ

Решение

Короткий ответ:

Вы проверяете не ту вещь.

Очень длинный ответ:

Вы пытаетесь подтвердить PurchaseOrder но это деталь реализации. Вместо этого вам следует проверить саму операцию, в данном случае partNumber а также supplierName параметры.

Проверка этих двух параметров сама по себе была бы неудобной, но это вызвано вашим дизайном - вы упускаете абстракцию.

Короче говоря, проблема в вашем IPurchaseOrderService интерфейс. Он не должен принимать два строковых аргумента, но один единственный аргумент (объект параметра). Давайте назовем этот параметр объекта: CreatePurchaseOrder, В этом случае интерфейс будет выглядеть так:

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

interface IPurchaseOrderService
{
    void CreatePurchaseOrder(CreatePurchaseOrder command);
}

Параметр Объект CreatePurchaseOrder оборачивает оригинальные аргументы. Этот параметр объекта является сообщением, которое описывает цель создания заказа на покупку. Другими словами: это команда.

Используя эту команду, вы можете создать IValidator<CreatePurchaseOrder> реализация, которая может выполнять все надлежащие проверки, включая проверку наличия надлежащего поставщика запчастей и создание удобных для пользователя сообщений об ошибках.

Но почему IPurchaseOrderService ответственность за проверку? Валидация - это междисциплинарная задача, и вы должны стараться не смешивать ее с бизнес-логикой. Вместо этого вы можете определить декоратор для этого:

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

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

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

Таким образом, мы можем добавить проверку, просто обернув PurchaseOrderService:

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

Конечно, проблема с этим подходом состоит в том, что было бы очень неудобно определять такой класс декоратора для каждого сервиса в системе. Это было бы серьезным нарушением принципа СУХОЙ.

Но проблема вызвана недостатком. Определение интерфейса для конкретной службы (например, 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 ICommandHandler<T> decoratee;
    private readonly IValidator<T> validator;

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

    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 CreatePurchaseOrderHandler(),
        new CreatePurchaseOrderValidator());

Но поскольку этот декоратор является общим, мы можем обернуть его вокруг каждого обработчика команд в нашей системе. Вот Это Да! Как это за то, что СУХОЙ?

Этот дизайн также позволяет легко добавлять сквозные проблемы позже. Например, ваш сервис в настоящее время кажется ответственным за вызов 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.Get(p => p.Number == command.PartNumber);

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

        var supplier = this.supplierRepository.Get(p => p.Name == command.SupplierName);

        if (supplier == null)
        {
            yield return new ValidationResult("Supplier Name", 
                $"Supplier named {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);
    }
}

Обратите внимание, что командные сообщения станут частью вашего домена. Существует взаимно-однозначное сопоставление между вариантами использования и командами, и вместо проверки сущностей эти сущности будут деталями реализации. Команды становятся контрактом и получат подтверждение.

Обратите внимание, что, вероятно, ваша жизнь станет намного проще, если ваши команды будут содержать как можно больше идентификаторов. Таким образом, ваша система может выиграть от определения команды следующим образом:

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

Когда вы сделаете это, вам не нужно будет проверять, существует ли деталь с указанным именем. Уровень представления (или внешняя система) передал вам Id, поэтому вам больше не нужно проверять существование этой части. Обработчик команд, конечно, должен завершиться сбоем, если в этом идентификаторе нет части, но в этом случае возникает ошибка программирования или конфликт параллелизма. В любом случае нет необходимости сообщать клиенту о выразительных ошибках в проверке.

Это, однако, перемещает проблему получения правильных идентификаторов на уровне представления. На уровне представления пользователь должен будет выбрать деталь из списка, чтобы мы могли получить идентификатор этой детали. Но все же я испытал это, чтобы сделать систему намного проще и масштабируемой.

Это также решает большинство проблем, указанных в разделе комментариев статьи, на которую вы ссылаетесь, например:

  • Поскольку команды можно легко сериализовать и связать с моделью, проблема с сериализацией сущностей исчезнет.
  • Атрибуты DataAnnotation можно легко применять к командам, что позволяет выполнять проверку на стороне клиента (Javascript).
  • Декоратор может быть применен ко всем обработчикам команд, которые заключают полную операцию в транзакцию базы данных.
  • Он удаляет циклическую ссылку между контроллером и уровнем обслуживания (через ModelState контроллера), устраняя необходимость в контроллере для нового класса обслуживания.

Если вы хотите узнать больше об этом типе дизайна, вы должны обязательно проверить эту статью.

Другие вопросы по тегам