Одна из составляющих успешного приложения - это создание домена, который наиболее точно подходит к сценариями использования системы.
Анемичная доменная модель
Если ваши доменные объекты являются контейнерами данных и всё, что в них есть, это свойства get/set, то вы используете анемичную доменную модель. Её особенностью является то, что доменный объект не имеет поведения.
Задача
Сценарии использования, к примеру, интернет-магазина:
- Пользователь, который зарегистрировался в системе, получает письмо со ссылкой на подтверждение регистрации. Перейдя по ссылке, он подтверждает свою регистрацию и может заходить под своим логином и паролем в систему.
- Пользователь может делать заказы
- При этом в личном кабинете он видит общую сумму, на которую заказал. В общей сумме текущих заказов не учитываются уже завершенные заказы
Начнем с анемичной модели данных. У нас будет класс Account:
public class Account { public int Id { get; set; } public bool IsApproved { get; set; } public DateTime? ActivationDate { get; set; } public List<Order> Orders { get; set; } }
И класс Order:
public class Order { public int Id { get; set; } public int Price { get; set; } public Account Account { get; set; } public bool IsComplete { get; set; } }
Реализация
Каждый из сценариев работы довольно просто реализовать:
Сценарий №1. Активация пользователя
account.ActivationDate = DateTime.Now; account.IsApproved = true;
Сценарий №2. Добавление заказа
account.Orders.Add(order); order.Account = account;
Сценарий №3. Подсчёт общей суммы
account.Orders .Where(order => order.IsComplete == false) .Sum(order => order.Price);
Главный вопрос: где будет располагаться этот код?
Решение №0
Есть самое простое и неправильное решение. Мы будем писать этот код прямо в обработчиках на aspx-страницах или WinForms:
public partial class Default : Page { protected void Page_Load(object sender, EventArgs e) { // выборка объекта account AccountOrdersSumLabel.Text = account.Orders .Where(order => order.IsComplete == false) .Sum(order => order.Price); } protected void AddOrderButton_Click(object sender, EventArgs e) { // выборка объекта account account.Orders.Add(order); order.Account = account; // сохранение объекта account } }
Все будет хорошо, пока добавлять продукт можно только из этой формы, а подсчёт общей суммы происходит только по этой формуле. Проблемы начнутся, когда на другой форме потребуется такая же функциональность. Придется дублировать код. Тогда, при изменении логики работы, придется исправлять её во всех code-behind'ах.
Глупо дублировать код, а потом тратить много времени на исправление одного изменившегося бизнес-требования.
Решение №1
Все-таки дублировать не будем. Мы вынесем код реализации наших сценариев в класс со звучным названием AccountHelper или AccountManager. Скорее всего этот класс будет без состояния, а потому статическим.
Получаем:
public static class AccountHelper { public static void Activate(Account account) { account.ActivationDate = DateTime.Now; account.IsApproved = true; } public static void AddOrder(Account account, Order order) { account.Orders.Add(order); order.Account = account; } public static int CalculateOrdersSum(Account account) { return account.Orders .Where(order => order.IsComplete == false) .Sum(order => order.Price); } }
Проблема классов с названием *Helper или *Manager в том, что они могут себе позволить делать всё, что угодно. Их абстрактные названия позволяют «помогать» классу Account делать абсолютно разные вещи. Такие классы со временем становятся God-object.
У таких классов множество недостатков. Например, трудно тестировать код, который использует эти классы, потому что они статические. Они делают код сильно связаным, т.к. нарушают принцип инверсии зависимостей. Очень часто из одного Helper'а вызывают другие Helper'ы. В итоге, граф зависимостей напоминает паутину из связей.
К тому же, это решение обладает всеми недостатками следующего.
Решение №2
Начнем бороться с сильной связанностью в коде. Сделаем всё правильно и создадим класс AccountService с интерфейсом IAccountService. Все объекты, которым понадобится активация или добавление заказа будут использовать интерфейс IAccountService вместо конкретной реализации. Это также поможет нам в тестировании кода.
public interface IAccountService { void Activate(Account account); void AddOrder(Account account, Order order); int CalculateOrdersSum(Account account); } public class AccountService : IAccountService { public void Activate(Account account) { account.ActivationDate = DateTime.Now; account.IsApproved = true; } public void AddOrder(Account account, Order order) { account.Orders.Add(order); order.Account = account; } public int CalculateOrdersSum(Account account) { return account.Orders .Where(order => order.IsComplete == false) .Sum(order => order.Price); } }
Разобрались со связанностью и тестирование. Уже шаг вперёд. Но я вижу ещё две проблемы.
Функций типа AddOrder и CalculateOrdersSum будет довольно много. Через пол года разработки интерфейс IAccountService вырастит до 40-50 функций. «Загрязнение» интерфейса можно было бы пережить, если бы не вторая проблема.
В коде в любом месте можно в обход сервиса написать «свою активацию» пользователя. Например, взять объект Account из базы, выставить ему поле IsApproved в true и при этом забыть обновить поле ActivationDate. Тоже самое касается сценария добавления заказа. Можно вызвать функцию Add у свойства Orders где угодно и забыть выставить поле Account у добавляемого заказа. Это делает систему нестабильной. API приложения беззащитно перед пользователями системы. С таким подходом остается только надеятся, что программист найдёт нужную ему функцию в IAccountService, а не станет изобретать свой подход.
Решение №3
Поместим все эти функции в сам доменный объект Account. Обратите внимание на то, как изменились модификаторы доступа к полям объекта:
public class Account { private readonly List<Order> orders; public Account() { orders = new List<Order>(); } public int Id { get; set; } public bool IsApproved { get; private set; } public DateTime? ActivationDate { get; private set; } public IEnumerable<Order> Orders { get { return orders; } } public int OrdersSum { get { return Orders .Where(order => order.IsComplete == false) .Sum(order => order.Price); } } public void Activate() { ActivationDate = DateTime.Now; IsApproved = true; } public void AddOrder(Order order) { orders.Add(order); order.Account = this; } }
Теперь домен нашего приложения даёт пользователю готовое API, которое не требудет ни Helper'ов, ни сервисов. К тому же мы уберегаем пользователя от ошибок. Он уже не сможет активировать Account выставив только IsApproved. Теперь функция Activate сама заполнит нужные поля.
Заключение
Итак, если функция оперирует данными и объектами, которые находятся внутри домена, то, скорее всего, надо оставлять эту функцию внутри домена. Кроме надёжности кода, вы в добавок создадите доменный язык для вашего приложения.
Ссылки