Модульность — это архитектурный подход, при котором кодовая база разделяется на четко определенные, независимые блоки с явно выраженными обязанностями и границами. Каждый модуль предоставляет понятный публичный интерфейс и скрывает свои внутренние детали, позволяя частям системы развиваться без жесткой взаимосвязи.
Но модульность не является универсальным требованием. Для небольших проектов или прототипов на ранних стадиях внедрения множества модулей может добавить ненужную нагрузку и замедлить разработку, не принося при этом реальных преимуществ. В таких случаях простота часто побеждает.
Однако, как только становится ясно бизнес-направление приложения и очевидно, что кодовая база будет значительно расти, начинать с модульной структуры становится прагматичным решением. На этом этапе модульность помогает управлять сложностью на ранних этапах, устанавливает границы ответственности и предотвращает превращение системы в жестко связанный монолит по мере ускорения разработки.
Проектирование структуры до написания кода
Прежде чем писать какой-либо производственный код, стоит потратить время на обдумывание структуры приложения и, во многих случаях, на ее набросок в виде простой диаграммы. Этот начальный шаг помогает прояснить границы, обязанности и взаимосвязи между частями системы задолго до того, как они будут закодированы в цели сборки или пакеты.
Как только эта скелетная структура станет ясной, реализация станет гораздо более продуманной и менее реактивной. Практической отправной точкой является базовый слой приложения. Этот слой обычно включает в себя общие модели данных, сетевой стек и основные сущности, которые будут использоваться в большинстве функций и сервисов. Создание этого фундамента на раннем этапе формирует стабильную основу для будущих модулей и снижает вероятность переработки основных концепций, когда разработка функций уже идет полным ходом.
Три основные категории модулей
На практике модульная кодовая база выигрывает от небольшого набора четко определенных категорий модулей. Первая — это базовый уровень. Эти модули содержат основные строительные блоки приложения (повсеместно используемые модели данных, общие примитивы и техническую инфраструктуру), которые должны оставаться широко доступными. Базовый модуль не должен зависеть от каких-либо модулей более высокого уровня, но он будет импортироваться почти везде.
Вторая категория — это сервисные модули. Здесь находятся общие вспомогательные функции и возможности: компоненты, которые функции могут повторно использовать без дублирования логики. Сервисные модули зависят от базового уровня, и их могут импортировать функциональные модули. Направление зависимости имеет значение: базовый уровень никогда не должен импортировать сервисы, поскольку это перевернуло бы иерархию и перенесло бы специфические для приложения задачи на базовый уровень.
Третья категория — это функциональные модули. Функциональный модуль представляет собой сценарий взаимодействия с пользователем с четким началом и результатом: завершение платежа, изменение настроек, просмотр ленты, открытие сведений о товаре или завершение процесса регистрации. Функциональные модули могут зависеть от базового уровня и сервисов, но они не должны зависеть от других функциональных модулей. Когда функции импортируют друг друга, граф зависимостей запутывается, границы теряют смысл, и проект возвращается к монолиту — простому разделению на целевые объекты.
Когда модульность становится трендом
Многие команды внедряют модульность, потому что это кажется современным подходом. Целью становится «заставить это скомпилироваться», и работа над проектированием на этом заканчивается. В результате получается плотная сеть взаимосвязанных модулей, раздутая публичная поверхность, повторяющиеся циклические проблемы зависимостей и растущая коллекция «божественных» модулей и разнородных сервисов, которые пытаются справиться со всем. Команды по-прежнему получают некоторые непосредственные преимущества, такие как меньшее количество конфликтов при изменениях и лучшая параллельная разработка, но большая часть архитектурных преимуществ исчезает.
Со временем такая модульность создает трение, а не устраняет его. Разработчики избегают касаться модулей, принадлежащих другим командам, потому что обязанности неясны, и система накапливает синглтоны и «божественные» объекты, из-за чего кодовая база кажется переполненным ящиком для хлама. Технический долг продолжает расти, и его редко удается погасить в условиях жестких сроков и смещения внимания на инструменты и скорость разработки.
В этом контексте использование упрощенных решений становится нормой: одна функция импортирует другую функцию только для доступа к одному сервису или даже одному представлению, и кодовая база незаметно возвращается к монолитной зависимости — только теперь это сложнее заметить. Кстати, предварительный просмотр в Xcode также может перестать работать в такой среде, так что это действительно можно считать выстрелом себе в ногу.
Определение контрактов модулей
Чтобы предотвратить постепенное расширение обязанностей модуля, его контракт необходимо определить заранее. Полезным мысленным упражнением является представление того, что модуль опубликован на GitHub, и вам нужно объяснить, в отрыве от контекста, как его следует использовать. В этом сценарии каждый публичный тип и функция становятся частью обещания внешним пользователям. Сохранение этой области видимости небольшой окупается быстро, особенно когда роль модуля ясна, а его внешние точки взаимодействия можно свести к самым необходимым.
Фундаментальные модули обычно содержат мало или совсем не содержат бизнес-логики и не представляют пользовательские сценарии, что объясняет, почему они часто предоставляют более широкий набор публичных API. Любое изменение на этом уровне обходится дорого и может повлиять на значительную часть кодовой базы.
Сервисные модули могут содержать общую бизнес-логику, используемую в нескольких функциях, но они не должны моделировать пользовательские потоки. Их публичные API должны оставаться сфокусированными. Как только они начинают бесконтрольно разрастаться, это серьезный сигнал к проверке модуля на предмет превращения в «модуль-бог». Разработка сервисов с учетом принципов открытого исходного кода здесь полезна, поскольку они будут использоваться во многих контекстах и должны оставаться предсказуемыми и удобными.
Модули функций, напротив, обычно требуют лишь небольшого публичного интерфейса: точки входа в функцию и способа наблюдения или получения ее результатов, часто выраженных через модели данных, специфичные для функции. Следует избегать прямого импорта функций друг в друга, будь то с помощью таких инструментов, как Tuist, или благодаря последовательной дисциплине команды.
Навигация и поток данных между модулями
Навигация внутри модуля функций обычно проста. Функция владеет своим внутренним потоком и управляет переходами между своими экранами, не раскрывая эти детали остальной части приложения. Сложность возникает, когда навигация должна пересекать границы модулей.
Переход от функции A к функции B лучше всего осуществлять вне обоих модулей, с помощью внешнего координатора. Этот координатор может быть координатором уровня приложения или более специализированной конструкцией, такой как координатор панели вкладок. Ключевое свойство заключается в том, что он имеет доступ ко всем функциональным модулям и отвечает за координацию переходов между ними. Когда функция A завершает определенный сценарий и требует передачи функции B, она сигнализирует об этом намерении общему координатору. Затем координатор выполняет навигацию и передает все необходимые данные. Такой подход обеспечивает изоляцию функциональных модулей, избегает прямых зависимостей между ними и централизует навигацию между функциями в одном явном месте.
Сборка модулей и управление взаимодействием между функциями
На данном этапе может показаться, что координатор также отвечает за сборку модулей функций, однако эту ответственность лучше оставить внутри самой функции. Функция должна иметь возможность создавать свой собственный внутренний граф, предоставляя выделенный API для сборки, будь то через статические фабричные методы, билдеры или явный тип сборки. Важно, чтобы функция объявляла, какие данные и сервисы ей необходимы, и выполняла собственную композицию, вместо того чтобы полагаться на внешний координатор для подключения своих внутренних компонентов.
Это, естественно, поднимает вопрос об обмене данными между функциями. Один из распространенных подходов заключается в том, чтобы рассматривать выходные данные функции как результирующую модель, которая передается компоненту, осведомленному об обеих функциях, например, координатору приложения, который затем может преобразовать этот результат в форму, подходящую для следующей функции.
Когда несколько функций используют одни и те же модели данных, это часто указывает на то, что эти модели должны находиться в отдельном модуле, который не содержит пользовательских сценариев и действует как облегченный набор функций.
Если взаимодействие выходит за рамки простой передачи данных и включает в себя более сложную координацию, можно ввести выделенный модуль-посредник. Этот модуль находится выше в иерархии, знает об обеих функциях и управляет их обменом данными, позволяя приложению зависеть от посредника, а не от самих функций. Такой подход увеличивает архитектурный вес, поэтому его следует использовать только в случаях, когда взаимодействие является состоянием, частым или достаточно сложным, чтобы оправдать наличие явного ответственного модуля.
Другое часто предлагаемое решение — использование модулей-бриджей (мостов), построенных на основе протоколов, чтобы избежать импорта функций друг в друга. Хотя на бумаге это хорошо согласуется с идеалами чистой архитектуры, компромиссы реальны: больше модулей, сложнее отладка, дольше время сборки и повышенная когнитивная нагрузка. Во многих случаях эти мосты создают иллюзию развязки, маскируя при этом еще более тесную связь. По этой причине мосты на основе протоколов следует вводить выборочно и с четкой целью, например, для изоляции очень большой зависимости, поддержки тестирования с помощью альтернативных реализаций или облегчения постепенной миграции от монолитной к модульной структуре.
Внутри функционального модуля протоколы могут быть вполне уместны, если они служат конкретной цели. Стремление к максимальной гибкости и полной взаимозаменяемости редко окупается. Ситуация, когда что-то действительно нужно заменить, возникает нечасто, в то время как ситуация, когда разработчик пытается изучить поведение функции и в итоге оказывается в протоколе, происходит регулярно.
Заключение: модульность как целенаправленная практика
Модульность — мощный инструмент для распутывания сложных систем и создания условий для устойчивой разработки приложений. При правильном проектировании она снижает трение при изменениях, уменьшает риск непредвиденных побочных эффектов, сокращает циклы обратной связи и делает повседневную разработку более предсказуемой. Она также может открыть двери для совместного использования хорошо изолированных модулей в нескольких приложениях, превращая внутренний код в долгосрочные активы, а не в специфичный для проекта багаж.
В то же время, модульность — это не механический рефакторинг, который можно применять вслепую. Это сложный инструмент, требующий тщательного обдумывания и сдержанности. Такие инструменты, как Tuist и растущий набор инструментов разработки с использованием ИИ, могут поддержать эти усилия, устранив механические издержки, но они не заменяют архитектурного суждения. Четкое распределение ответственности, командная дисциплина, целостное архитектурное видение и твердое понимание чистоты кода остаются основой, которая предотвращает хаос в модульной системе и позволяет проекту уверенно развиваться.

