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 в реальном проекте:

GitHub

P.S. Архитектура в каждой команде "немного" своя. Поэтому пример в статье намеренно упрощён — чтобы было легче увидеть саму идею DSL.