Domain-Driven Design: Простые способы улучшить дизайн ПО

1 декабря 2011 г.

Недавно я проводил консультацию, в ходе которой заметил, что можно эффективно использовать несколько простых приемов для улучшения дизайна ПО. Несмотря на то, что приемы и правда простые, эффект получает очень сильным. Эти приемы давно известны и применяются в повседневной работе, особенно теми, кто уже использует Domain-Driven Design.

Проблема

Как оказалось, обычными являются доменные объекты вида (код из реального проекта):

public class ScanerData : IEntity
{
    public virtual int Id { get; protected set; }

    public virtual DateTime Time { get;  set; }

    public virtual string PipeId { get;  set; }
       
    public virtual byte[] Bitmap { get; set; }

    public virtual ComputerData Mashine {get;  set;}
}

Если этот доменный объект содержит в себе только поля для данных, то где находится логика работы с этим объектом и связанными с ним объектами? Я обычно нахожу эту логику в классах типа *Manager, *Helper, *Wrapper, *Service и т.п, которые в 99% случаев нарушают принцип единственности ответственности. Бизнес-логику пишут где угодно, только не в самих доменных объектах. В этом случае объекты являются контейнерами для данных, чем-то вроде DTO между БД и слоем "Business Logic". Такая доменная модель называется анемичной со всеми вытекающими отсюда последствиями.

Ниже описаны 3 простых приема, которые позволят улучшить дизайн вашей системы. Детальные описания вы найдете в ссылках в конце статьи.

№1 Убираем публичные сеттеры

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

Ошибочное решение

Будем делать это в классе ProductService:

public class Product
{
    public int Price { get; set; }

    public CategoryType Category { get; set; }

    public int Discount { get; set; }
}

public enum CategoryType
{
    Regular,
    Super
}

public class ProductService
{
    public void ChangePrice(Product product, int newPrice)
    {
        product.Price = newPrice;

        if (product.Price < 100)
            product.Category = CategoryType.Regular;

        if (product.Category == CategoryType.Regular)
            product.Discount = 0;
    }
}

Где гарантия, что когда мы захотим поменять цену у продукта через setter, мы вспомним про метод ChangePrice у ProductService? А если захотим изменить цену продукта в другом месте, то надо не забыть проверить границу цены и изменить категорию. Еще держим в голове, что при изменении категории, здесь или в любом другом классе, надо бы не забыть обнулить скидку, если категория стала Regular.

Правильное решение

public class AnyClass
{
    public void AnyMethod(Product product, int newPrice)
    {
        product.ChangePriceTo(newPrice);
    }
}

public class Product
{
    private const int SomeMinPriceFromDomainLogic = 100;

    public int Price { get; private set; }

    public CategoryType Category { get; private set; }

    public int Discount { get; private set; }

    public void ChangePriceTo(int newPrice)
    {
        Price = newPrice;

        if (Price < SomeMinPriceFromDomainLogic)
            SetCategory(CategoryType.Regular);
    }

    private void SetCategory(CategoryType category)
    {
        Category = category;

        if (Category == CategoryType.Regular)
            Discount = 0;
    }
}

Мы сделали сеттеры приватными, а сам доменный объект теперь предоставляет открытый метод для изменения своего состояния - ChangePriceTo. Доменный объект сам контролирует внесение изменения в цену.

Здесь речь идет про обычную инкапсуляцию. Почему о ней забывают, когда используют Auto-Property?

№2 Добавляем параметризованный конструктор

User Story: У пользователя обязательно должно быть не пустое имя.

Ошибочное решение

public class AccountHelper
{
    public void CreateAccount(string name)
    {
        Account account = new Account();

        account.Name = name;
    }
}

public class Account
{
    private string name;

    public string Name
    {
        get { return name; }
        set
        {
            if (string.IsNullOrEmpty(name))
                throw new ArgumentException("name is empty", "name");

            name = value;
        }
    }
}

Account можно создать без имени? Да, можно, если не выставить ему имя после создания. При этом Account будет в неверном состоянии. Значит User Story не работает. Мы выполнили только ту часть, которая связана с не пустым именем. При создании Account'a API домена никак нас не ограничило в создании класса с неверным состоянием.

Правильное решение

public class Account
{
    public Account([NotNull] string name)
    {
        if (string.IsNullOrEmpty(name))
            throw new ArgumentNullException("name");

        Name = name;
    }

    public string Name { get; private set; }
}

public class AnyClass
{
    public void AnyMethod(string name)
    {
        var account = new Account(name);
    }
}

Теперь Account можно создать только в валидном состоянии, без вариантов.

Я использовал атрибут NotNull, очень полезная примочка для ReSharper - External Annotations.

№3 Заменяем List, IList, ICollection и т.п. на IEnumerable

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

Ошибочное решение

public class Account
{
    public Account()
    {
        Roles = new List<Role>();
    }

    public List<Role> Roles { get; set; } 
    public List<HistoryEntry> HistoryEntries { get; set; } 
}

public class HistoryEntry
{
    public HistoryEntry(Role role)
    {
        Role = role;
        CreatedAt = DateTime.Now;
    }

    public DateTime CreatedAt { get; private set; }
    public Role Role { get; private set; }
}

public class AccountHelper
{
    public void AddRole(Account account, Role role)
    {
        if (account.Roles.Contains(role))
            throw new AccountStateException();

        account.Roles.Add(role);

        account.HistoryEntries.Add(new HistoryEntry(role));
    }
}

Чем плох интерфейс IList? Тем, что у него есть методы Add, Remove и т.п. Работу с этими методами вне объекта мы контролировать не можем. Кто знает, когда и где будет вызван account.Roles.Add(role)? Будут ли сделаны все необходимые проверки перед добавлением роли?

Правильное решение

public class Account
{
    private readonly List<Role> roles = new List<Role>();
    private readonly List<HistoryEntry> historyEntries = new List<HistoryEntry>();

    public IEnumerable<Role> Roles
    {
        get { return roles; }
    }

    public IEnumerable<HistoryEntry> HistoryEntries
    {
        get { return historyEntries; }
    }

    public void AddRole(Role role)
    {
        if (roles.Any(x => x == role))
            throw new AccountStateException();

        roles.Add(role);

        historyEntries.Add(new HistoryEntry(role));
    }
}

public class HistoryEntry
{
    public HistoryEntry(Role role)
    {
        Role = role;
        CreatedAt = DateTime.Now;
    }

    public DateTime CreatedAt { get; private set; }
    public Role Role { get; private set; }
}

public class AnyClass
{
    public void AnyMethod(Account account, Role role)
    {
        account.AddRole(role);
    }
}

Интерфейс IEnumerable позволяет делать только чтение коллекции, но не изменение. Никто извне, без контроля со стороны класса Account, не сможет добавить или удалить роль.

Обратите внимание, что Accout является корнем агрегата и вся работа по добавлению роли, проверки и добавлению истории проходит через метод AddRole. Класс Account скрывает работу с объектом HistoryEntry.

Заключение

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


Ссылки

Серия статей: Strengthening your domain, Jimmy Bogard

Anemic Domain Model, Martin Fowler

Doing it wrong: getters and setters

Domain-Driven Design: создание домена

Domain-Driven Design: aggregation root

Моя книга «Антихрупкость в IT»

Как достигать результатов в IT-проектах в условиях неопределённости. Подробнее...