Управление Swift-пакетами в приложениях с внутренними модулями
Управлять Swift-пакетами проще, когда вы разделяете networking, доменные правила и UI-хелперы по чётким границам модулей и избегаете сюрпризов с зависимостями.

Почему это быстро превращается в хаос
Большинство приложений не начинают с проблемы модулей. Сначала есть один экран, один API-запрос и одна общая папка, которая кажется безобидной. Управление Swift-пакетами становится болезненным позже, когда приложение растёт, а код всё ещё ведёт себя так, будто живёт в одной большой комнате.
Первый тревожный сигнал прост: одно небольшое изменение начинает затрагивать файлы по всему приложению. Экран списка товаров нуждается в новом поле, и кто-то обновляет ответ API, затем код маппинга, потом общий форматтер, потом два view model, а затем переиспользуемую ячейку. Это не просто обычный рост приложения. Обычно это значит, что границы так и не были чётко определены.
Общие хелперы быстро усугубляют ситуацию. Команды часто создают пакет или папку под названием Common, Shared или Utils, а потом складывают туда всё подряд, потому что сегодня это экономит время. Через месяц там уже лежат форматирование дат, маппинг ошибок, экраны загрузки, feature flags и случайные расширения. Никто не понимает, что туда действительно относится, поэтому импортируют это все.
Код сетевого слоя тоже любит просачиваться выше. Экран должен запрашивать данные, которые он может показать, а не сырые типы запросов и ответов. Но когда view model импортирует модели сети напрямую, UI начинает зависеть от названий полей API, токенов пагинации и ошибок транспорта. Тогда изменение на бэкенде превращается в изменение экрана, даже если сам экран на самом деле не менялся.
Импорты часто говорят правду раньше, чем это сделает команда. Если UI-код импортирует networking, а доменная логика импортирует SwiftUI, границы модулей уже сломаны. Импорты — не корень проблемы. Они лишь дым.
Обычно вместе появляются несколько симптомов:
- одно изменение функции приводит к правкам в нескольких не связанных друг с другом пакетах
Сначала задайте границы, а потом переносите код
Большинство команд усложняют управление Swift-пакетами сильнее, чем нужно. Сначала они переносят файлы, а потом неделю чинят импорты. Начните наоборот: решите, за что отвечает каждый модуль, до того как трогать проект.
Простая разбивка хорошо работает для большинства приложений. Поместите API-клиенты, сборку запросов, декодирование ответов и логику повторных попыток в Networking. Поместите бизнес-правила, модели, которые передают смысл приложения, и сценарии использования в Domain. Поместите форматтеры, стили, небольшие переиспользуемые view и вспомогательные функции для экранов в модуль UI support.
Звучит очевидно, но граница быстро размывается. Форматтер цен принадлежит UI support. Правило скидки — нет. JSON-декодер для заказов относится к Networking. Правило, которое решает, можно ли отменить заказ, относится к Domain.
Запишите для каждого модуля одну простую фразу. Если не получается, граница всё ещё размыта.
"Networking работает с внешними сервисами и превращает ответы в данные приложения."
"Domain решает, как должно вести себя приложение."
"UI support даёт экранам общие инструменты отображения, а не бизнес-правила."
Эти короткие фразы экономят время позже. Когда коллега спрашивает, где должен жить новый код, у вас уже есть ответ.
Сначала не выносите экраны функций в общие пакеты. Это ошибка, которую я вижу чаще всего. Команды переносят целые экраны в пакеты, потому что папка выглядит большой, а потом каждое небольшое изменение UI тянет за собой общий код, состояние приложения, превью, ресурсы и логику конкретной функции. Пакет перестаёт ощущаться общим и начинает напоминать второе приложение.
Подождите, пока два или три сценария не начнут использовать одни и те же части экрана, прежде чем выносить их отдельно. Стиль кнопки, экран пустого состояния или форматтер даты обычно можно делить рано. Полный экран оформления заказа или сценарий аккаунта — обычно нет.
Хорошая граница выдерживает мелкие изменения. Если меняется API, вы должны затронуть Networking и, возможно, один маппер. Если меняется бизнес-правило, вы должны затронуть Domain. Если меняется стиль текста, вы должны затронуть UI support. Когда одно редактирование влечёт за собой изменения во всех модулях, разбиение выбрано неверно, и дальше будет только хуже.
Структура пакетов, которую легко читать
Большинство команд делят слишком рано. Они превращают каждую папку в пакет, и простое переименование внезапно затрагивает пять targets, три импорта и два набора тестов.
Управление Swift-пакетами становится проще, когда вы начинаете с небольшого набора пакетов, который соответствует реальной ответственности в приложении. В большинстве случаев это означает сначала несколько широких пакетов, а не по одному пакету на каждый хелпер, экран или модель.
Хорошая стартовая структура часто выглядит так:
- Networking для API-клиентов, сборки запросов, авторизации и транспортного кода
- Domain для бизнес-правил, сценариев использования и моделей, которыми владеют эти правила
- UI helpers для design tokens, переиспользуемых view, форматирования и небольших утилит представления
Этого достаточно для многих приложений. Если один пакет быстро растёт, разделите его позже. Идти в обратном порядке — вот где команды застревают.
У каждого пакета должен быть один понятный публичный интерфейс. Если другим модулям нужно знать десять внутренних типов, чтобы им пользоваться, граница пакета выбрана неправильно. Хороший пакет обычно экспортирует небольшой набор точек входа, а все грязные детали остаются внутри.
Держите модели рядом с кодом, который ими действительно владеет. Команды часто создают огромный общий пакет Models, потому что сначала он кажется аккуратным. Но он почти никогда не остаётся аккуратным. Модель оформления заказа, которую использует только сценарий заказа, должна жить вместе с ним, а не в общем хранилище, которое начинает импортировать каждый модуль.
Тесты должны жить рядом с каждым пакетом, а не в одном огромном тестовом target на уровне приложения. Когда пакет владеет своими тестами, рефакторинг кажется безопаснее и быстрее. Можно менять пакет Networking, не переживая, что какой-то не связанный UI-тест сломается без причины.
С отложите feature packages, пока общие слои не перестанут меняться каждую неделю. Если Networking, доменные правила и UI helpers всё ещё часто меняют форму, feature packages унаследуют эту суету. Подождите, пока базовые пакеты не станут скучными. Здесь скучность — это хорошо.
Читаемая структура — не самая модульная на бумаге. Это та, где разработчик может открыть проект, понять, где должен жить код, и внести изменение, не решая сначала головоломку с зависимостями.
Делайте разбиение приложения небольшими шагами
Большие переписывания пакетов обычно проваливаются по простой причине: команды перемещают слишком много кода до того, как увидят реальные зависимости. Маленькие шаги работают лучше, потому что каждый шаг показывает, какие импорты ещё имеют смысл, а какие прячут цикл.
Перед тем как что-то выносить, просмотрите текущие импорты файл за файлом. Запишите, какой модуль что импортирует, и отметьте любые циклические ссылки. Если UI-хелпер импортирует networking, а networking импортирует форматтер из UI-слоя, у вас уже есть узел. Развяжите его сначала на бумаге, а потом переносите код.
Сначала переносите самый спокойный код. Простые модели, enum и маленькие утилиты обычно переживают рефакторинг почти без изменений. Они дают общий фундамент, не таща за собой UIKit, SwiftUI или API-код.
- Общие типы данных, которые используют несколько функций
- Небольшие хелперы для разбора или форматирования без побочных эффектов
- Утилитарный код, который не трогает хранилище, сетевые вызовы или view
- Константы и простые value objects
Затем вынесите networking за небольшой интерфейс клиента. В управлении Swift-пакетами этот шаг важен, потому что конкретный API-код легко расползается по всему приложению, если его не удерживать. Держите пакет тонким: сборка запросов, декодирование ответов, обработка авторизации и один protocol, который может вызывать остальная часть приложения.
Когда типы данных перестанут прыгать туда-сюда, выносите доменные правила. Расчёты цен, правила валидации, feature flags или лимиты корзины хорошо подходят сюда. Сначала дождитесь, пока модели успокоятся. Если вынести доменную логику слишком рано, каждое изменение модели превратится в лишнюю возню с пакетами.
UI-хелперы выносите последними. Экраны часто тянут за собой лишний код: загрузку изображений, темы оформления, превью, локализацию и одноразовые расширения view. Выносите только те хелперы, которые нужны многим экранам и не зависят от бизнес-правил. Общий стиль бейджа — нормально. View карточки товара, который импортирует checkout-логику, — нет.
После каждого переноса сразу собирайте проект и исправляйте импорты. Не накапливайте пять переносов модулей и не надейтесь, что компилятор разберётся потом. Один чистый перенос, одна сборка, один маленький тест, один коммит. Десять скучных коммитов лучше одного гигантского рефакторинга, после которого все гадают, что сломалось.
Пример: магазинное приложение с тремя общими модулями
Простое магазинное приложение хорошо показывает, почему управление Swift-пакетами лучше всего работает, когда каждый пакет владеет только одним типом задач. Экран списка товаров не должен знать, как работает API, как работают правила остатков и как цены рисуются на экране. Именно так маленькие приложения превращаются в головоломку с зависимостями.
Хорошее разбиение выглядит так:
- Networking получает сырые данные о товарах с сервера.
- Domain превращает эти данные в правила приложения, например в обработку цен, статус наличия и сортировку.
- UIHelpers форматирует валюту, собирает цвета бейджей и хранит небольшие вспомогательные функции для view в одном месте.
Представьте, что экран списка товаров загружает десять позиций. Экран запрашивает данные у Networking, но не получает ничего готового для показа. Networking возвращает только сырые значения, такие как price_cents, inventory_count и sale_price.
Затем вступает Domain. Он превращает эти сырые значения в простые модели приложения и применяет правила, которые важны для бизнеса. Если у товара мало остатков, это решает Domain. Если товары со скидкой должны идти выше обычных, это тоже решает Domain. Экран просто отображает результат.
UIHelpers остаётся маленьким намеренно. Он не решает, товар ли участвует в скидке. Он только знает, как показать это решение. Если Domain говорит, что у товара есть предупреждающий бейдж, UIHelpers может выбрать его цвет и отформатировать итоговую цену как "$19.99".
Оформление заказа получает от этого приятный бонус. Оно может переиспользовать те же правила Domain для итогов, проверки наличия и логики скидок, вообще не импортируя Networking. Это важно, потому что оформление заказа обычно работает с товарами, которые уже загружены в приложение. Ему нужны правила, а не слой API.
Теперь представьте, что бизнес меняет одно правило: цены со скидкой для одного региона должны округляться по-другому. Если это правило живёт в Domain, вы меняете его один раз. Список товаров, корзина и оформление заказа подхватывают изменение. Никто не роется в коде view, и никто не трогает сетевой слой ради изменения цены.
Это хороший тест для модульного кода. Когда меняется одно бизнес-правило, в первую очередь должен меняться один пакет.
Правила, которые упрощают зависимости
Большая часть проблем с модулями начинается, когда один пакет тянется в сторону, а не вниз. Если UI-хелпер импортирует правила оформления заказа, а networking знает об экранах, мелкие правки быстро расходятся по всему приложению. В управлении Swift-пакетами это раздражает очень быстро.
Пусть UI зависит от доменного кода, а не наоборот. Доменный код должен описывать товары, корзины, цены и действия пользователя простыми Swift-типами. Он не должен знать, использует ли приложение SwiftUI, UIKit или оба сразу. Когда домен не видит интерфейс, можно переделать экран, не переписывая правила под ним.
Networking тоже должен оставаться узким. Он может отправлять запросы, декодировать ответы и сообщать об ошибках. Он не должен импортировать UIKit или SwiftUI и не должен возвращать view model, собранные под один экран. Если сервер прислал товарный payload, сначала преобразуйте его в простую модель. Потом пусть Domain решает, что эти данные означают.
Передавайте между модулями простые модели. Обычные struct, enum и небольшие protocol хорошо переходят между внутренними Swift-модулями. View, controllers и хелперы, привязанные к пакету, — нет. Если тип имеет смысл только внутри одного пакета, оставьте его там.
Держите публичные API маленькими. Пакету не нужно раскрывать каждый хелпер, маппер и extension. Обычно хватает одной-двух публичных точек входа. Остальное прячьте за доступом internal, чтобы другие модули не выстраивали случайные зависимости от деталей реализации.
Перед тем как добавить ещё одну зависимость, задайте себе короткий чек:
- Может ли этот модуль собраться без UI-фреймворков?
- Передаёт ли он простые модели, а не типы, завязанные на экран?
- Останется ли одно небольшое изменение чаще всего внутри этого пакета?
Добавляйте зависимость только тогда, когда копирование кода обходится дороже, чем дополнительная связность. Дублировать маленький форматтер в двух местах часто дешевле, чем создавать общий пакет, который потом всем нужно понимать. Общий код должен убирать трение. Если он добавляет головоломку с зависимостями, пусть код ещё немного побудет локальным.
Ошибки, которые создают клубки зависимостей
Большая часть проблем с зависимостями начинается с хороших намерений. Команда хочет более чистый код, поэтому создаёт пакет под названием "Shared" и начинает складывать туда все странные файлы подряд. Через месяц код networking уже знает о view models, UI-хелперы импортируют бизнес-правила, и никто не может сказать, что где должно жить.
Общий пакет для всего лишнего — обычно первая ловушка. Если у типа нет понятного дома, остановитесь и назовите его работу. "Networking", "Domain" и "UIHelpers" что-то значат. "Common", "Core" и "Utils" часто превращаются в ящики с хламом.
Следующая ошибка — делить слишком рано. Десять крошечных пакетов могут выглядеть аккуратно на схеме, но в реальной работе это только замедляет. Каждое небольшое изменение затрагивает манифесты, импорты и настройку тестов. Держите хелперы рядом с функцией, которая их использует, пока они действительно не понадобятся нескольким модулям. Один форматтер даты или одно расширение цвета почти никогда не заслуживают отдельного пакета.
Слишком широкие public API добавляют ещё один слой боли. Public API быстро расползается, потому что его легко импортировать и потом тяжело убрать. Начинайте с малого. Открывайте только те типы, к которым другие модули действительно должны обращаться, а остальное оставляйте internal. Так у вас будет пространство для рефакторинга без поломки половины приложения.
Тесты тоже создают скрытые узлы. Если тестам пакета нужен весь app target просто для сборки, границы модулей уже протекают. Тесты должны зависеть от того пакета, который они проверяют, плюс от небольших mock или fixtures, если нужно. Когда networking-пакету нужны экраны приложения, чтобы запустить тесты, значит пакет владеет слишком многим или зависит не от того.
Где команды случайно смешивают ответственности
Смешивание типов API-ответов со state экрана — одна из самых частых ошибок в управлении Swift-пакетами. Response-модели следуют за сервером. State экрана следует за view. Они меняются по разным причинам, поэтому им нужно жить в разных местах.
У магазинного приложения это видно особенно хорошо. ProductResponse может содержать сырые поля цен, флаги наличия и backend-названия. Экрану нужен только заголовок, цена для показа и понимание, показывать ли бейдж "Sale". Если использовать одну структуру для обеих задач, детали networking начнут просачиваться в UI-код, и небольшое изменение на бэкенде превратится в переписывание экрана.
Исправление скучное, и именно поэтому оно работает. Держите транспортные модели в пакете networking. Держите бизнес-правила и модели приложения в пакете Domain. Держите display-хелперы и состояние view рядом с UI-слоем. Такая структура может казаться менее изобретательной, но она экономит время, когда приложение растёт.
Короткая проверка перед тем, как добавлять пакет
Добавить пакет в первый день кажется аккуратным решением. Через месяц вы меняете один небольшой тип, и половина приложения пересобирается. В управлении Swift-пакетами новый пакет должен убирать трение, а не добавлять ещё одну границу, которую придётся защищать.
Начните с причины разбиения. Повторное использование — хорошая причина. "Может быть, мы когда-нибудь переиспользуем это" — обычно нет. Если сейчас код использует только одна функция и, скорее всего, так и останется ещё какое-то время, оставьте его внутри этой функции.
Пакет обычно нужен тогда, когда его можно описать одной короткой фразой. "Этот модуль обращается к API." "Этот модуль хранит правила корзины." "Этот модуль форматирует цены и даты." Если для объяснения вам нужно слово "и", граница, вероятно, всё ещё размыта.
Пройдитесь по этому фильтру, прежде чем создавать что-то новое:
- Код скоро понадобился бы как минимум двум функциям, а не когда-нибудь потом.
- Модуль должен собираться без случайного подключения SwiftUI или UIKit, если только UI не является его единственной задачей.
- Его тесты должны жить рядом с ним и проверять его поведение без помощи app target.
- В файлах функций должно стать меньше импортов или сами импорты должны стать проще.
- Если перенос только меняет названия папок, лучше не трогать.
Проверка на UI важнее, чем многим кажется. Пакет networking, который импортирует SwiftUI ради баннера ошибки, уже делает слишком много. То же самое касается доменного модуля, который знает о цветах, шрифтах или view modifiers. Как только UI начинает просачиваться в нижние слои, любое небольшое изменение превращается в головоломку с зависимостями.
Тесты — хороший способ проверить реальность. Если вы не можете сказать, чем владеет пакет, вы, скорее всего, не сможете нормально его протестировать. Пакет правил корзины может владеть тестами скидок и налогов. Пакет UI-хелперов может владеть snapshot-тестами или тестами форматирования. Если единственные тесты всё ещё лежат в app target, значит у пакета пока нет настоящей задачи.
Представьте магазинное приложение. Если checkout и история заказов оба нуждаются в форматировании денег, маленький общий пакет — нормально. Если только checkout использует особый стиль кнопки, оставьте этот код рядом с checkout. Общий код должен снижать умственную нагрузку. Если он только перемещает файлы, не трогайте его.
Что делать дальше
На неделю перестаньте добавлять новые пакеты и посмотрите на то, что уже есть. Откройте несколько обычных экранов, проследите их импорты и отметьте места, где одно небольшое изменение тянет в сборку половину приложения. Обычно это и есть реальные проблемные зоны, а не папки, которые просто выглядят неаккуратно.
Начните с самых плохих циклов. Если Checkout импортирует UI-хелпер, этот хелпер импортирует доменные типы, а доменный код возвращается назад в app code, запишите это. Быстрой схемы на бумаге достаточно. Вам не нужен идеальный диаграммный макет, чтобы увидеть, где внутренние Swift-модули мешают друг другу.
Прежде чем создавать что-то новое, слейте слабые пакеты. Если в пакете один enum, два extensions и нет понятной причины жить отдельно, верните его в более сильный модуль. Маленькие пакеты помогают только тогда, когда у них есть ясная задача. Если они существуют лишь потому, что разбиение когда-то показалось аккуратным, они только добавляют трение.
Короткий чек-лист для ревью полезнее длинного архитектурного документа:
- Новый код должен зависеть внутрь, а не в сторону от unrelated features.
- UI-хелперы не должны импортировать сценарии приложения или бизнес-правила.
- Общий код должен заслуживать своё место, обслуживая больше одной функции.
Первый рефакторинг держите маленьким. Перенесите одну границу и понаблюдайте несколько дней. Проверьте время сборки, падения тестов и то, сколько модулей затрагивает обычное изменение функции. Если один перенос делает ревью проще и сокращает churn импортов, продолжайте. Если он создаёт больше обёрток, чем ясности, откатите его и найдите более удачный шов.
Магазинное приложение — хороший тест на здравый смысл. Если изменение правила скидки заставляет одновременно править networking, UI-хелперы корзины и view списка товаров, разбиение всё ещё неверное. Если то же изменение остаётся внутри Domain с одной тонкой правкой на границе, ваше управление Swift-пакетами становится лучше.
Запишите одно правило зависимостей в code review и применяйте его каждый раз. Пусть оно будет достаточно коротким, чтобы его запоминали. "Feature-модули могут использовать Domain и UI-хелперы, но Domain не может импортировать feature-код" — этого вполне достаточно.
Если после пары небольших проходов приложение всё ещё кажется спутанным, внешняя помощь может сэкономить время. Oleg может проверить границы модулей, CI и командный процесс, не добавляя лишних слоёв и не превращая приложение в академический эксперимент.
Цель проста: сделать следующее изменение меньше предыдущего.