Building a scalable SwiftPM architecture using enums, DSL patterns, and compiler-enforced dependencies
Swift Package Manager has become the standard way to structure modern iOS projects.
It gives us modularization, faster builds, and clear dependency boundaries.
But as projects grow, Package.swift often turns into something like this:
.target(
name: "SomeFeature",
dependencies: [
"Core",
"UI",
"Resources"
]
)
Everything is defined as strings.
This makes refactoring fragile and allows architectural violations to slip in.
Renaming a module requires updating multiple string references, and nothing prevents incorrect dependencies.
SwiftPM enforces module boundaries — but only if we model them correctly.
In this article, we’ll go one step further.
Instead of treating Package.swift as a simple configuration file, we’ll turn it into a type-safe DSL for modular architecture.
The result is a SwiftPM setup that:
- removes string-based dependencies
- models modules and features as enums
- generates targets declaratively
- enforces architectural rules at compile time
- scales cleanly as the project grows
What we’ll build
By the end of this article you’ll have a SwiftPM setup that looks like this:
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)})
Instead of fragile strings, dependencies become type-safe constructs.
And the architecture becomes explicit, readable, and scalable.
Let’s start with the core problem: why string-based Package.swift files become painful long before the app itself becomes large.
The Problem With String-Based Dependencies
At first, using strings in Package.swift feels perfectly reasonable. SwiftPM itself supports it, and small projects rarely suffer from it.
But as the number of modules grows, several problems start to appear.
1. Refactoring becomes fragile
Renaming a module means manually updating every reference in the manifest. Miss one string and the build breaks — sometimes far from where the change was made.
2. Architecture rules are not encoded in the code
Many teams follow conventions like:
- Features should depend on Domain
- Features should never import Api
- Shared modules should not depend on features
But these rules often exist only in documentation or code review comments.
With plain string dependencies, nothing prevents a feature from accidentally depending on the wrong module.
3. Repetition grows quickly
Feature modules often follow identical dependency patterns:
.target(
name: "SomeFeature",
dependencies: ["Core", "UI", "Resources"]
)
.target(
name: "OtherFeature",
dependencies: ["OrdersDomain", "Core", "UI", "Resources"]
)
As the project grows, this pattern repeats dozens of times.
At that point Package.swift stops describing architecture and becomes a long list of duplicated declarations. The key observation is simple: most of this structure is not arbitrary — it follows rules. If those rules are predictable, we can encode them directly in Swift instead of repeating them manually.
That is exactly what the DSL approach does.
In the next section, we’ll start modeling the architecture explicitly using enums and small abstractions built on top of SwiftPM.
From Boilerplate to Declarative Architecture
When projects grow, Package.swift often turns into a long list of nearly identical target declarations.
A typical setup might look like this:
.target(
name: "NewsPresentation",
dependencies: [
"NewsDomain",
"Core",
"UI",
"Resources"
]
)
.target(
name: "NewsDomain",
dependencies: [
"NewsData",
"Core"
]
)
.target(
name: "NewsData",
dependencies: [
"Core"
]
)
Now imagine repeating this structure for every feature in your application.
With five features, you already have 15 target declarations.
With ten features, you have 30.
Most of these targets follow exactly the same dependency rules — only the feature name changes.
With the DSL approach, the same architecture can be expressed in a single line:
featureTargets(module: { .News($0)})
This generates:
- News_Presentation
- News_Domain
- News_Data with the correct dependency graph already wired:
Designing the DSL
The key idea behind this approach is simple: most module structures follow predictable patterns. Instead of repeating those patterns manually in Package.swift, we can encode them directly in Swift.
SwiftPM manifests are just Swift files, which means we can use the language itself to model architecture.
We start by identifying the core building blocks of our dependency graph.
External dependencies
Remote packages should be declared in one place so they become the single source of truth for third‑party libraries.
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"
)
}
}
}
Now the dependency list can be generated declaratively:
RemotePackages.allCases.map { $0.info.buildDependency() }
Instead of scattering remote package definitions across the manifest, we centralize them in a small catalog.
Local shared modules
Next we define the shared modules used across the project.
enum Local {
case Core
case UI
case DI
case Resources
case Networking
}
This turns module names into typed values instead of raw strings.
We can now build targets directly from the enum:
Local.Core.target()
Local.UI.target(deps: [.module(.Core)])
Local.DI.target(deps: [.module(.Core)])
Feature modules
Features follow a consistent layered structure. Instead of defining every feature target manually, we model features as typed values.
enum Local {
case Core
case UI
case DI
case Resources
case Networking
case News(_ layer: FeatureLayer) // Declare a feature module
}
Each feature contains multiple layers:
enum FeatureLayer: String {
case Presentation
case Domain
case Data
}
A small Feature abstraction can then generate the targets for a feature automatically:
featureTargets(module: { .Authorisation($0)} ])
featureTargets(module: { .News($0)} )
This is the core of the DSL: we stop describing targets individually and start describing the architecture itself.
In the next section, we’ll look at how the Feature abstraction generates targets and enforces dependency rules between layers.
Generating Feature Targets
The real power of this approach appears when we start generating feature targets automatically.
Most feature modules follow the same structure. A typical feature contains three layers:
- Presentation — UI and view logic
- Domain — business logic and use cases
- Data — repositories, API access, persistence
But the real value comes from encoding the dependency rules between layers.
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)
]
}
Now every feature automatically follows the same architecture.
Behind the scenes, this generates three targets per feature:
- Authorisation_Presentation
- Authorisation_Domain
- Authorisation_Data
with the correct dependency graph already wired.
This has several important benefits:
- architectural rules are encoded once
- adding new features requires almost no boilerplate
- dependency graphs stay consistent
- the manifest remains compact even as the number of features grows
Instead of manually describing every target, we describe the architecture itself, and let the DSL generate the structure.
This is the moment where Package.swift stops being a configuration file and starts acting as an architecture definition.
Customising feature layers
The three-layer setup above is only one possible configuration.
Many teams use a Presentation–Domain–Data structure, but the DSL itself does not depend on that exact shape. The same idea works with other architectural styles as long as the dependency rules are predictable.
For example, some teams prefer an API / Implementation split:
- FeatureApi
- FeatureImpl
Others may use a VIPER-style module structure:
- View
- Presenter
- Interactor
- Router
- DataStore
The important idea is not the number of layers. It is that the layer rules are encoded once and generated consistently.
Putting It All Together
At this point we have all the pieces needed to build a declarative SwiftPM architecture:
- a catalog for external libraries
- typed local modules
- typed feature definitions
- a DSL that generates feature targets Now let’s see how this looks inside the actual Package.swift.
...
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)] )
}
...
The final result is a manifest that stays compact and readable even as the number of modules grows.
Instead of managing targets manually, we describe the rules of the architecture, and let Swift generate the structure.
Why this matters
This approach brings several practical benefits.
Safer refactoring
Module names are typed values instead of strings, which makes renaming significantly safer.
Consistent architecture
Feature layers and dependencies follow the same rules across the entire project.
Less boilerplate
Adding a new feature requires a single line instead of multiple target declarations.
Better scalability
As the number of modules grows, the manifest stays compact instead of turning into a long list of repetitive target declarations.
When to use this approach
This pattern works best when:
- your project contains multiple feature modules
- the architecture follows predictable dependency rules
- you want to keep Package.swift maintainable at scale For very small apps, a simple manifest is often enough.
But once your project starts growing, turning Package.swift into a small architecture DSL can dramatically improve maintainability.
Conclusion
The full implementation of this DSL can be found here:
Swift Package Manager already gives us strong modularization tools.
But by default, Package.swift is often treated as a simple configuration file filled with string-based dependencies.
By modeling architecture directly in Swift — using enums, small abstractions, and a focused DSL — we can turn the manifest into something much more powerful.
Instead of listing targets manually, we encode the rules of the architecture once and let the compiler generate the structure.
The result is a modular SwiftPM setup that is:
- type-safe
- scalable
- explicit
- and significantly easier to maintain.
Package.swift should describe architecture, not just targets.

Top comments (0)