Типы данных можно разделить на примитивные и пользовательские. Примитивы хранят некоторое скалярное значение, например число или символ (int, bool, char, decimal и др). Пользовательскими типами являются те, которые создает непосредственно программист, с целью сгруппировать несколько примитивов в одну логическую единицу и добавить к ним логику. Пользовательские типы описывают при помощи классов или структур, а конкретными примерами являются типы Person, Address, Order, Product и так далее до бесконечности.
- Описание проблемы
- Решение: замена примитива объектом
- Преимущества подхода
- Недостатки подхода
- Почитать дальше
Описание проблемы
При проектировании некоторого доменного класса, программист может интуитивно следовать простым истинам: использовать примитивный тип данных для хранения скалярного значения и создать класс или структуру для группировки нескольких примитивов. Получается примерно такое:
public class User { public long Id { get; set; } public string Email { get; set; } public int Discount { get; set; } }
Для свойств Email и Discount прогнозируемо были выбраны типы string и int соответственно, которые справляются со своей основной задачей — хранением значения, но их использование не дает никакой гарантии, что значения всегда будут валидными. Диапазон значений, которые могут хранить string и int превышают диапазон значений удовлетворяющих требованиям логики разрабатываемого приложения. Простыми словами не каждая строка является email’ом и не каждое число — дисконт. Email должен выполнять целый ряд условий, таких как наличие символа ‘@’ и других, а значение Discount не может быть меньше одного и больше ста.
При возникновении подобных ограничений разработчик вынужден думать о валидации значений. В самом лучшем случае валидировать придется один раз при передаче данных из контролера или сервиса в доменную модель, в худшем — много раз: при создании объекта User, при обновлении значений Email или Discount, при передаче Discount в доменный сервис Calculator для выполнения некоторых расчетов и так далее. В результате можем получить дублирующуюся логику валидации, размазанную тонким слоем по всему приложению, которая будет выглядеть где-то так:
public class User { public int Discount { get; set; } 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 { get; set; } public Email Email { get; set; } public Discount Discount { get; set; } }
Преимущества подхода
Использование классов Email и Discount в доменной модели (в классах User, Calculator, EmailService), которые невозможно создать с невалидными значениями, дает несколько преимуществ:
- Логика валидации собрана в единственном месте, что максимально упрощает внесение в нее изменений.
- Нет никакой необходимости думать о валидации Email или Discount перед их использованием, так как эти объекты гарантировано содержат валидные значения.
- Объекты являются неизменяемыми, что защищает приложение побочных эффектов, связанных с Email и Discount.
- Когда программист натыкается в коде на объекты Email или Discount, у него уйдет одна секунда на нахождение соответствующей логики валидации.
Недостатки подхода
- Свойство-объект с единственным примитивом внутри сложнее мапить на колонку в таблице базы данных при работе с ORM, чем свойство-примитив. Тем не менее Entity Framework Core позволяет достаточно легко это сделать при помощи механизма Value Conversions.
- Неизменяемость создаваемых объектов в отдельных случаях может привести к проблемам с производительностью, при попытках их частых «изменений».