2,000 Imports: Organizing my Apps' SwiftPM modules
How to organize your apps Swift modules for clarity & convenience using a hidden (unofficial) Swift feature. A practical solution for small to medium-sized apps.
The Problem
I recently decided to work on the biggest feature for RemafoX to date and while I was thinking about where to start, I found myself drowning in over 70 targets for my less-than-one-year-old project. Note that I'm modularizing my app for clear code segregation and faster build times (= faster SwiftUI previews, tests & more) using the vanilla SwiftPM-based method presented by Point-Free in this free episode. Because I plan on working on this app for years to come (the 27 features online are just the tip of the iceberg, I have many more ideas), I've decided to first clean up this mess. After all, a round of refactoring between features keeps the code base clean and makes the developer happy! 😇
I remembered that I had discovered the @_exported
attribute while reading through Swift Evolution threads when preparing one of the issues of my related newsletter. While it's not recommended to use underscored APIs as their behavior might change or they might even potentially get entirely removed, I found myself lacking alternatives with my goal of cleaning up the many unorganized targets. For exactly this reason, I convinced myself that the chances of this attribute getting entirely removed were relatively low. If anything, I believe that the related pitch might get picked up some time and finds its way into official Swift, so we can replace @_exported
with whatever it could be named then. Also, I found out that Point-Free is depending on this attribute as well in The Composable Architecture framework, which my app heavily depends on already. So why not go all-in on it?
In short, what the attribute helps with is this: Imagine you have 10 feature modules and 5 helper modules. In each file of the 10 features, I tend to import all (or most) of these 5 helper modules, which results in something like this:
import Assets
import Analytics
import ComposableArchitecture
import Constants
import Defaults
import HandySwift
import HelpfulErrorUI
import ReusableUI
import SFSafeSymbols
import SwiftUI
import Utility
And this gets repeated over and over again. Ok, it's true that not all of them are needed in every single file of a target, but the truth is also that Xcode is linking the entire module anyway when only a single file in the module imports it, so importing them all in all files wouldn't hurt build times (AFAIK). Using @_exported import
we can combine all these imports by creating a new target, call it something like CoreDependencies
and create a Swift file in it with this content:
@_exported import Assets
@_exported import Analytics
@_exported import ComposableArchitecture
@_exported import Constants
@_exported import Defaults
@_exported import HandySwift
@_exported import HelpfulErrorUI
@_exported import ReusableUI
@_exported import SFSafeSymbols
@_exported import SwiftUI
@_exported import Utility
Now, whenever we import CoreDependencies
, it will import all other modules, too!
But is putting everything into one group called CoreDependencies
really the right solution? Another problem apart from having to repeat the imports too often is that I'm currently sorting all these 70 modules alphabetically due to the lack of another kind of grouping or structure. This lack of grouping doesn't only make it harder to find the right module when I roughly know what I'm looking for but don't remember the exact name of the module. It can also lead to circular dependencies while working on features and trying to reuse as much code as possible. It requires strategic planning of what belongs where to allow reusing code while preventing cyclic dependencies which lead to compiler errors.
The Solution
The best way to find a practical solution to a problem is to look at a real-world example. So, here's a selection of modules I actually use in RemafoX:
Analytics
Assets
BetterCodable
CommandLineSetup
ComposableArchitecture
Constants
FilesSearch
Foundation
HandySwift
HelpfulErrorUI
MachineTranslation
Paywall
ProjectsBrowser
ReusableUI
SFSafeSymbols
Settings
SwiftUI
Utility
Yes, I've also listed Foundation
and SwiftUI
in the list above. Why? Because at the end of the day, they are dependencies that need to be imported as well, just like any other dependency we import, be it an external or internal dependency. I view them as built-in external dependencies. You might be used to at least import Foundation
in any Swift file, but actually, you can write Swift code without Foundation
, you'll just have the barebones Swift features then including everything contained in the Swift Standard Library. It totally works!
And actually, these two imports that we all made so often represent an Apple-internal grouping of features/helpers: Apple groups a whole bunch of functionality behind Foundation
, and they do the same with SwiftUI
or UIKit
/AppKit
. The deciding factor seems to be that everything that represents some kind of UI or is directly related to UI belongs to one group, and everything that doesn't represent a UI or isn't directly related to UI into another group. So, the most natural thing we could do is to follow their lead, we could even copy their naming by using Foundation
for the non-UI group and UI
(which appears in both SwiftUI
and UIKit
) for the UI group. Because our groups are specific to an apps domain, the resulting names for our groups would be: AppFoundation
and AppUI
.
Let's apply this to the list of modules above:
// AppFoundation
Analytics
BetterCodable
CommandLineSetup
Constants
FilesSearch
Foundation
HandySwift
MachineTranslation
Utility
// AppUI
Assets
ComposableArchitecture
HelpfulErrorUI
Paywall
ProjectsBrowser
SFSafeSymbols
ReusableUI
Settings
SwiftUI
This already starts to look better. But there's one more thing we can learn from how Apple structures its frameworks: Apple doesn't link each and every non-UI feature as part of Foundation
, nor do they ship all SwiftUI-related code as part of SwiftUI
. Combine
and Charts
are two frameworks we need to import separately. Why not ship them as part of Foundation
and SwiftUI
? Because they are useful only in some specific domains and might not be needed in a more global scope.
If you remember the initial problem, it was that I had a set of modules that I imported over and over again in many places because they were useful helpers in a global manner, rather than being useful only in some specific domains. So it makes sense to import them as part of a unified group name. But what actually is a helper? What differentiates it from a more domain-specific feature?
I personally call a feature a "helper" or a "utility" feature, when its global availability is much more useful than it hurts the development process. Of course, this is somewhat subjective but as a rule of thumb I do this: If I already use the feature in multiple different parts of my app, plus when thinking about 2 or 3 potential new features I might add to my app sometime in the future and at least one of them could also make use of it, then it's probably very useful globally.
In more practical terms, I would separate the above list of modules like this:
// (globally useful) Helpers
Analytics
Assets
BetterCodable
ComposableArchitecture
Constants
Foundation
HandySwift
HelpfulErrorUI
ReusableUI
SFSafeSymbols
SwiftUI
Utility
// (domain-specific) Features
CommandLineSetup
FilesSearch
MachineTranslation
Paywall
ProjectsBrowser
Settings
If we now combine the two dimensions of separation, we end up with something like the following graph with 4 quarters & dependencies in between:
Here are a few important things to note:
- The ⬆️ top "Feature" half is built on top of the bottom "Helpers" half, thus:
- The ↖️ green "Non-UI Features" modules can
import AppFoundation
. - The ↗️ red "UI Features" modules can import both,
AppFoundation
&AppUI
. - Within a group (quarter), modules can depend on each other (prevent cycles!).
- ➡️ "UI" modules can depend on ⬅️ "Non-UI" modules or on
AppFoundation
. - The ⬇️ bottom "Helpers" are never allowed to import from "Features" above!
- External modules can also be "Features" (see ↖️, currently I have none in ↗️)
To apply this structure, I just created a new module named AppFoundation
, plus a new Swift file in it named AppFoundation.swift
with the following contents:
// System
@_exported import Foundation
// Internal
@_exported import Analytics
@_exported import Constants
@_exported import Utility
// External
@_exported import BetterCodable
@_exported import HandySwift
I also created a module AppUI
with the following contents for AppUI.swift
in it:
// System
@_exported import SwiftUI
// Internal
@_exported import Assets
@_exported import HelpfulErrorUI
@_exported import ReusableUI
// External
@_exported import ComposableArchitecture
@_exported import SFSafeSymbols
Now I can replace the 11 imports from the initial example at the beginning of this article, that I took from a file inside a UI Feature module, with just these 2 lines:
Note that I didn't even have to import Foundation
or SwiftUI
. And for any Non-UI Feature I even just need a single line stating import AppFoundation
!
Of course, this doesn't mean I won't ever import anything else anymore. I'll still be having imports on the vertical axis, where a specific module imports another specific module inside a group, e.g. a ConfigFile
UI feature importing child components like ConfigFileLinter
and ConfigFileNormalizer
. But these are domain-specific imports and don't lead to many repetitive imports.
The last thing I did was group my products, my dependencies, and targets in my Package.swift
file by these 4 quarters. For this, I added pragma marks like // MARK: - Non-UI Features
in all sections and put the related statements into them in alphabetic order. My resulting manifest now looks something like this:
import PackageDescription
let package = Package(
name: "RemafoX",
platforms: [.macOS(.v12)],
// MARK: - Products
products: [
// MARK: - Grouping Products
.library(name: "AppFoundation", targets: ["AppFoundation"]),
.library(name: "AppUI", targets: ["AppUI"]),
.library(name: "AppTest", targets: ["AppTest"]),
// MARK: - Non-UI Helper Products (AppFoundation)
.library(name: "Analytics", targets: ["Analytics"]),
.library(name: "Constants", targets: ["Constants"]),
.library(name: "Utility", targets: ["Utility"]),
// MARK: - UI Helper Products (AppUI)
.library(name: "Assets", targets: ["Assets"]),
.library(name: "HelpfulErrorUI", targets: ["HelpfulErrorUI"]),
.library(name: "ReusableUI", targets: ["ReusableUI"]),
// MARK: - Test Helper Products (AppTest)
.library(name: "TestResources", targets: ["TestResources"]),
// MARK: - Non-UI Feature Products
.library(name: "CommandLineSetup", targets: ["CommandLineSetup"]),
.library(name: "FilesSearch", targets: ["FilesSearch"]),
.library(name: "MachineTranslation", targets: ["MachineTranslation"]),
// MARK: - UI Feature Products
.library(name: "Paywall", targets: ["Paywall"]),
.library(name: "ProjectsBrowser", targets: ["ProjectsBrowser"]),
.library(name: "Settings", targets: ["Settings"]),
],
// MARK: - Dependencies
dependencies: [
// MARK: - Non-UI Helper Dependencies (AppFoundation)
.package(url: "https://github.com/marksands/BetterCodable.git", from: "0.4.0"),
.package(url: "https://github.com/sindresorhus/Defaults", from: "6.3.0"),
.package(url: "https://github.com/FlineDev/HandySwift", branch: "main"),
// MARK: - UI Helper Dependencies (AppUI)
.package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols", from: "3.3.0"),
.package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.40.2"),
// MARK: - Test Helper Dependencies (AppTest)
.package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "0.3.0"),
// MARK: - Non-UI Feature Dependencies
.package(url: "https://github.com/FlineDev/Microya", branch: "main"),
.package(url: "https://github.com/JohnSundell/Splash.git", from: "0.16.0"),
.package(url: "https://github.com/jakeheis/SwiftCLI.git", from: "6.0.3"),
.package(url: "https://github.com/TelemetryDeck/SwiftClient", branch: "main"),
// MARK: - UI Feature Dependencies
],
// MARK: - Targets
targets: [
// MARK: - Grouping Targets
.target(
name: "AppFoundation",
dependencies: [
// Internal
"Analytics",
"Constants",
"Utility",
// External
.product(name: "BetterCodable", package: "BetterCodable"),
.product(name: "HandySwift", package: "HandySwift"),
]
),
.target(
name: "AppUI",
dependencies: [
// Internal
"Assets",
"HelpfulErrorUI",
"ReusableUI",
// External
.product(name: "SFSafeSymbols", package: "SFSafeSymbols"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
]
),
.target(
name: "AppTest",
dependencies: [
// Internal
"TestResources",
// External
.product(name: "CustomDump", package: "swift-custom-dump"),
]
),
// MARK: - Non-UI Helper Targets (AppFoundation)
.target(
name: "Analytics",
dependencies: [
// Internal
"Constants",
// External
.product(name: "HandySwift", package: "HandySwift"),
.product(name: "TelemetryClient", package: "SwiftClient"),
]
),
.testTarget(name: "AnalyticsTests", dependencies: ["AppTest", "Analytics"]),
.target(
name: "Constants",
dependencies: [
.product(name: "Defaults", package: "Defaults"),
.product(name: "HandySwift", package: "HandySwift"),
]
),
.target(
name: "Utility",
dependencies: [
// Internal
"Analytics",
"Constants",
// External
.product(name: "HandySwift", package: "HandySwift"),
.product(name: "Defaults", package: "Defaults"),
]
),
.testTarget(name: "UtilityTests", dependencies: ["AppTest", "Utility"]),
// MARK: - UI Helper Targets (AppUI)
.target(
name: "Assets",
dependencies: [.product(name: "Defaults", package: "Defaults")],
resources: [
.process("Colors.xcassets"),
.process("Images.xcassets"),
.copy("Sounds"),
]
),
.target(
name: "HelpfulErrorUI",
dependencies: [
// Internal
"AppFoundation",
"Assets",
"ReusableUI",
]
),
.target(
name: "ReusableUI",
dependencies: [
// Internal
"AppFoundation",
"Assets",
// External
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "Splash", package: "Splash"),
.product(name: "SFSafeSymbols", package: "SFSafeSymbols"),
]
),
// MARK: - Test Helper Targets (AppTest)
.target(
name: "TestResources",
dependencies: [],
path: "TestResources",
exclude: ["Package.swift"],
sources: ["TestResources.swift"],
resources: [
.copy("CustomSample"),
.copy("EmptyFileStructureSamples"),
.copy("GitHubSampleProjects"),
]
),
// MARK: - Non-UI Feature Targets
.target(name: "CommandLineSetup", dependencies: ["AppFoundation"]),
.target(
name: "MachineTranslation",
dependencies: [
// Internal
"AppFoundation",
// External
.product(name: "Microya", package: "Microya"),
]
),
.testTarget(
name: "MachineTranslationTests",
dependencies: ["AppFoundation", "AppTest", "MachineTranslation"],
exclude: ["Resources/secrets.json.sample"],
resources: [.copy("Resources/secrets.json")]
),
// MARK: - UI Feature Targets
.target(name: "Paywall", dependencies: ["AppFoundation", "AppUI"]),
.target(
name: "ProjectSetup",
dependencies: [
"AppFoundation",
"AppUI",
"ProjectDragAndDrop",
"ProjectAnalyzer",
]
),
.target(
name: "Settings",
dependencies: [
"AppFoundation",
"AppUI",
"SettingsTabCurrentPlan",
"SettingsTabGeneral",
"SettingsTabMachineTranslation",
]
),
// ... many more features related to Project, Settings etc.
]
)
AppFoundation
and AppUI
, I also introduced an AppTest
grouping target to my app which I use to unify imports of XCTest
and dependencies/helpers like CustomDump
(highly recommended!).To replace all relevant imports with AppFoundation
/AppUI
, I used this trick:
- First, I used Xcodes Find & Replace for every
@_exported
lib and replaced all imports withAppFoundation
, so I ended up with a lot of files with multiple imports ofAppFoundation
. - Next, I used the SwiftLint rule
duplicate_imports
which supports auto-correction. Install it viabrew install swiftlint
, then run these 3 lines:
3. Lastly, I reverted the changes for files that lie inside modules which are themselves part of AppFoundation
using Git, also AppFoundation.swift
itself.
4. Then I repeated the above steps for AppUI
. It all took less than 10 minutes!
That's it! As you can see, before cleaning up I had ~2,000 imports in my project:
After the cleanup, I have now only 1,200 imports, roughly 40% less than before!
Also, my Package.swift
manifest file got a lot shorter, from 827 lines to 575 lines, that's roughly a third less. And it's all so much more structured, I'm happy! 😍
Conclusion
Thanks to @_exported import
and separation of modules into four groups by asking if they are (A) "UI-related" or "Non-UI-related", and (B) more "globally useful" or more "domain-specific", I can now import an infinite number of "Helper" modules into my "Feature" modules with just one or two import
lines! Not only that, but these groups with their import rules also serve as a guide to easily place my code into the right module to prevent circular dependencies.
The result: Less code to write, fewer chances for build errors – a win-win!
A drag & drop translator for String Catalog files – it's really easy.
Get it now to machine-translate your app to up to 150 languages!