Как правило статьи, рассказывающие о проектировании типами, содержат примеры на функциональных языках — Haskell, F# и других. Может показаться, что эта концепция неприменима к объектно-ориентированным языкам, но это не так.


В этой статье я переведу примеры из статьи Скотта Власчина Проектирование типами: Как сделать некорректные состояния невыразимыми на идиоматический C#. Также я постараюсь показать, что этот подход применим не только в качестве эксперимента, но и в рабочем коде.


Создаём доменные типы


Сначала надо портировать типы из предыдущей статьи серии, которые используются в примерах на F#.


Оборачиваем примитивные типы в доменные


Примеры на F# используют доменные типы вместо примитивов для адреса электронной почты, почтового кода США и кода штата. Попробуем сделать обёртку примитивного типа на C#:


public sealed class EmailAddress
{
    public string Value { get; }

    public EmailAddress(string value)
    {
        if (value == null)
        {
            throw new ArgumentNullException(nameof(value));
        }
        if (!Regex.IsMatch(value, @"^\S+@\S+\.\S+$"))
        {
            throw new ArgumentException("Email address must contain an @ sign");
        }

        Value = value;
    }

    public override string ToString()
        => Value;

    public override bool Equals(object obj)
        => obj is EmailAddress otherEmailAddress &&
           Value.Equals(otherEmailAddress.Value);

    public override int GetHashCode()
        => Value.GetHashCode();

    public static implicit operator string(EmailAddress address)
        => address?.Value;
}

var a = new EmailAddress("a@example.com");
var b = new EmailAddress("b@example.com");

var receiverList = String.Join(";", a, b);

Я перенёс проверку корректности адреса из фабричной функции в конструктор, поскольку такая реализация более типична для C#. Также пришлось реализовать сравнение и преобразование к строке, что на F# сделал бы компилятор.


С одной стороны, реализация выглядит довольно объёмной. С другой стороны, специфика адреса электронной почты выражена здесь только проверками в конструкторе и, возможно, логикой сравнения. Большую часть здесь занимает инфраструктурный код, который, к тому же, вряд ли будет меняться. Значит, можно либо сделать шаблон, либо, на худой конец, копировать общий код из класса в класс.


Надо отметить, что, создание доменных типов из примитивных значений — это не специфика функционального программирования. Наоборот, использование примитивных типов считается признаком плохого кода в ООП. Примеры таких обёрток можно у видеть, например, в NLog и в NBitcoin, да и стандартный тип TimeSpan — это, по сути обёртка над числом тиков.


Создаём объекты-значения


Теперь надо создать аналог записи:


public sealed class EmailContactInfo
{
    public EmailAddress EmailAddress { get; }

    public bool IsEmailVerified { get; }

    public EmailContactInfo(EmailAddress emailAddress, bool isEmailVerified)
    {
        if (emailAddress == null)
        {
            throw new ArgumentNullException(nameof(emailAddress));
        }

        EmailAddress = emailAddress;
        IsEmailVerified = isEmailVerified;
    }

    public override string ToString()
        => $"{EmailAddress}, {(IsEmailVerified ? "verified" : "not verified")}";
}

Снова потребовалось больше кода, чем на F#, но большую часть работы можно выполнить за счёт рефакторингов в IDE.


Как и EmailAddress, EmailContactInfo — это объект-значение (в смысле DDD, а не типов-значений в .NET), давно известный и применяемый в объектом моделировании.


Остальные типы — StateCode, ZipCode, PostalAddress и PersonalName портируются на C# схожим образом.


Создаём контакт


Итак, код должен выражать правило "Контакт должен содержать адрес электронной почты или почтовый адрес (или оба адреса)". Требуется выразить это правило таким образом, чтобы корректность состояния была видна из определения типов и проверялась компилятором.


Выражаем различные состояния контакта


Значит, контакт — это объект, содержащий имя человека и либо адрес электронной почты, либо почтовый адрес, либо оба адреса. Очевидно, один класс не может содержать трёх разных наборов свойств, следовательно, надо определить три разных класса. Все три класса должны содержать имя контакта и при этом должна быть возможность обрабатывать контакты разных типов единообразно, не зная, какие именно адреса содержит контакт. Следовательно, контакт будет представлен абстрактным базовым классом, содержащим имя контакта, и тремя реализациями с различным набором полей.


public abstract class Contact
{
    public PersonalName Name { get; }

    protected Contact(PersonalName name)
    {
        if (name == null)
        {
            throw new ArgumentNullException(nameof(name));
        }

        Name = name;
    }
}

public sealed class PostOnlyContact : Contact
{
    private readonly PostalContactInfo post_;

    public PostOnlyContact(PersonalName name, PostalContactInfo post)
        : base(name)
    {
        if (post == null)
        {
            throw new ArgumentNullException(nameof(post));
        }

        post_ = post;
    }
}

public sealed class EmailOnlyContact : Contact
{
    private readonly EmailContactInfo email_;

    public EmailOnlyContact(PersonalName name, EmailContactInfo email)
        : base(name)
    {
        if (email == null)
        {
            throw new ArgumentNullException(nameof(email));
        }

        email_ = email;
    }
}

public sealed class EmailAndPostContact : Contact
{
    private readonly EmailContactInfo email_;

    private readonly PostalContactInfo post_;

    public EmailAndPostContact(PersonalName name, EmailContactInfo email, PostalContactInfo post)
        : base(name)
    {
        if (email == null)
        {
            throw new ArgumentNullException(nameof(email));
        }
        if (post == null)
        {
            throw new ArgumentNullException(nameof(post));
        }

        email_ = email;
        post_ = post;
    }
}

Вы можете возразить, что надо использовать композицию, а не наследование, и вообще надо наследовать поведение, а не данные. Замечания справедливые, но, на мой взгляд, применение иерархии классов здесь оправдано. Во-первых, подклассы не просто представляют особые случаи базового класса, вся иерархия представляет собой одну концепцию — контакт. Три реализации контакта очень точно отражают три случая, оговоренные бизнес-правилом. Во-вторых, взаимосвязь базового класса и его наследников, разделение обязанностей между ними легко прослеживается. В-третьих, если иерархия станет действительно проблемой, можно выделить состояние контакта в отдельную иерархию, как это было сделано в исходном примере. На F# наследование записей невозможно, зато новые типы объявляются достаточно просто, поэтому разбиение было выполнено сразу. На C# же более естественным решением будет разместить поля Name в базовом классе.


Создание контакта


Создание контакта происходит довольно просто.


public abstract class Contact
{
    public static Contact FromEmail(PersonalName name, string emailStr)
    {
        var email = new EmailAddress(emailStr);
        var emailContactInfo = new EmailContactInfo(email, false);
        return new EmailOnlyContact(name, emailContactInfo);
    }
}

var name = new PersonalName("A", null, "Smith");
var contact = Contact.FromEmail(name, "abc@example.com");

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


Обновление контакта


Обновление контакта тоже не вызывает сложностей — надо просто добавить абстрактный метод в тип Contact.


public abstract class Contact
{
    public abstract Contact UpdatePostalAddress(PostalContactInfo newPostalAddress);
}

public sealed class EmailOnlyContact : Contact
{
    public override Contact UpdatePostalAddress(PostalContactInfo newPostalAddress)
        => new EmailAndPostContact(Name, email_, newPostalAddress);
}

public sealed class PostOnlyContact : Contact
{
    public override Contact UpdatePostalAddress(PostalContactInfo newPostalAddress)
        => new PostOnlyContact(Name, newPostalAddress);
}

public sealed class EmailAndPostContact : Contact
{
    public override Contact UpdatePostalAddress(PostalContactInfo newPostalAddress)
        => new EmailAndPostContact(Name, email_, newPostalAddress);
}

var state = new StateCode("CA");
var zip = new ZipCode("97210");
var newPostalAddress = new PostalAddress("123 Main", "", "Beverly Hills", state, zip);
var newPostalContactInfo = new PostalContactInfo(newPostalAddress, false);
var newContact = contact.UpdatePostalAddress(newPostalContactInfo);

Как и при использовании option.Value в F#, здесь возможен выброс исключения из конструкторов, если адрес электронной почты, почтовый индекс или штат указаны неверно, но для C# это является распространённой практикой. Конечно же, в рабочем коде здесь или где-то в вызывающем коде должна быть предусмотрена обработка исключений.


Обработка контактов вне иерархии


Логично расположить логику обновления контакта в самой иерархии Contact. Но что, если требуется выполнить что-то, что не укладывается в её область ответственности? Предположим, что надо отобразить контакты на пользовательском интерфейсе.


Можно, конечно, опять добавить абстрактный метод в базовый класс и продолжать добавлять по новому метод каждый раз, когда понадобится ещё как-то обрабатывать контакты. Но тогда будет нарушен принцип единственной ответственности, иерархия Contact будет захламлена, а логика обработки размазана между реализациями Contact и местами ответственными за, собственно, обработку контактов. В F# такой проблемы не было, хотелось бы, чтобы код на C# был не хуже!


Ближайшим аналогом сопоставления с образцом в C# является конструкция switch. Можно было бы добавить в Contact свойство перечислимого типа, которое позволяло бы определить реальный тип контакта и выполнить преобразование. Также можно было бы использовать более новые возможности C# и выполнять switch по типу экземпляра Contact. Но ведь мы хотели, чтобы при добавлении новых корректных состояний Contact компилятор сам подсказывал, где не хватает обработки новых случаев, а switch не гарантирует обработку всех возможных случаев.


Но ведь в ООП есть и более удобный механизм для выбора логики в зависимости от типа, и мы им только что воспользовались при обновлении контакта. А раз теперь выбор зависит и от вызывающего типа, то он тоже должен быть полиморфным. Решение — шаблон Посетитель (Visitor). Он позволяет выбирать обработчик в зависимости от реализации Contact, отвязывает методы обработки контактов от их иерархии, и, если добавится новый тип контакта, и, соответственно, новый метод в интерфейсе Посетителя, то потребуется его написать во всех реализациях интерфейса. Все требования выполнены!


public abstract class Contact
{
    public abstract void AcceptVisitor(IContactVisitor visitor);
}

public interface IContactVisitor
{
    void Visit(PersonalName name, EmailContactInfo email);

    void Visit(PersonalName name, PostalContactInfo post);

    void Visit(PersonalName name, EmailContactInfo email, PostalContactInfo post);
}

public sealed class EmailOnlyContact : Contact
{
    public override void AcceptVisitor(IContactVisitor visitor)
    {
        if (visitor == null)
        {
            throw new ArgumentNullException(nameof(visitor));
        }

        visitor.Visit(Name, email_);
    }
}

public sealed class PostOnlyContact : Contact
{
    public override void AcceptVisitor(IContactVisitor visitor)
    {
        if (visitor == null)
        {
            throw new ArgumentNullException(nameof(visitor));
        }

        visitor.Visit(Name, post_);
    }
}

public sealed class EmailAndPostContact : Contact
{
    public override void AcceptVisitor(IContactVisitor visitor)
    {
        if (visitor == null)
        {
            throw new ArgumentNullException(nameof(visitor));
        }

        visitor.Visit(Name, email_, post_);
    }
}

Теперь можно написать код для отображения контактов. Для простоты я буду использовать консольный интерфейс.


public sealed class ContactUi
{
    private sealed class Visitor : IContactVisitor
    {
        void IContactVisitor.Visit(PersonalName name, EmailContactInfo email)
        {
            Console.WriteLine(name);
            Console.WriteLine("* Email: {0}", email);
        }

        void IContactVisitor.Visit(PersonalName name, PostalContactInfo post)
        {
            Console.WriteLine(name);
            Console.WriteLine("* Postal address: {0}", post);
        }

        void IContactVisitor.Visit(PersonalName name, EmailContactInfo email, PostalContactInfo post)
        {
            Console.WriteLine(name);
            Console.WriteLine("* Email: {0}", email);
            Console.WriteLine("* Postal address: {0}", post);
        }
    }

    public void Display(Contact contact)
        => contact.AcceptVisitor(new Visitor());
}

var ui = new ContactUi();
ui.Display(newContact);

Дальнейшие улучшения


Если Contact объявлен в библиотеке и появление новых наследников в клиентах библиотеки нежелательно, то можно изменить область видимости конструктора Contact на internal, либо вообще сделать его наследников вложенными классами, объявить видимость реализаций и конструктора private, а создание экземпляров делать через только статические методы-фабрики.


public abstract class Contact
{
    private sealed class EmailOnlyContact : Contact
    {
        public EmailOnlyContact(PersonalName name, EmailContactInfo email)
            : base(name)
        {

        }
    }

    private Contact(PersonalName name)
    {

    }

    public static Contact EmailOnly(PersonalName name, EmailContactInfo email)
        => new EmailOnlyContact(name, email);
}

Таким образом можно воспроизвести нерасширяемость типа-суммы, хотя, как правило, этого не требуется.


Заключение


Надеюсь, мне удалось показать, как средствами ООП ограничить корректные состояния бизнес-логики при помощи типов. Код получился более объёмным, чем на F#. Где-то это обусловлено относительной громоздкостью решений ООП, где-то — многословностью языка, но решения нельзя назвать непрактичными.


Что интересно, начав с чисто функционального решения, мы пришли к следованию рекомендация предметно-ориентированного программирования и паттернам ООП. На самом деле, это не удивительно, потому что подобие типов-сумм и паттерна Посетитель известно, и довольно давно. Целью этой статьи было показать не столько конкретный приём, сколько продемонстрировать применимость идей из "башни из слоновой кости" в императивном программировании. Конечно, не всё получится перенести так же легко, но с появлением всё новых и новых функциональных возможностей в мейнстримовых языках программирования границы применимого будут расширятся.




> Код примеров доступен на GitHub

Комментарии (21)


  1. leotsarev
    28.11.2018 10:23

    А еще не хватает примера, как грузить контакт из базы :-)


    1. D4RKV01D
      28.11.2018 14:35

      Ну, по логике вещей, они вытягиваються в виде простых моделей, а потом при помощи мапера превращаються в такие контакты.


      1. leotsarev
        29.11.2018 12:22

        Да, просто в этом случае в мэппере получается волосня, нет?


    1. MaxKot Автор
      28.11.2018 14:43

      А в чём сложность?


      Если доступ в БД организован хранимыми процедурами, то есть или DataReader, или DTO из которых напрямую можно вытащить данные, определить, какие данные контакта есть и создать экземпляр соответсвющего класса.


      Если используется ORM, то всё уже зависит от конкретной реализации, но, насколько я знаю, Entity Framework и NHibernate поддерживают наследование.


      1. D4RKV01D
        28.11.2018 17:03

        Вы предлагаиете наследовать ваши модели от классов которые используют ORM?


        1. MaxKot Автор
          28.11.2018 23:44

          Нет, я предлагаю использовать мои модели непосредственно в качестве классов, которые сохраняются в БД через ORM. Я имел ввиду, что ORM позволяют сохранять иерархию классов и восстанавливать нужного наследника из БД. Насколько сильно придётся подстраивать модели под возможности ORM, надо смотреть по конкретной ORM.


          1. buldo
            29.11.2018 00:12
            +1

            Ух, ну не надо так прибивать гвоздями друг другу доменную модель и модель хранения. Даже если есть ORM, то вполне вероятно, что структура доменного объекта будет не похожа на оптимальное представление в БД


            1. MaxKot Автор
              29.11.2018 00:48

              А как вы предлагаете использовать ORM? Чисто для доступа к БД?


              По моему опыту в этом случае лучше использовать хранимые процедуры, потому что всё равно получаются те же хранимые процедуры, только на C#. В результате ORM фактически работает с DTO, эти DTO всё равно надо как-то отображать в доменную модель, при этом язык для написания запросов ограничен, к оптимизации запросов прибавляется слой трансляции из C# в SQL, а схема БД жёстко связана с приложением (или приложениями).


              1. buldo
                29.11.2018 01:14
                +1

                Нет, только не хранимки. По моему мнению их использование усложняет поддержку — слой доступа к данным уезжает непонятно куда и выходит из под контроля.
                Если говорить о настольных приложениях — то все просто — поднял доменную модель из базы и объекты долго живут и все хорошо.
                Если говорить о веб приложениях, то объекты живут условно говоря только на время запроса. И тут приходит на помощь CQRS и начинают появляться плюсы orm. При выполнении команды — поднимаем нормальную модель из базы. При выполнении запроса работаем напрямую с orm, строя хитрые оптимальные запросы. Ведь, чтобы отобразить на странице список имён каких либо элементов, нам не нужно доставать из базы целые сущности — достаточно достать именно имена, да и сортировку можно сделать средствами базы.


                1. MaxKot Автор
                  29.11.2018 10:05

                  слой доступа к данным уезжает непонятно куда и выходит из под контроля.

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


                  поднял доменную модель из базы


                  поднимаем нормальную модель из базы


                  нам не нужно доставать из базы целые сущности

                  Видимо, мы не совсем поняли друг друга. В моём понимании иерархия Contact в этом примере — это и есть сущность. То есть это модель предметной области, а не модель представления для MVC. Должно быть, пример с отображением Contact сразу на интерфейс создал не совсем правильное впечатление.


                  1. buldo
                    29.11.2018 10:29

                    Впринципе подход с SSDT имеет право на жизнь, однако если все запросы приходится писать на чистом SQL, то их потом и маппить вручную приходится на объекты? Не задалбывает? Кажется что это тупая и монотонная работа.


                    Нет, я понял о какой модели вы говорите. Я имел ввиду, что не всегда модель простая — в моей работе чаще всего это агрегат. При этом не всегда все части этого агрегата лежат в одной и той же базе. Иногда это может быть объектная БД с доступом через REST. Так вот для того, чтобы выполнять запросы к сервису "на запись", то есть во время которых выполняется какая-то бизнес-логика, мы собираем полный агрегат. Но это слишком дорого делать на каждый чих, например для запросов на чтение кусков этого агрегата. Тогда мы лезем напрямую в базу и не строим жирную модель.


              1. D4RKV01D
                29.11.2018 12:45

                Хранить ваши модели в БД так себе затея
                1) При таком подходе сложнее поддердживать бд/ домейн логику, т.к. изминение в вашей модели затрагивают DAL и Buisness Logic.
                2) Если БД уже есть вам нужно будет писать аналогичные модели, для того чтобы отобразить структуру БД
                3) Изменение источника данных будет крайне сложным (например если источник данных нужно будет изменить на вызов сервиса, место БД)


      1. leotsarev
        29.11.2018 12:23

        Покажите как это красиво сделать, если не сложно.


  1. Sinatr
    28.11.2018 11:33

    Как правило статьи, рассказывающие о проектировании типами, содержат примеры на функциональных языках
    Что за «проектирование типами»? Первый раз слышу термин.
    Оборачиваем примитивные типы в доменные
    Что такое «доменные типы»? Типы, которые вы создаете, похожи на простые ООП обьекты.
    Надеюсь, мне удалось показать, как средствами ООП ограничить корректные состояния бизнес-логики при помощи типов.
    Хмм… вы показали какой-то код, но так как постановка задачи непонятно где, то сложно сказать справились вы или нет.

    Придирки:

    • Имена приватных полей с подчеркиванием в конце?
    • ContactUI лучше чем ContactUi


    1. Szer
      28.11.2018 11:43

      Что за «проектирование типами»? Первый раз слышу термин.

      Type Driven Development


    1. MaxKot Автор
      28.11.2018 14:35

      Что за «проектирование типами»?

      Как было отмечено выше, Type Driven Development. У Влащина есть серия на эту тему "Designing with types", из которой и были портированы примеры.


      Что такое «доменные типы»?

      Имеется ввиду, что это типы, выражающие специфику предметной области. То есть можно было бы везде использовать string для хранения адресов электронной почты, но не всякая строка является адресом электронной почты, и чтобы это выразить вводится "доменный" тип.


      так как постановка задачи непонятно где

      Во-первых, задача ставилась в статье, на которуюя я ссылаюсь в самом начале этой статьи и из которой были портированы примеры. Во-вторых, задча ещё раз указана начале раздела "Создаём контакт":


      Итак, код должен выражать правило "Контакт должен содержать адрес электронной почты или почтовый адрес (или оба адреса)".


      Имена приватных полей с подчеркиванием в конце?

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


      ContactUI лучше чем ContactUi

      Дело вкуса.


      1. Sinatr
        29.11.2018 13:26

        Про TDD, который test driven development, слышал, про ваш — нет, но по крайней мере «type driven development» гуглится, в отличие от «проектирование типами», спасибо. За доменные типы тоже спасибо, знал, просто забыл.

        Насчет постановки задачи, ее не понять, если не быть в теме, потому и спросил. Но тема для меня лично не выглядит интересной, т.к. то что вы делали (из того что я понял) — весьма очевидное, я бы тоже сделал «как-то так», но т.к. в статье нет сравнения с «неправильным» кодом — поучиться чему-то увы не получилось.

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

        Подчеркивания в конце имени поля — ни разу не встречал, не знаю о каком автодополнении вы говорите (в какой среде/редакторе), даже не задумывался об этом в VS. Довольно уверенно могу рекомендовать использовать подчеркивания в начале, т.к. сложилось впечатление, что вы используете правила из других языков.

        По поводу именования, есть guidelines, это не обязательные, но все же рекомендации, ContactUI — правильнее в C#.


  1. Atreides07
    28.11.2018 15:20
    +2

    Конкретно эта статья и оригинал статьи на F# это типичный пример того как категорически нельзя писать бизнес логику. И то как, к большому сожалению, многие пишут бизнес логику.
    Проблема этого кода в том что пока два типа Post, Email у тебя получается всего три варианта возможных значений: PostOnly, EmailOnly, PostAndEmail, и соответственно если бизнес попросит добавить третье значение то образуется "комбинаторный взрыв" Phone, Email, Post, PhoneAndEmail, PhoneAndPost, EmailAndPost, PhoneAndEmailAndPost. А в примере на C# на каждый из них надо модифицировать тонну C# код с наследованием, визиторами и т.д. и т.п. А если бизнес попросит 4-тый (рабочий адрес/телефон)? 5-тый?


    Автор сам делает развитие этой мысли и решение в следующей статье: https://fsharpforfunandprofit.com/posts/designing-with-types-discovering-the-domain/


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


  1. 0xd34df00d
    28.11.2018 22:06

    Принципиальная разница с выражением типами в том, что вот эти все проверки в конструкторах — это действия, а не значения. Их нельзя потребовать (как значение некоторого типа) и использовать (для типобезопасности) в клиентском коде. Если вы их из конструктора вдруг уберёте, ничего в клиентском коде не сломается, и даже ни одна из сигнатур не поменяется.


  1. gdt
    29.11.2018 03:54

    1. MaxKot Автор
      29.11.2018 09:47

      Спасибо, исправил.