Предметно-ориентированное проектирование

Определение

Проектирование на основе предметной области (Domain Driven Design, DDD) – это объектно- ориентированный подход к проектированию ПО, основанный на предметной области, ее элементах, поведении и отношениях между ними. Целью является создание программных систем, являющихся реализацией лежащей в основе предметной области, путем определения модели предметной области, выраженной на языке специалистов в этой области. Модель предметной области может рассматриваться как каркас, на основании которого будут реализовываться решения.

Цели

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

Стоимость

Предметно-ориентированное программирование вносит определенные издержки на проектирование и реализацию сущностей. Проектировать предметную область следует по принципу необходимого и достаточного усложнения, так любая избыточная сложность усложняет понимание. Хороший пример итеративного усложнения предметной области можно увидеть в главе Accounting Patterns книги Analysis Patterns Мартина Фаулера.

Принципы разработки

  1. SOLID

  2. Сущности должны отражать моделируемую предметную область

  3. Ограничивайте изменяемость (final в Java)

  4. Чистая логика. Отделяйте бизнес-логику от других слоев приложения (сущности не должны не иметь методов работы с БД, сетью). Сущности могут иметь аннотации (spring, persistance и другие. Наличие аннотаций не является препятствием для того, чтобы создать нужные объекты для теста)

  5. Создавайте небольшие сущности для конкретных действий (Single Responsibility в Solid). Даже незначительные изменения в больших классах могут быть не тривиальной задачей. Избегайте GOD-объектов с бесконечной функциональностью, они являются копилкой технического долга.

  6. Для работы с БД и сетевыми ресурсами используются репозитории, шлюзы и т. п., с возможностью создания заглушек («mocks») для тестирования.

Отношения

В методологии DDD предметная область описывается сущностями и их отношениями.

Рассмотрим 3 основных вида отношений между сущностями: является (is), содержит (has), использует (uses).

Примеры:

Автомобиль является транспортным средством.

Автомобиль содержит двигатель.

Водитель использует автомобиль (можно употреблять конкретный глагол, например "ведет")

Отношение «является» (is)

Отношение «является» показывает общность поведения сущности. Данное отношение должно соответствовать принципу подстановки Барбары Лисков (LSP)(буква L в SOLID).

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

Примеры

Автомобиль является транспортным средством.

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

Транспортное средство можно завести.

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

Отношение «содержит» (has)

Отношение «содержит» определяет состав сущностей.

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

Отношение «использует» (uses)

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

SOLID

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

Основная идея принципов SOLID - «разделение». Объекты, модули, функции в программе должны быть свободно соединены таким образом , чтобы предотвратить распространение изменения в одном месте на другие места программы. Причина, по которой это желательно, заключается в том, что слабосвязанные объекты проще в обслуживании, они более гибкие и более мобильные.

Single-responsibility principle (SRP)

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

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

Open–closed principle (OCP)

Классы и другие объекты должны быть открыты для расширения , но закрыты для модификации.

Данный принцип напрямую связан с полиморфизмом.

Ключевым способом реализации OCP является «кодирование под абстракции», а не конкретные реализации.

Роберт C. Мартин, создатель и главный проповедник SOLID, считает Бертрана Мейера создателем OCP. В своей книге 1988 года « Конструирование объектно-ориентированного программного обеспечения» Мейер описывает необходимость разработки гибких систем, которые могут адаптироваться к изменениям без сбоев. Для этого Мейер выступает за проектирование систем, в которых объекты (классы, модули, функции и т. Д.) «Открыты для расширения, но закрыты для модификации».

Liskov Substitution Principle (LSP)

Подтип объекта должен быть взаимозаменяемым с его родительским типом, если это касается любых функций, которые полагаются на родительский объект

Подтипы не должны нарушать контракты, установленные их родительскими типами.

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

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

В объектно-ориентированном дизайне распространенной техникой создания объектов с одинаковым поведением является использование супертипов и подтипов. Супертип определяется с помощью некоторого набора характеристик, которые затем наследуются всеми его подтипами. В свою очередь, подтипы могут затем переопределить реализацию некоторого поведения супертипа, что позволяет дифференцировать поведение посредством полиморфизма. Это чрезвычайно мощная техника; однако возникает вопрос, что точно делает один объект подтипом другого. Достаточно ли одного объекта унаследовать от другого? В 1987 году Барбара Лисков предложила ответ на этот вопрос, утверждая, что объект следует рассматривать как подтип другого объекта, только если он взаимозаменяем со своим родительским объектом в отношении любой взаимодействующей функции.

Interface Segregation Principle (ISP)

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

Целью ISP является защита от так называемого «загрязнения интерфейса», когда объект реализует интерфейс, который гарантирует поведение, выходящее за рамки того, что требуется конкретному объекту.

LSP и OCP призывают нас кодировать под абстракции, под интерфейсы, где интерфейс является своего рода «контрактом». Однако что происходит, когда вы вынуждены создать объект, которому на самом деле не нужны все поведения, описанные в его интерфейсе?

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

В рамках своих принципов SOLID Роберт C. Мартин предложил решение этой проблемы, которое он назвал принципом разделения интерфейсов (ISP). Мартин утверждал, что загрязнение интерфейса было в первую очередь результатом «толстых интерфейсов», то есть интерфейсов с большим количеством предписанных методов. Чтобы противостоять эффектам «жирных» интерфейсов, Мартин определил ISP следующим образом:

Клиенты не должны зависеть от интерфейсов, которые они не используют.

Мартин выступает за использование так называемых «ролевых интерфейсов», которые представляют собой небольшие интерфейсы, содержащие только методы, которые представляют интерес для объектов, которые их используют. Поэтому толстый интерфейс можно разбить на более мелкие ролевые интерфейсы, которые гарантируют определенное связанное поведение.

Dependency inversion principle (DIP)

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

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

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

Чистая логика

Чистая функция обладает следующими свойствами:

  1. является детерминированной;

  2. не обладает побочными эффектами (не работает с глобальными переменными, базами данных, файлами, сетью).

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

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

Слои приложения

Слой предметной области

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

  1. Как данные преобразовываются или вычисляются. Например, вычисление суммы комиссионных или налога.

  2. Как данные передаются людям или программным компонентам (описывают бизнес-процесс)

В данном слое располагается «структурированная» бизнес-логика, сформированная из «сущностей». В данном слое вы разбиваете комплексную логику на отдельные «классы»-«функции».

В качестве аналогии можно привести любой пример сложной системы из реальной жизни. Например, двигатель автомобиля передает вращение генератору, но они не объединяются в единый аггрегат. Они «разрабатываются» и «тестируются» независимо, после чего соединяются в единую бизнес-логику «автомобиль», взаимодействуя друг с другом через «интерфейс».

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

Другими словами, сущности — это «игроки», их взаимодействие - «правила игры». а структуры данных, которыми они обмениваются - «мячи». Описав все это, вы получите слой предметной области.

В зависимости от специфики приложения слой предметной области может быть, а может и не быть связанным со слоем хранения и слооем шлюзов.

Для тестирования слоя предметной области применяются юнит-тесты.

Слой служб

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

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

Слой служб устанавливает множество доступных действий и координирует отклик приложения на каждое действие. - “Patterns of Enterprise Application Architecture”

Из книги “Patterns of Enterprise Application Architecture” by Martin Fowler, Randy Stafford.

Двумя базовыми вариантами реализации слоя служб являются создание интерфейса доступа к домену (domain facade) и конструирование сценария операции (operation script). При использовании подхода, связанного с интерфейсом доступа к домену, слой служб реализуется как набор “тонких” интерфейсов, размещенных “поверх” модели предметной области. В классах, реализующих интерфейсы, никакая бизнес-логика отражения не находит — она сосредоточена исключительно в контексте модели предметной области. Тонкие интерфейсы устанавливают границы и определяют множество операций, посредством которых клиентские слои взаимодействуют с приложением, обнаруживая тем самым характерные свойства слоя служб.

Создавая сценарий операции, вы реализуете слой служб как множество более “толстых” классов, которые непосредственно воплощают в себе логику приложения, но за бизнес-логикой обращаются к классам домена. Операции, предоставляемые клиентам слоя служб, реализуются в виде сценариев, создаваемых группами в контексте классов, каждый из которых определяет некоторый фрагмент соответствующей логики. Подобные классы, расширяющие супертип слоя (Layer Supertype, 491) и уточняющие объявленные в нем абстрактные характеристики поведения и сферы ответственности, формируют “службы” приложения (в названиях служебных типов принято употреблять суффикс “Service”). Слой служб и заключает в себе эти прикладные классы.

Слой хранения

Репозитории, струкуры доступа к данным.

Слой шлюзов

Шлюзы, адаптеры к внешним системам.

Слой контроллеров

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

Перечень задач контроллера

  1. Принять веб-запрос

  2. Провалидировать запрос по схеме

  3. Десериализовать запрос

  4. Вызвать вариант использования, передав на вход структуру данных, получить ответную структуру данных

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

Внедрение зависимостей

Используйте инверсию управления в виде “Внедрения зависимостей” Dependency Injection (DI). Внедрение зависимостей облегчает тестирование и параллельную разработку ПО (например, в случаях, когда вызываемые компоненты еще не готовы, для них создаются заглушки)

Истинное внедрение зависимостей идет еще на один шаг вперед. Класс не предпринимает непосредственных действий по разрешению своих зависимостей; он остается абсолютно пассивным. Вместо этого он предоставляет set-методы и/или аргументы конструктора, используемые для внедрения зависимостей. В процессе конструирования контейнер DI создает экземпляры необходимых объектов (обычно по требованию) и использует аргументы конструктора или set-методы для скрепления зависимостей. Фактически используемые зависимые объекты задаются в конфигурационном файле или на программном уровне в специализированном конструирующем модуле.

Преимущества внедрения зависимостей

Поддерживаемый код

Простые, автономные классы легче исправить, чем сложные, тесно связанные классы.

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

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

Тестируемость

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

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

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

Читаемость

Код с внедрением зависимостей более читаем. Благодаря функциональной декомпозиции код не перегружен ненужными деталями. Более читаемый код более удобен в сопровождении

Гибкость

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

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

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

Расширяемость

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

Небольшие, гибкие классы можно легко расширять либо наследованием, либо композицией.

Код, использующий внедрение зависимостей, приводит к более расширяемой структуре классов. Полагаясь на абстракции вместо реализаций, приложение может легко варьировать нужную реализацию.

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

Командная разработка

Внедрение зависимостей требует от вас кодирования абстракций, а не реализаций.

Если вы работаете в команде, внедрение зависимостей облегчит развитие команды. (Даже если вы работаете в одиночку, ваша работа, вероятно, будет передана кому-то в будущем.)

Вы можете определить абстракции перед выполнением реализаций. Затем каждый разработчик может написать свой код, используя абстракции, еще до того, как будут написаны реализации.

Кроме того, поскольку код слабо связан, эти реализации не будут полагаться друг на друга, и поэтому их легко разделить между разработчиками

Преимущества кодирования с интерфейсами

Кодирование под абстракции

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

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

Подключаемые реализации

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

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

Межмодульная связь

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

Тестируемый код

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

Шаблоны

Интерфейсы облегчают реализацию шаблонов проектирования и позволяют делают такие вещи, как Dependency Injection . Большинство шаблонов и практик, включая платформы внедрения зависимостей, доступны благодаря мощным и гибким интерфейсам. Шаблоны и архитектуры разработки, такие как Model-View-Controller (MVC) и Model-View-ViewModel (MVVM), намного проще реализовать и использовать при проектировании с интерфейсами.

Инструменты проектрования

  1. PlantUML, плагин NetBeans и web-сервер для рисования диаграмм в браузере