Замена примитива объектом в доменной модели

Типы данных можно разделить на примитивные и пользовательские. Примитивы хранят некоторое скалярное значение, например число или символ (int, bool, char, decimal и др). Пользовательскими типами являются те, которые создает непосредственно программист, с целью сгруппировать несколько примитивов в одну логическую единицу и добавить к ним логику. Пользовательские типы описывают при помощи классов или структур, а конкретными примерами являются типы Person, Address, Order, Product и так далее до бесконечности.

Описание проблемы

При проектировании некоторого доменного класса, программист может интуитивно следовать простым истинам: использовать примитивный тип данных для хранения скалярного значения и создать класс или структуру для группировки нескольких примитивов. Получается примерно такое:

public class User
{
    public long Id { getset; }
    public string Email { getset; }
    public int Discount { getset; }
}

Для свойств Email и Discount прогнозируемо были выбраны типы string и int соответственно, которые справляются со своей основной задачей — хранением значения, но их использование не дает никакой гарантии, что значения всегда будут валидными. Диапазон значений, которые могут хранить string и int превышают диапазон значений удовлетворяющих требованиям логики разрабатываемого приложения. Простыми словами не каждая строка является email’ом и не каждое число — дисконт. Email должен выполнять целый ряд условий, таких как наличие символа ‘@’ и других, а значение Discount не может быть меньше одного и больше ста.

При возникновении подобных ограничений разработчик вынужден думать о валидации значений. В самом лучшем случае валидировать придется один раз при передаче данных из контролера или сервиса в доменную модель, в худшем — много раз: при создании объекта User, при обновлении значений Email или Discount, при передаче Discount в доменный сервис Calculator для выполнения некоторых расчетов и так далее. В результате можем получить дублирующуюся логику валидации, размазанную тонким слоем по всему приложению, которая будет выглядеть где-то так:

public class User
{
    public int Discount { getset; }
    public void ChangeDiscount(int discount)
    {
        if (discount < 1 && discount > 100)
            throw new BusinessLogicException("Invalid discount");
        Discount = discount;
    }
}

public class Calculator
{
    public int Calculate(int discount)
    {
        if (discount < 1 && discount > 100)
            throw new BusinessLogicException("Invalid discount");
        //calculate ...
    }
}

public class EmailService
{
    public void SendEmail(string email)
    {
        if (!Regex.IsMatch(email, @"^\S+@\S+$"))
            throw new BusinessLogicException("Invalid e-mail");
        //send email ...
    }
}

Решение: замена примитива объектом

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

  • публичное свойство для хранения примитива
  • приватный конструктор
  • фабричный метод для создания объекта, с предварительной валидацией

Создадим классы User и Discount, следуя правилам выше:

public class Email
{
    private Email(string email) => Value = email;
    public string Value { get; }
    public static Email Create(string email)
    {
        if (!Regex.IsMatch(email, @"^\S+@\S+$"))
            throw new BusinessLogicException("Invalid e-mail");
        return new Email(email);
    }
}

public class Discount
{
    private Discount(int discount) => Value = discount;
    public int Value { get; }
    public static Discount Create(int discount)
    {
         if (discount < 1 && discount > 100)
            throw new BusinessLogicException("Invalid discount");
        return new Discount(discount);
    }
}

А сам класс User изменится следующим образом:

public class User
{
    public long Id { getset; }
    public Email Email { getset; }
    public Discount Discount { getset; }
}

Преимущества подхода

Использование классов Email и Discount в доменной модели (в классах User, Calculator, EmailService), которые невозможно создать с невалидными значениями, дает несколько преимуществ:

  • Логика валидации собрана в единственном месте, что максимально упрощает внесение в нее изменений.
  • Нет никакой необходимости думать о валидации Email или Discount перед их использованием, так как эти объекты гарантировано содержат валидные значения.
  • Объекты являются неизменяемыми, что защищает приложение побочных эффектов, связанных с Email и Discount.
  • Когда программист натыкается в коде на объекты Email или Discount, у него уйдет одна секунда на нахождение соответствующей логики валидации.

Недостатки подхода

  • Свойство-объект с единственным примитивом внутри сложнее мапить на колонку в таблице базы данных при работе с ORM, чем свойство-примитив. Тем не менее Entity Framework Core позволяет достаточно легко это сделать при помощи механизма Value Conversions.
  • Неизменяемость создаваемых объектов в отдельных случаях может привести к проблемам с производительностью, при попытках их частых «изменений».

Почитать дальше

ValueObject, Martin Fowler

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход /  Изменить )

Google photo

Для комментария используется ваша учётная запись Google. Выход /  Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход /  Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход /  Изменить )

Connecting to %s