В предыдущей части мы рассмотрели, что такое домен и какими принципами можно руководствоваться при его модуляризации. В этой части сконцентрируемся на типах связей между модулями и различиях в проектировании ООП и UDF-кода. Приятного чтения! Типы взаимодействия  Взаимодействие «родитель-ребенок» Заключение  Большинство разработчиков, которые изучают UDF, уже имеют опыт использования ООП. Однако многие подходы в UDF могут сильно отличаться от принятых в ООП. Это может усложнить изучение новой архитектуры. В этой статье я попытался систематизировать способы взаимодействия модулей между собой и показать, как они могут быть реализованы в ООП и UDF. Для начала определимся с терминами. В рамках статьи буду оперировать понятием «модуль». Важно понимать, что термин не привязан к конкретному языку, архитектуре или парадигме. Модуль — элемент домена, который хорошо сформирован вокруг конкретной задачи (подробнее в разделе High Cohesion из предыдущей статьи). В ООП модуль реализуется с помощью объектов классов, в UDF — тройкой State, Reducer, Actions. Перейду к рассмотрению связей между модулями. По типу взаимодействия между модулями можем разделить их на 2 группы:  Они никак не взаимодействуют друг с другом. Они каким-то образом взаимодействуют. Например, один модуль что-то сообщает или запрашивает у другого. Рассмотрим эти группы детальнее: Это самый простой случай. У нас есть 2 модуля и они ничего не знают друг о друге.  Посмотрим, как 2 таких модуля можно было бы реализовать в ООП и в UDF: ООП  Создадим 2 экземпляра двух различных классов и будем оперировать ими независимо друг от друга. UDF В рамках AppState живут 2 отдельных стейта, а их редюсеры один за другим вызываются в главном редюсере. 2 модуля каким-либо образом взаимодействуют друг с другом. Можно выделить такие виды взаимодействия: Domain1 нужно что-то сообщить в Domain2. Domain1 нужно что-то синхронно получить из Domain2. Domain1 нужно что-то асинхронно получить из Domain2. ООП Для взаимодействия между объектами одному объекту обычно предоставляется ссылка на другой объект: Если нужно что-то сообщить в объект, мы вызываем метод этого класса: Если нужно что-то синхронно получить из класса, мы вызываем метод, который возвращает искомое значение: Если нужно что-то асинхронно получить, то в зависимости от языка и фреймворка могут использоваться коллбеки, делегаты, промисы и так далее: В ООП также существуют способ организовать взаимодействие между объектами без явных ссылок друг на друга. Например, этого можно добиться с помощью шаблона «Посредник». UDF В случае UDF модули чаще всего ничего не знают друг о друге, а их взаимодействие реализуется с помощью посредника. В качестве посредника между двумя модулями выступает их общий родительский модуль: Вот что здесь происходит: Редюсер всего приложения получает Action из модуля Driver. Модуль приложения знает о модуле Car, поэтому в рамках своего редюсера он может обновить данные в стейте модуля Car. Тоже самое в коде: Так как соседние модули взаимодействуют через общего родителя, нет смысла разбирать типы взаимодействия между ними. Лучше сосредоточиться на взаимодействия между модулями «родитель-ребенок». По взаимодействию «родитель-ребенок» выделю 2 группы: У модуля один дочерний модуль и только он им владеет. Несколько модулей используют один и тот же дочерний модуль. Такую ситуацию можно представить как один модуль, вложенный в другой. Разберем основные типы взаимодействия «родитель-ребенок»: a. Родителю нужно что-то изменить в ребенке. b. Ребенку что-то нужно изменить в родителе. c. Родителю нужно что-то получить от ребенка. d. Ребенку что-то нужно получить от родителя. ООП Тут мы можем использовать композицию: Таким образом, экземпляр Car единолично владеет экземпляром Engine.  а. Родителю нужно что-то изменить в ребенке.  b. Ребенку что-то нужно изменить в родителе. c. Родителю нужно что-то получить от ребенка. d. Ребенку что-то нужно получить от родителя. UDF Реализуем Engine как дочерний модуль по отношению к Car: a. Родителю нужно что-то изменить в ребенке. b. Ребенку что-то нужно изменить в родителе. c. Родителю нужно что-то получить от ребенка. d. Ребенку что-то нужно получить от родителя. В такой ситуации данные, нужные ребенку, выносятся в отдельный дочерний стейт. Однако проблема может возникнуть, когда мы попытаемся использовать 2 отдельных дочерних стейта в двух разных местах приложения.  Предположим, у нас есть 2 машины: Когда AppReducer получает Action для Car, неизвестно, какому из двух модулей он предназначается. В результате сработают редюсеры обоих модулей, и мы обновим State в обоих модулях. Экшену нужно добавить контекст, к какому конкретно модулю он имеет отношение. Рассмотрим 2 решения: Namespace и Иерархия экшенов. Введем протокол Namespacable, который будет требовать от Action наличие неймспейса: Чтобы у нас была возможность указать редюсеру, в рамках какого неймспейса он должен работать и не прокидывать редюсеру еще один параметр, реализуем такую функцию высшего порядка: Теперь мы можем создать Action для нашего модуля и реализовать протокол Namespaceable: А затем отправить их, используя соответствующий неймспейс: Теперь остается только создать соответствующее редюсеры и вызвать в appReducer:  В результате получим такую картину: Рассмотрим иерархическую композицию экшенов, аналогичную композиции стейтов: Тогда мы можем отправить их вот так: Внутри appReducer, в зависимости от ветки, вызываем редюсер на соответствующем стейте: Для удобства реализации appReduce хотелось бы иметь аналог namespacableReducer, чтобы мы могли просто указать, в какой из веток экшенов мы заинтересованы в данном редюсере. Для этого нам нужно типизировать редюсеры по экшену, а затем добавить функцию contraReducer: Теперь мы можем в виде замыкания указать, какой из экшенов нужно достать. Так как замыкания получаются достаточно массивными, зафиксируем их в расширении для AppActions: Теперь мы можем сделать тоже самое, что и для Namespacable: По extension кажется, что мы не избавились от логики раскрытия энама экшенов, а просто перенесли его в extension. Мы бы полностью избавились от этого кода, если бы в свифте были KeyPath для энамов. Тогда создание редюсеров выглядело бы как-то так: Разработчики The Composable Architecture (TCA) озаботились этой проблемой и сделали фреймворк CasePaths. С его помощью наши 2 редюсера в TCA выглядели бы примерно так: Это ситуация, когда один и тот же экземпляр модуля используют 2 родителя: ООП Используем агрегацию: Таким образом, каждый из родителей получает ссылку на один и тот же экземпляр дочернего класса. UDF Данный случай подробно разобран в статье «UDF в супераппе». Такой случай тоже имеет 2 решения: Computed Module State и State Protocol. Computed Module State Стейт каждого модуля, который использует дочерний, сделаем вычисляемым. Физически в стейте будем хранить только дочерний стейт. Это позволит нам гарантировать, что дочерний модуль всегда будет только один: State Protocol Вместо вычислимых свойств для описания стейтов будем использовать протоколы. Физически в стейте также хранится только один дочерний стейт, а AppState просто реализует данные протоколы: В качестве заключения я собрал все вышеизложенные подходы в одну таблицу:  ООП UDF Модули не взаимодействуют Два отдельных класса Два отдельных набора стейтов, редюсеров и экшенов Модули взаимодействуют Вызов метода, Callback, Promise и так далее Модули используют общий родительский модуль как посредника Родитель-ребенок. Ребенок только одного родителя Композиция Namespace или Иерархия экшенов Родитель-ребенок. Ребенок нескольких родителей Агрегация Computed Module State или State Protocol