Типизация реального мира 27.11.2024

Предисловие

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

У бизнеса есть собственный идиом, состоящий из общего языка, на котором бизнес разговаривает, а у языка есть раскиданные по отделам диалекты. Диалекты могут сильно отличаться от отдела к отделу: какой-нибудь «заказ» интернет-магазина не то же самое, что «заказ» у логистики. А какой-нибудь «покупатель товара» интернет-магазина не то же самое, что «покупатель товара» у поставщика этих товаров. Даже «товар» с точки зрения интернет-магазина и закупаемый у поставщика «товар» скорее всего является не тем же самым. Просто потому, что набор свойств и процессов вокруг них разные, и вообще логистический заказ может включать в себя несколько отгружаемых заказов интернет-магазина. Вероятно, многие уже догадались, что под диалектами я здесь подразумеваю предложенный Эриком Эвансом вездесущий/единый язык (ubiquitous language) какого-то ограниченного контекста (bounded context).

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

На кошечках и собачках

Представим модульный монолит интернет-магазина, в котором для email покупателя и email получателя заказа разработчик Игнат решил использовать тип string как наиболее подходящий. Не целочисленный же использовать, верно? По всем контекстам этого монолита для каждого email распространился этот строковый тип.

И всё работало, пока в один прекрасный момент у системы не начал отказывать контекст биллинга, потому что где-то в контексте чекаута в ходе рефакторинга снесли валидацию — она теперь пропускает пробелы в адресе email. Но в контексте биллинга ожидается email без пробелов, а не просто какая-то произвольная строка, ведь банк более строг к данным, которые приходят на вход.

После возникновения инцидента все схватились за голову, активно думали об Игнате не очень хорошо, а бизнес терял деньги, потому что оплата заказа через продовое окружение банка перестала работать. Все созванные дежурные разработчики повспоминали защитное программирование, fail fast и накидали во всех найденных местах какие-то проверки, не допускающие невалидные данные дальше по коду.

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

Но сейчас во всём коде фигурирует мало о чём говорящий тип string, и догадаться о его содержимом можно только по косвенным признакам: именам переменных, аргументам методов, сопроводительным комментариям и in-code документации. Повезёт, если имя аргумента метода не простой email.

К такому типу данных нет какого-либо доверия. А вдруг пустая строка? А вдруг там только локальная часть без доменной? А надо обрезать пробелы? Стоимость поддержки такого кода неоправданно высока, ведь приходится изучать код вокруг и вообще задумываться о предусловиях (preconditions) и возможно даже постусловиях (postconditions) во всех местах каждый раз заново.

После тушения пожара, Игнат вспомнил, что Стив Макконнелл в своей книге «Совершенный код» писал про сужение в типах данных области допустимых значений через реализацию собственного типа данных. Мартин Фаулер также писал что-то похожее в своей книге «P of EAA». Эрик Эванс назвал такие типы данных «Value Object», а после Вон Вернон более подробно раскрыл в своей красной книге. Ещё небезызвестный Владимир Хориков и на архитектурных конференциях, и в своём блоге рассказывал про доменные объекты-значения.

Вспомнив всё это, Игнат решил ввести абстракцию поверх существующего в языке программирования типа string - специальный объект Email. Все правила и ограничения внешнего мира переместились в этот единый объект, вся система стала работать с email покупателя и получателя через этот объект. Любые новые случайно вскрывшиеся требования к email теперь вносятся в это единое место в виде предусловий.

Также в коде появилась более строгая типизация, которая явным образом сообщает об ошибках, не допуская до кода дальше: просто абы какую строку в тип данных Email уже проблематично засунуть. В коде, работающем с email, теперь есть уверенность, что это именно email, так как сформировать объект-значение с невалидными данными невозможно.

А ещё статические анализаторы начали подсвечивать моменты, где по невнимательности были перепутаны строковые параметры местами. Странно, что ещё никто не заметил.

И вроде всё здорово, но теперь пришли сотрудники клиентского центра и недовольно поинтересовались, почему это у получателя заказа стоит запрет на корпоративные внутренние адреса, имеющие особенный домен первого уровня, валидный в рамках внутренней корпоративной сети? Сотрудники компании жалуются, куча обращений, что делать с этим — непонятно. Пока отвечают «используйте личный email, а несработавшую корпоративную скидку мы вам как-то компенсируем через бухгалтерию, вот форма для заполнения заявления на возврат средств».

Перед разработчиком встала дилемма: если разрешить корпоративный домен в объекте-значении Email, то также появится возможность указывать корпоративный адрес и в email покупателя. Но биллинг с таким не работает, а за невыпущенные чеки ОФД полагается штраф по закону. Но вот если бы были строки, как раньше, то такой проблемы бы не было, но были бы другие проблемы, к которым возвращаться уж очень не хочется.

И снова Игнат не учёл, что с точки зрения бизнеса email покупателя и email получателя — это два разных типа данных, каждый со своими особенностями и ограничениями. То, что они могут быть идентичны — это всего лишь совпадение для конкретного заказа, но не общее правило. То, что правила и ограничения могут быть идентичны — совершенно не значит, что они будут идентичны в будущем. Это нужно всегда учитывать.

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

Шаг в сторону выделения email в объект-значение Email был верным, но реализован не до конца. У рассматриваемого гипотетического бизнеса email покупателя и email получателя разные. Разработчик должен был следовать единому языку контекста и переносить терминологию в код по возможности в неизменном виде, а не изобретать свою поверх.

В итоге Игнат ввёл разные объекты-значения для каждого типа контекста:

  • ContragentEmail для контрагента в контексте биллинга.
  • CustomerEmail для покупателя заказа в контексте управления заказами;
  • RecipientEmail для получателя заказа также в контексте управления заказами.

Статический анализатор стал отслеживать несовпадения типов более жёстко. Даже обнаружились несколько проблемных мест, где отправлялись уведомления о движении заказа по процессам на email покупателя, а получатель заказа их никогда и не получал — странно, что ещё не жаловались, конечно.

Кроме того, Игнат решил не отказываться и от введённого ранее Email, так как получилась хорошая абстракция над строкой для реализации email по общепринятым спецификациям. Теперь в нём остались только нужные правила, обеспечивающие общий тип данных, без особенностей внешних ограничений конкретных кусков системы. Все объекты-значения конкретных адресов теперь внутри себя используют этот Email и остаётся удобная возможность сузить или расширить типизацию глобально для всей системы.

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

Надеюсь, Игнат не скатится на дно, так как он мне ещё нужен для следующих статей.

Немного про единый язык

Единый язык — очень крутая концепция борьбы со сложностью, которая позволяет стандартизировать набор используемых терминов, чтобы все участники (и от бизнеса, и от разработки) говорили на одном языке в пределах какого-то контекста. Чтобы заказ у логистов был таким же заказом у логистов в коде, а не каким-то абстрактным заказом с типом «логистический» из какого-то перечисления доступных типов. И чтобы процесс доставки заказа в пункт выдачи назывался именно так, а не обновлением данных заказа. Это не то же самое, хотя в коде формально оно может приводить к одному ожидаемому результату.

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

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

Из-за такого разработчику требуется создать в своей голове что-то вроде переводчика с одного языка, на котором говорит бизнес, на другой язык, на котором разговаривают разработчики. Также нужно дополнительно обучить тому второму языку и менеджера проекта, потому что так или иначе в общих созвонах придётся употреблять терминологию обоих языков с постоянными пояснениями, что же конкретно сейчас имеется в виду. А ещё объяснять, почему задача имеет такой большой срок и почему в ней требуется регрессионное тестирование всех типов заказов, хотя мы всего лишь вводим другой шаблон уведомления для одного конкретного типа заказа.

В проект приходит другой незнакомый с проектом разработчик и/или менеджер — и всё повторяется, да.

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

Концепция единого языка применима не только к глобальным бизнесовым сущностям моделируемой предметной области, но также и к их свойствам: разграничение и чёткое определение свойств важно для консистентности модели. Как различные атрибуты бизнес-сущностей связаны между собой и могут ли они считаться идентичными? Является ли email покупателя заказа тем же самым, что email получателя заказа? Является ли дата оформления заказа тем же самым, что и дата оплаты заказа?

Зависит от (it depends of). Фраза, которая применима чуть ли не во всех возможных ситуациях, так как нет какой-то серебряной пули, то есть универсального ответа.

Если у заказа покупатель всегда является одновременно и получателем этого заказа, то email покупателя и email получателя являются своего рода синонимами. Их можно стандартизировать до «email покупателя» без выделения получателя в отдельный термин-синоним, чтобы не вносить путаницу.

Если у заказа покупатель и получатель могут отличаться или всегда отличаются, то email первого и второго — это два разных email. Даже несмотря на то, что email покупателя может совпадать с email получателя. Скорее всего бизнес будет так и говорить: чек полного расчёта мы отправляем на email покупателя, так как именно он оплачивает заказ, а движение заказа в определённые состояния мы отправляем на email получателя, так как именно получатель заинтересован в том, что происходит с заказом.

Аналогично и во второй ситуации: если заказ создаётся всегда в оплаченном состоянии, то дата оплаты заказа и дата оформления заказа также синонимичны. Скорее всего, бизнес даже не будет выделять в отдельный термин дату оплаты заказа в контексте чекаута или управления заказами.

Что же лучше?

Ответ «зависит от (it depends of)» отличный, но мало кого устраивающий. К сожалению, иначе и не ответить.

Нужно всегда держать в голове, что вход в проект, чтобы добавить новую функциональность, не бесплатный. Вход в проект, чтобы исправить некорректно работающую функциональность, также небесплатный. Вход в проект в принципе небесплатный. Разные уровни компетенций разработчиков могут превратить проект в хаос, и энтропия, которую нужно стремиться уменьшать, может увеличиваться по экспоненте, таща за собой также стоимость рефакторинга, стоимость тестируемости (testability), стоимость модифицируемости (modifiability) и стоимость ремонтопригодности (maintability).

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

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

Конечно, у этого подхода также есть и своя цена с точки зрения ресурсов. Упаковывать в особенную структуру или объект, выделять под неё память и прочее может быть накладно в высоконагруженных системах, где ops считаются десятками, а то и сотнями тысяч. И то, в таких системах пишут или вообще небизнесовый код на низкоуровневых конструкциях, или через препроцессоры компилятора инлайнят все структуры, заменяя их на примитивы, где-то даже жертвуя проверками в рантайме. Но для этого нужно быть каким-нибудь Амазоном, чтобы об экономии на спичках вообще стоило бы задумываться, чтобы сэкономить 10 нанотиков.

Если у нас простой проект с небольшим сроком жизни, то перенос концепции единого языка с применением всяких тактических паттернов DDD в код, возможно, будет не лучшей идеей. Нужно понимать, что для быстрых недолгих проектов типа стартапов или проверок гипотез используются другие подходы, например RAD. Но для проектов с долгим циклом жизни и постоянной итеративной эволюцией выгоднее на длинной дистанции вкладываться в упрощение понимаемости и поддержки кода ценой усложнения (увеличения количества) этого кода. Упрощаем что-то одно, усложняем что-то другое, не забываем про поддержку — это что-то про треугольник тройственности, где нужно найти верный/подходящий баланс и не скатиться в неудобную, точнее сказать, неоправданную, бюрократию. Но результат стоит того.