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.

2,000 Imports: Organizing my Apps' SwiftPM modules
Photo by Paul Teysen / Unsplash

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

UPDATE: For new/smaller apps, I use a simplified solution of what I describe in detail below. See my Foundation repository for more.

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?


Want to see your ad here? Contact me at ads@fline.dev to get in touch.

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:

  1. The ⬆️ top "Feature" half is built on top of the bottom "Helpers" half, thus:
  2. The ↖️ green "Non-UI Features" modules can import AppFoundation.
  3. The ↗️ red "UI Features" modules can import both, AppFoundation & AppUI.
  4. Within a group (quarter), modules can depend on each other (prevent cycles!).
  5. ➡️ "UI" modules can depend on ⬅️ "Non-UI" modules or on AppFoundation.
  6. The ⬇️ bottom "Helpers" are never allowed to import from "Features" above!
  7. 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:

import AppFoundation
import AppUI

11 imports were reduced to just 2 thanks to the @_exported attribute.

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.
   ]
)
☑️
Similar to 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:

  1. First, I used Xcodes Find & Replace for every @_exported lib and replaced all imports with AppFoundation, so I ended up with a lot of files with multiple imports of AppFoundation.
  2. Next, I used the SwiftLint rule duplicate_imports which supports auto-correction. Install it via brew install swiftlint, then run these 3 lines:
echo "only_rules: [duplicate_imports]" > temp_swiftlint.yml
swiftlint lint --config temp_swiftlint.yml --path Sources --autocorrect
rm temp_swiftlint.yml

Adjust the parameter passed to --path if yours is different than Sources.

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!

💁🏻‍♂️
Enjoyed this article? Check out TranslateKit!
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!
👨🏻‍💻
Want to Connect?Follow me on 👾Twitch, 🎬YouTube, 🐦Twitter, and 🦣Mastodon.