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

В настоящее время у меня есть сервисный уровень, основанный на статье Проверка с сервисным уровнем с сайта 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;
}

С измененным интерфейсом IPurchaseOrderService:

interface IPurchaseOrderService
{
    void CreatePurchaseOrder(CreatePurchaseOrder command);
}

CreatePurchaseOrder параметров 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());

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

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

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

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

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

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

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

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

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

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