The benefits and complexities of a modularized codebase
As codebases grow, almost every project will reach a point where the Swift compiler takes a very long time to compile your project every time you want to run it. Every time your Swift code is compiled, the compiler has to do a lot of work to infer types, check correctness, and do all kinds of other compile-time work that turns your code into a binary that can be run on a device.
If your computer has a big enough processor, compile time might not be a big concern you have. Instead, it might be tricky for you to figure out which objects in your codebase depend on which other objects. This usually results in a lot of implicit dependencies that you’re not immediately aware of until you start refactoring.
A common solution for both build times and implicit dependencies is to modularize your codebase. By modularizing a codebase you will define more clear boundaries between different clusters of objects that make up a screen of feature, and you make it much easier for Xcode to only rebuild the modules that have changed.
In this post, we will explore some of the benefits and complexities of a modularized codebase. We’ll start by looking at how you can add a new module to a project from within Xcode. Then we’ll look at how you can add code to a module, and how this impacts the access control modifiers that you use. After that, you will learn about some of the downsides that come with a modularized codebase.
By the end of this post, you will have a much better understanding of what it means to modularize a Swift codebase, and you should have enough insights to determine whether or not modularizing your own codebase makes sense.
Preparing your project for modularization
Most projects these days will exist as an xcodeproj file that can be opened in Xcode. If this is the case for you, there are a couple of ways for you to add new modules to your project. One of the easier ways to do this is to go to your project settings and adding a new target under your list of targets by clicking the plus button at the bottom of the screen.
The template you choose for your new target should be framework. Every module we make is a framework of its own.
After selecting this project, you can name your module. Pick a name that describes the purpose of your model the best. Try to avoid coming up with super generic modules like MyProjectCore. Instead, try and think of features or more specific parts of your codebase that you want to put into modules. For example, MyProjectNetworking, MyProjectModels, SignupFlow or FeedView. Typically you’ll find that as your project grows bigger of time, your desire for smaller modules grows.
After adding your new framework you’ll find that a new folder is added to your Xcode project. In this folder, you will add all the code that belongs to a specific module.
Another approach that you can take when modularizing your code is to create a new .xcworkspace to work in. A workspace can hold multiple projects, and each of your modules can exist in an Xcode project of its own. This is especially useful when you want to be able to easily work on your modules in complete isolation from the rest of your project.
For now, we’ll stick to adding our frameworks to our project like we just did. One of the nice things about this approach is that Xcode will automatically add our new framework to the app target’s framework dependencies as you can see in the screenshot below.
It’s also possible to create your modules as spm packages which comes with pros and cons of its own. Again, we won’t go over that in this post but it’s good to know that spm is an option as well.
The information provided in this post is applicable regardless of the approach you take in your projects.
Swift modules and access control
In Swift, the default access control for all of your code is internal. This means that all code that exists in the same module has access to your classes, methods, and properties. We can mark classes, properties, and methods as private or fileprivate when we want to hide things from the rest of our code, or we can use public when we want other modules to have access to certain parts of code.
In a project where all of your code exists as one large module, you’ll never need the public keyword because the default internal access level already makes your symbols visible to all code in your project.
Once you start modularizing your code, you’ll find that you can’t fully rely on having all of your code be internal anymore. In the initial phases of modularizing your code this can be frustrating. You’ll copy over some files from your app target to a framework, import the framework where it’s needed in your app, and then you’ll see all kinds of compiler errors telling you that the things your using aren’t available due to having an internal access level.
While this is frustrating at first, it’s also kind of nice. By having to mark things as public when we want other modules to access them, we suddenly have a lot of insight into which parts of a module are depended on by other modules. This can often help expose when we’ve accidentally created too much tightly coupled objects when we really want them to be loosely coupled.
Having this insight can help us define better interfaces for each module while making sure that we don’t accidentally have tight coupling in unexpected places of our codebase. Sometimes this might mean that what we thought of as a single module will actually turn into two or three smaller modules; this is perfectly fine. For example, you don’t want a module that’s supposed to handle caching data locally to depend on the module that does networking just because your model objects exist in the networking module. In that case it would be much nicer for the networking and caching modules to depend on a module that’s entirely dedicated to containing our model objects.
In a way, modularizing a codebase allows us to have a whole new layer of access control that we don’t have otherwise which can be incredibly useful.
Exploring the build process in a modularized codebase
Slow build times can be a real productivity killer. I’m pretty sure we can all agree on this. And while Swift tries to compile as little of your code as possible when something changes in your project, we can still encounter situations where our build takes a while to complete after changing something simple.
That’s because Xcode will analyze and plan your build process based on all of the Swift files that exist within the target that you want to build.
For example, if you want to build and run your project and your entire project is a single module, Xcode will run the entire build pipeline you’ve specified for your project. If this includes running Swiftlint, then Xcode will lint every file in your project. This is usually pretty fast of course, but small things add up in a large project.
When you modularize your project, Xcode will be a lot better at seeing what definitely didn’t change, and your build pipeline can run only for the target you’re building, and the frameworks that have changed since the last build.
This is really powerful and can be a huge win in terms of build times.
More importantly, when you’re working on a SwiftUI app for example, each module you make can contain its own UI and you can run previews on small, lightweight modules which is way faster than having to compile your entire app just to show a preview for a single screen in your app.
For smaller projects the gains in build times aren’t huge, especially if you’re running your project on a beefy CPU like the M1 Max for example. But as your project grows, or when you’re using less capable machines, small wins can really add up and improve your developer experience tremendously.
Cons of modularizing your code
No benefits in programming come with a downside. Sometimes a downside can be significant and obvious. Other times a downside is more hidden and a lot harder to spot.
When you’re modularizing your code you’re adding complexity to your codebase. You’ll find that you need to start thinking about dependencies between modules. You might encounter some situations where you’ve introduced a circular dependency where two modules depend on each other (which is something you generally don’t want). Additionally, the added access control means that you need to make sure that you annotate your code with the correct modifiers, especially when you need something to be available in other modules.
All in all, I think the benefits outweigh the cons but the added complexity can really slow you down when you project becomes significantly huge. Most apps don’t tend to get that big, but once you start approaching apps like Uber, AirBnB, Soundcloud, and other large companies you’ll find that it’s way harder to reason about your codebase than it was when your codebase was smaller.
Modules help reasoning on a granular level in these situations. But adding new modules or features can be complex. You might need to add multiple modules to get the job done. You’ll need to think about your dependencies, and you’ll need to make sure that you don’t re-implement features that already existed in other modules. And when you discover that you were about to duplicate logic, you’ll need to consider how you can share the logic between the two modules.
Again, I think all in all the benefits of a properly modularized codebase outweigh the cons. But modularization isn’t something you should introduce into your codebase without a plan.
In this post, you’ve learned about modularized codebases. You learned why you might want to modularize a codebase, how you can start doing this, and what the benefits are. You learned that increased access control and faster build times are two important reason to begin modularizing a codebase. And of course, faster SwiftUI previews are a huge upside of modularization too.
We also talked a bit about the cons of modularization. You learned that huge codebases are inherently complex beasts, and that having lots of modules means that you need to put a lot more planning and thoughts into adding new features and modules to your codebase.
Of course, this complexity doesn’t have to be bad at all and there’s a good reason why large companies modularize their codebase. There’s simply way more benefits than there are downsides. Just remember that you shouldn’t modularize without a plan, and that you should always consider whether the added complexity solves a problem you’re potentially having. A poorly approached modularization effort is much worse than not modularizing at all.