
Swift Package Manager сегодня является стандартным инструментом для модульной архитектуры iOS-проектов. Он позволяет разделять код на независимые модули,
ускорять сборку и явно описывать зависимости. Однако по мере роста проекта файл Package.swift часто превращается в длинный список строковых зависимостей:
.target( name: "SomeFeature", dependencies: [ "Core", "UI", "Resources" ] )
Меня всегда раздражала одна особенность Package.swift:
мы описываем зависимости, но не описываем архитектуру, из за этого:
переименование модулей усложняется;
архитектурные правила не проверяются компилятором;
количество повторяющегося кода быстро растёт.
В этой статье вместо того чтобы рассматривать Package.swift как простой конфигурационный файл, превратим его в типобезопасный DSL для модульной архитектуры, где:
модули описываются через enum;
фичи генерируются декларативно;
архитектурные правила фиксируются в коде.
В итоге объявление зависимостей будет выглядеть так:
Libraries.allCases.map { $0.info.buildDependency() } Local.Core.target() Local.UI.target(deps: [.module(.Core)]) Local.DI.target(deps: [.module(.Core)]) Local.Resources.target() featureTargets(module: { .SomeFeature($0)})
Погнали!
Ключевая особенность SwiftPM в том, что манифесты — это обычные Swift-файлы. Это значит, что мы можем использовать возможности языка для описания архитектуры, пусть и с некоторыми ограничениями.
и при попытке сделать проект с многомодульной архитектурой я получал примерно следующее:
.target( name: "NewsPresentation", dependencies: [ "NewsDomain", "Core", "UI", "Resources" ] ) .target( name: "NewsDomain", dependencies: [ "NewsData", "Core" ] ) .target( name: "NewsData", dependencies: [ "Core" ] )
Если в проекте пять фич — это уже 15 объявлений target. Если десять — то 30.
Используя DSL, ту же архитектуру можно выразить одной строкой:
featureTargets(module: { .News($0)})
при этом будет сгенерированы следующие слои:
News_Presentation
News_Domain
News_Data
с уже правильно настроенным графом зависимостей

Проектируем DSL
Основная идея очень простая: большинство модульных архитектур следуют предсказуемым шаблонам. Вместо того чтобы повторять эти шаблоны в Package.swift, мы можем описать их прямо на Swift.
Объявление сторонних библиотек:
enum RemotePackages: CaseIterable { case Alamofire var spec: RemotePackageSpec { switch self { case .Alamofire: return .init( "https://github.com/Alamofire/Alamofire.git", packageName: "Alamofire", version: "5.10.0" ) } } }
Теперь список зависимостей можно сгенерировать декларативно:
RemotePackages.allCases.map { $0.info.buildDependency() }
Объявление локальных модулей:
enum Local { case Core case UI case DI case Resources case Networking }
После этого объявление target выглядит так:
Local.Core.target() Local.UI.target(deps: [.module(.Core)]) Local.DI.target(deps: [.module(.Core)])
Объявление feature-модулей
enum Local { case Core case UI case DI case Resources case Networking case News(_ layer: FeatureLayer) // обьявляем фича модуль }
Фича состоит из нескольких слоев:
enum FeatureLayer: String { case Presentation case Domain case Data }
Эти слои будут использоваться для автоматической генерации target-модулей.
Настоящая же ценность DSL — в кодировании правил зависимостей между слоями:
func featureTargets( module: ( _ layer: FeatureLayer) -> Local, presentationExtra: [Target.Dependency] = [], domainExtra: [Target.Dependency] = [], dataExtra: [Target.Dependency] = [] ) -> [Target] { let presentation = module(.Presentation) let domain = module(.Domain) let data = module(.Data) return [ presentation.target(deps: [ .module(domain.name), .module(.Core), .module(.UI), .module(.Resources) ] + presentationExtra), domain.target(deps: [ .module(data.name), .module(.Core) ] + domainExtra), data.target(deps: [ .module(.Core), .module(.Networking) ] + dataExtra) ] }
И теперь объявление feature-модуля выглядит так:
featureTargets(module: { .Authorisation($0)} ]) featureTargets(module: { .News($0)} )
Настройка FeatureLayer
Еще одно преимущество такого подхода — структура слоев полностью настраиваемая. Команда может выбирать ее в зависимости от архитектуры проекта.
Например, можно разделить фичу на API и реализацию:
FeatureApi
FeatureImpl
Или использовать более детализированную структуру, например VIPER:
View
Presenter
Interactor
Router
DataStore
Важно не количество слоев, а правила зависимостей между ними.
Именно эти правила DSL позволяет зафиксировать в коде.
Полный текст package.swift
// swift-tools-version: 5.9 import PackageDescription import Foundation // MARK: - Declarations enum ProjectPaths { static let sources = "Sources" } // MARK: Local Modules enum Local { case Core case UI case DI case Resources case Networking case Router(_ layer: FeatureImplLayer) case MainScreen(_ layer: FeatureLayer) case DetailScreen(_ layer: FeatureLayer) } // MARK: Remote Packages enum RemotePackages: CaseIterable { case Alamofire var spec: RemotePackageSpec { switch self { case .Alamofire: return .init( "https://github.com/Alamofire/Alamofire.git", packageName: "Alamofire", version: "5.8.0" ) } } } // MARK: Feature Layering System (Optional) enum FeatureLayer: String { case Presentation, Domain, Data } enum FeatureImplLayer: String { case Impl, Api } // MARK: - Package Formation let packageName = "DemoApp" let package = buildPackage( name: packageName, defaultLocalization: "en", platforms: [.iOS(.v15)] ) { [ Local.Router(.Impl).product(), Local.Router(.Api).product(), Local.Core.product() ] } dependencies: { RemotePackages.allCases.map { $0.spec.buildDependency() } } targets: { // Base modules Local.Networking.target(deps: [.module(.Core), .library(.Alamofire)]) Local.UI.target(deps: [.module(.Core)]) Local.DI.target(deps: [.module(.Core)]) Local.Core.target() Local.Resources.target(resources: [ .process("Resources.xcassets") ]) featureTargets(module: { .Router($0) }, implementationExtra: [ .module(.MainScreen(.Presentation)), .module(.DetailScreen(.Presentation)) ]) featureTargets(module: { .MainScreen($0)}, presentationExtra: [ .module(Local.DI)] ) featureTargets(module: { .DetailScreen($0)}, presentationExtra: [ .module(Local.DI)] ) } // MARK: - Feature configuration func featureTargets( module: ( _ layer: FeatureLayer) -> Local, presentationExtra: [Target.Dependency] = [], domainExtra: [Target.Dependency] = [], dataExtra: [Target.Dependency] = [] ) -> [Target] { let presentation = module(.Presentation) let domain = module(.Domain) let data = module(.Data) return [ presentation.target(deps: [ .module(domain.name), .module(.Core), .module(.UI), .module(.Resources) ] + presentationExtra), domain.target(deps: [ .module(data.name), .module(.Core) ] + domainExtra), data.target(deps: [ .module(.Core), .module(.Networking) ] + dataExtra) ] } func featureTargets( module: ( _ layer: FeatureImplLayer) -> Local, implementationExtra: [Target.Dependency] = [], apiExtra: [Target.Dependency] = [] ) -> [Target] { let implementation = module(.Impl) let api = module(.Api) return [ implementation.target(deps: [ .module(api), .module(.Core) ] + implementationExtra), api.target(deps: apiExtra) ] } // DSL PART // MARK: - Helpers превращает enum Local в рабочее описание модуля extension Local { var name: String { let parsed = parsedDescription if let layer = parsed.layer { return "\(parsed.base)_\(layer)" } return parsed.base } private var path: String { let parsed = parsedDescription if let layer = parsed.layer { return "\(ProjectPaths.sources)/Features/\(parsed.base)/\(layer)" } return "\(ProjectPaths.sources)/\(parsed.base)" } private func module(_ resources: [Resource]?) -> TargetSpec { return TargetSpec(name: name, path: path, resources: resources) } private var module: TargetSpec { TargetSpec(name: name, path: path) } func target(deps: [Target.Dependency] = [], resources: [Resource]? = nil) -> Target { module(resources).target(deps: deps) } func product() -> Product { module.product() } } //Упрощает объявление зависимостей extension Target.Dependency { static func module(_ m: Local) -> Target.Dependency { .target(name: m.name) } static func module(_ name: String) -> Target.Dependency { .target(name: name) } static func library(_ lib: RemotePackages) -> Target.Dependency { .product(name: lib.spec.productName, package: lib.spec.packageName) } } // MARK: - DSL Core // Позволяет декларативно собирать список Target @resultBuilder enum TargetsBuilder { static func buildBlock(_ parts: [Target]...) -> [Target] { parts.flatMap { $0 } } static func buildExpression(_ t: Target) -> [Target] { [t] } static func buildExpression(_ ts: [Target]) -> [Target] { ts } } // Позволяет декларативно собирать список Product @resultBuilder enum ProductsBuilder { static func buildBlock(_ parts: [Product]...) -> [Product] { parts.flatMap { $0 } } static func buildExpression(_ p: Product) -> [Product] { [p] } static func buildExpression(_ ps: [Product]) -> [Product] { ps } } // Позволяет декларативно собирать список Package.Dependency. @resultBuilder enum DependenciesBuilder { static func buildBlock(_ parts: [Package.Dependency]...) -> [Package.Dependency] { parts.flatMap { $0 } } static func buildExpression(_ d: Package.Dependency) -> [Package.Dependency] { [d] } static func buildExpression(_ ds: [Package.Dependency]) -> [Package.Dependency] { ds } } // Обёртка над Package, чтобы собирать package через твой DSL func buildPackage( name: String, defaultLocalization: LanguageTag? = nil, platforms: [SupportedPlatform] = [], @ProductsBuilder products: () -> [Product], @DependenciesBuilder dependencies: () -> [Package.Dependency], @TargetsBuilder targets: () -> [Target] ) -> Package { PackageSpec( name: name, defaultLocalization: defaultLocalization, platforms: platforms, products: products(), dependencies: dependencies(), targets: targets() ).build() } // MARK: - Specs // Промежуточная модель для сборки Package struct PackageSpec { var name: String var defaultLocalization: LanguageTag? var platforms: [SupportedPlatform] = [] var products: [Product] = [] var dependencies: [Package.Dependency] = [] var targets: [Target] = [] func build() -> Package { Package( name: name, defaultLocalization: defaultLocalization, platforms: platforms, products: products, dependencies: dependencies, targets: targets ) } } // Модель для описания внешнего пакета struct RemotePackageSpec { let url: String let packageName: String let productName: String let version: Version init(_ url: String, packageName: String, productName: String? = nil, version: Version) { self.url = url self.packageName = packageName self.productName = productName ?? packageName self.version = version } func buildDependency() -> Package.Dependency { .package(url: url, from: version) } } // Модель для описания target struct TargetSpec { private let name: String private let path: String private let resources: [Resource]? init(name: String, path: String, resources: [Resource]? = nil) { self.name = name self.path = path self.resources = resources } func target(deps: [Target.Dependency] = []) -> Target { .target( name: name, dependencies: deps, path: path, resources: resources ) } func testTarget(deps: [Target.Dependency] = []) -> Target { .testTarget( name: name, dependencies: deps, path: path ) } func product() -> Product { .library(name: name, targets: [name]) } } // MARK: - Helper to automatically generate the feature path and name for import extension Local { private var parsedDescription: (base: String, layer: String?) { let description = String(describing: self) guard let start = description.firstIndex(of: "("), let end = description.firstIndex(of: ")") else { return (description, nil) } let base = String(description[..<start]) var layer = String(description[description.index(after: start)..<end]) // remove type prefix like "Main.FeatureLayer." if let last = layer.split(separator: ".").last { layer = String(last).capitalizedFirst } return (base, layer) } } extension String { var capitalizedFirst: String { prefix(1).uppercased() + dropFirst() } }
Все что используется для настройки проекта находиться до "DSL PART"
Код демо-проекта
Если хотите попробовать DSL в реальном проекте:
P.S. Архитектура в каждой команде "немного" своя. Поэтому пример в статье намеренно упрощён — чтобы было легче увидеть саму идею DSL.
