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

Правило звучало так: «У контакта должен быть электронный или почтовый адрес».

А тип, который мы спроектировали, получился таким:

type ContactInfo =
    | EmailOnly of EmailContactInfo
    | PostOnly of PostalContactInfo
    | EmailAndPost of EmailContactInfo * PostalContactInfo

Теперь представим, что бизнес решил добавить номера телефонов. Новое бизнес-правило: «У контакта должно быть как минимум что-то из перечисленного: электронный адрес, почтовый адрес, домашний телефон или рабочий телефон».

Как нам представить эту логику?

Немного поразмыслив, приходим к выводу, что существует 15 возможных комбинаций этих четырёх способов контакта. Мы ведь не хотим создавать объединение с 15-ю вариантами выбора? Есть ли лучший способ?

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

Изменение требований должно «ломать» код

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

type ContactInformation =
    {
    EmailAddresses : EmailContactInfo list;
    PostalAddresses : PostalContactInfo list
    }

Представим также, что вы создали функцию printReport, которая перебирает всю информацию и печатает её в отчёте:

// фиктивный код
let printEmail emailAddress =
    printfn "Email Address is %s" emailAddress

// фиктивный код
let printPostalAddress postalAddress =
    printfn "Postal Address is %s" postalAddress

let printReport contactInfo =
    let {
        EmailAddresses = emailAddresses;
        PostalAddresses = postalAddresses;
        } = contactInfo
    for email in emailAddresses do
         printEmail email
    for postalAddress in postalAddresses do
         printPostalAddress postalAddress

Грубо, но просто и понятно.

Теперь, если в силу вступает новое бизнес-правило, мы можем решить изменить структуру, чтобы в ней появились новые списки для телефонных номеров. Обновлённая структура будет выглядеть примерно так:

type PhoneContactInfo = string // на данный момент заглушка

type ContactInformation =
    {
    EmailAddresses : EmailContactInfo list;
    PostalAddresses : PostalContactInfo list;
    HomePhones : PhoneContactInfo list;
    WorkPhones : PhoneContactInfo list;
    }

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

Безусловно, вам придётся исправить любые сломавшиеся сопоставления с образцом. Но во многих случаях вы даже не узнаете о необходимости учесть новые варианты.

Например, вот printReport, умеющая работать с новыми списками:

let printReport contactInfo =
    let {
        EmailAddresses = emailAddresses;
        PostalAddresses = postalAddresses;
        } = contactInfo
    for email in emailAddresses do
         printEmail email
    for postalAddress in postalAddresses do
         printPostalAddress postalAddress

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

И снова перед нами проблема: можно ли спроектировать типы так, чтобы обезопасить себя от подобных ситуаций?

Углублённое понимание предметной области

Если вы ещё немного подумаете над нашим примером, то обнаружите, что за деревьями мы не увидели леса.

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

Но на самом деле всё это неправильно. Гораздо более подходящая концепция: «для связи с заказчиком, у нас будет список способов контакта, где способ контакта — это электронный адрес ИЛИ почтовый адрес ИЛИ телефонный номер».

Это — ключевая идея, как моделировать предметную область. Она приводит нас к созданию нового типа ContactMethod, который одним махом решает наши проблемы.

Давайте подправим типы так, чтобы применить новую концепцию:

type ContactMethod =
    | Email of EmailContactInfo
    | PostalAddress of PostalContactInfo
    | HomePhone of PhoneContactInfo
    | WorkPhone of PhoneContactInfo

type ContactInformation =
    {
    ContactMethods  : ContactMethod list;
    }

Теперь код отчёта должен быть изменён, чтобы обрабатывать новый тип:

// фиктивный код
let printContactMethod cm =
    match cm with
    | Email emailAddress ->
        printfn "Электронный адрес %s" emailAddress
    | PostalAddress postalAddress ->
         printfn "Почтовый адрес %s" postalAddress
    | HomePhone phoneNumber ->
        printfn "Домашний телефон %s" phoneNumber
    | WorkPhone phoneNumber ->
        printfn "Рабочий телефон %s" phoneNumber

let printReport contactInfo =
    let {
        ContactMethods=methods;
        } = contactInfo
    methods
    |> List.iter printContactMethod

Эти правки имеют ряд преимуществ.

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

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

Возвращаемся к бизнес-правилу с 15-ю комбинациями

Вернёмся к исходному примеру. Тогда мы решили, что для кодирования бизнес-правила нам, возможно, придётся описать все 15 возможных комбинаций способов связи.

Однако, новый взгляд на проблемы с отчётом, также влияет на наше понимание бизнес-правила.

Держа в голове концепцию «способа связи», мы можем перефразировать требование следующим образом: «У заказчика должен быть как минимум один способ связи, который может быть электронным адресом ИЛИ почтовым адресом ИЛИ телефонным номером».

Перепроектируем тип Contact так, чтобы он содержал список способов связи.

type Contact =
    {
    Name: PersonalName;
    ContactMethods: ContactMethod list;
    }

Но это всё ещё не совсем верно. Список может быть пустым. Как мы можем обеспечить соблюдение правила, согласно которому должен быть как минимум один способ связи?

Простейший способ заключается в том, чтобы добавить новое обязательное поле:

type Contact =
    {
    Name: PersonalName;
    PrimaryContactMethod: ContactMethod;
    SecondaryContactMethods: ContactMethod list;
    }

В этом дизайне поле PrimaryConactMethod (основной способ связи) является обязательным, а дополнительные способы связи — нет, что полностью соответствует бизнес-правилу!

Кроме того, этот рефакторинг дал нам новое понимание. Вполне возможно, что концепция «основного» и «дополнительного» способа связи может, в свою очередь, прояснить код в других местах, вызвав каскадные изменения в понимании и рефакторинге.

Заключение

В этом посте мы познакомились с тем, как использование типов для моделирования бизнес-правил может помочь вам разобраться в предметной области.

В книге Предметно-ориентированное проектирование Эрик Эванс посвятил целый раздел и, в частности, две главы (8 и 9) обсуждению важности углубленного рефакторинга.

Пример в этом посте сравнительно прост, но, я надеюсь, он показывает, как углубленное понимание может помочь улучшить как модель, так и правильность кода.

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

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


  1. amcured
    28.04.2026 06:49

    Изменение требований должно «ломать» код

    Чё? Автор, вроде, не совсем новичок, судя по страничке about, но это за гранью. Жертвовать обратной совместимостью ради непоймичего — это сразу на выход.

    Такой подход хорошо работает в тепличных условиях «сижу в кресле, пишу книжку», но ведь бывают соседние команды, пользователи, да что угодно.

    Не думал, что когда-нибудь это скажу, но это как раз тот случай, когда TDD прям сверкает. А типы сосут, да, как очень часто бывает в проектах сложнее тудушечки.


    1. markshevchenko Автор
      28.04.2026 06:49

      Мне кажется, вы не поняли мысль автора. Изменение бизнес-требований должно ломать код, чтобы компилятор вам подсказал, где его надо исправить. Скажем, если добавить новое значение в enum в C#, компилятор не подскажем места, где этот enum проверяется. В отличие от F#, который покажет, где надо дописать новый вариант.

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


      1. amcured
        28.04.2026 06:49

        А мне кажется, что если вы внимательно перечитаете пример автора, то вы увидите, что проблема «забыл про вновб добавленное» решена вызовом List.iter на коллекции, вместо хардкода. Это имеет какое-то отношение к типам? — Нет.

        Зато замена типа ContactInformation — на внезапно список (sic!) — преступление как перед всеми, кто этот тип использовал, так и перед всеми, кто его собирается использовать в будущем (теперь туда поместится пять имейлов и ни одного адреса).

        Типы — иногда — штука удобная, но вот такие бобмартиновские примеры даже ярого адепта отвадят.


        1. markshevchenko Автор
          28.04.2026 06:49

          Кажется, что в коде Влащина всё соответствует описанным бизнес-правилам. У контакта не обязательно должен быть адрес, но по меньшей мере какой-то один способ связи должен быть. Обязательное поле плюс список этому требованию соответствуют.

          По крайней мере, не вижу внутренних противоречий в статье.


    1. kleidemos
      28.04.2026 06:49

      Всё сильно зависит от проекта. У мя обычно речь идёт о десктопных/мобильных приложениях и в последнее время об играх. Тут по умолчанию нет команд, которые используют наши dll как либы в своём коде. Поэтому несмотря на приличный объём кодовой базы, мы очень легко идём на ломку типов.

      Более того, я предпочитаю ломать обратную совместимость каждый раз, когда чувствую, что во всём зависимом коде надо среагировать на изменения логики. В этом случае я могу искусственно заменить .PropertyName на .PropertyName_42, после чего пойти каскадно править весь поломанный код и в ручном режиме решать, достаточно ли механической правки имени, или нужно что-то потяжелее. Это же решение будут вынуждены принять все разрабы, когда стянут изменения в свои ветки.

      Сиё обсуждение происходит уже не в первый раз, и как я понял, для C#-еров такой подход выглядит экстремально, но тут просто необходимо учитывать устройство F#. Язык провоцирует нас создавать много типов с очень узкой сферой применения (<= домену в тдд). Если этому противиться, можно оказаться в ситуации, когда каждая правка будет сопровождаться философскими рассуждениями о том, каким эхом наши действия отразятся в вечности. Долго в режиме “симулятор тайп-астронавта: вид сбоку” существовать не получается, поэтому неизбежно начинается эрозия качества и прочий “реализьм”. Оба варианта мне не нравятся.

      Понятно, что в случае библиотечных (ну или просто очень больших) решений у подхода ломай -> чини -> повтори будут трудности. Придётся изворачиваться, работать через type extrensions, быть может отказаться от алгебраических типов, чтобы не сталкиваться с перспективой неработающих конструкторов и т.д. Однако, во-первых, до проекта таких размеров надо ещё дожить, во-вторых, даже на больших кодовых базах большая часть кода существует в изолированных карманах, которым “библиотечный” уровень обязательств без надобности.

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


      1. amcured
        28.04.2026 06:49

        Вы правильные вещи говорите, особенно про:

        рядовому разрабу достаточно чётко понимать, в каких условиях он находится и действовать соответствующим образом

        Проблема в том, что чувак не просто девелопер, но еще и архитектор, а вдобавок — консультант. К его словам прислушиваются (наверное). А тут такая фигня.

        Разумеется, когда речь идет про скрипт на баше, который переименует заковыристым способом файлы и помрёт уставшим, но гордым от хорошо выполненной работы — никто не заботится о расширяемости.

        Но по умолчанию я ожидаю от архитектора и консультанта услышать: «Ломать обратную совместимость не надо, пока не прижмёт, а когда вот так, так, или вот так прижмёт — делайте то, то, или вон то». А не «теперь нам надо добавить телефон, поэтому мы всё к херам разломаем и построим на обломках новый праздничный мир с блекджеком и алгебраическими типами».


        1. QweLoremIpsum
          28.04.2026 06:49

          Да это не про слом обратной совместимости, это вообще про другое это другой уровень, тут обратная совместимость никак не сломается


        1. kleidemos
          28.04.2026 06:49

          Применительно к F# ожидания надо скорректировать. Мы действительно до поры игнорируем обратную совместимость как несущественный фактор и не испытываем по этому поводу угрызений совести, так как эта схема работает на дистанциях значительно больших, чем это можно было представить по опыту C#. Лишь когда припрёт, мы начинаем танцы с бубнами. Причём этими танцами владеет очень ограниченный набор лиц/ Большинство из них явно выходят из аудитории, с которой работает Влашин как автор блога.

          // Опыта общения с ним в роли консультанта не имею, и не знаю, что он говорит при личном общении.


          1. amcured
            28.04.2026 06:49

            Ну как так-то? Я вот тут попиливаю свой язык с завтипами, компилируемый в BEAM. В данный момент воюю с hot reload’ом, из-за которого в своё время Армстронг и Вирдинг отказались от типизации в эрланге.

            Задача, в принципе, решаемая — но только до того момента, пока не меняются типы данных. Даже локальные.

            Я очень хорошо понимаю вашу аргументацию, большое спасибо, что не поленились рассказать о кухне. Причины тоже понятны: у ваших типов обычно очень узкая доменная область применения, они почти все локальные, поэтому их в подавляющем большинстве случаев можно считать «деталями реализации». Так?


            1. kleidemos
              28.04.2026 06:49

              Всё так. Чем уже сфера ответственности, тем меньше шансов что-то сломать на широком фронте.

              По хотрелоаду вполне возможно, что вы пытаетесь взять планку, которую F# не взял. У нас в REPL изменённый тип – это новый тип. С точки зрения компилятора он не эквивалентен старому, поэтому их нельзя просто так смешивать (за исключением случаев апкаста к общему предку). Часто приходится пересобирать зависимые модули и т.д. Кложуристы при столкновение с этим начинают выть, но после обретения некоторого опыта желания компилятора можно предвосхищать.

              С рекордами приблизительно тоже самое происходит. Если добавить в некий тип поле B, то все конструкции вида { record with A = 42 } (новое поле не задействовано!) в зависимых dll начнут падать в рантайме, пока их не пересоберут с обновлённой либой. Код при этом останется идентичным. То есть проблему не решили by design, но существенных потерь от этого никто не понёс.


              1. amcured
                28.04.2026 06:49

                Ага, понимаю. Иногда лучше иметь внятное, ограниченное и работающее решение, чем формально безупречное, но непонятное и кривое.

                По хотрелоаду вполне возможно, что вы пытаетесь взять планку, которую F# не взял. У нас в REPL изменённый тип – это новый тип.

                В BEAM понятие hot reload — не про REPL, а про продакшн :) Возможность загружать новую версию кода без остановки приложения.

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