How to modularize projects with Swift Package Manager

10 min read
July 31, 2023

If you’re an iOS developer, I’m sure you’ve found yourself in a situation where you’re working on a monolithic legacy project that has outgrown its original scope.

It’s likely that you’ve then found yourself going through spaghetti code – code that does everything and code that does nothing.

Here, we’ll discuss how project modularization can help shield your ever-growing project from those risks.

More specifically we’ll talk about our experience with building a modular iOS project using Apple’s Swift Package Manager and what you can learn from it.

Let’s get into it!

What is project modularization in Swift Package Manager?

Before we begin, I need to make sure you’re up to speed with the basic terminology. To start, I’ll give an overview of the basic ideas behind modular architecture.

Modular Architecture

Think of a project like a box that you’re filling with code and assets. When building a monolithic app, you’re going to put all your resources into a single box and ship it.

However, in modularized projects the aim is to fill the box with other, smaller boxes, essentially cleanly separating the content. Take the illustration below as an example:

Project modularization made simple.

As you can see, this is a compositional approach to building projects. A project can essentially be thought of as a set of features that usually grows over time. 

This philosophy is mirrored in the principles of modular architecture. Feature implementations exist in isolation from one another and can be added, removed, or replaced with relative ease.

Contrast this with the bulky monolithic architecture, where there’s no clear distinction between features and their respective assets. 

Because of this interconnectedness, monolithic projects have a tendency to become hard to maintain as their feature set grows.

Swift Packages and Swift Package Manager

Swift is a programming language with implicit namespacing. The namespace is defined by the containing module (or target).

When creating a new iOS app project, your complete code will be contained in the main module.

For example, if we create a target called MyApplication with 3 types: TypeA, TypeB and TypeC the three types will all be in the MyApplication module.

Their fully qualified names would be MyApplication.TypeA, MyApplication.TypeB and MyApplication.TypeC.

What this means is that unless we somehow explicitly create modules, all of the top-level components in our app will be in the same namespace which could lead to accidental tight-coupling if we’re not careful.

This is where Swift Packages and Swift Package Manager come into play.

Swift Package Manager (SPM) is a tool (whose logo is, incidentally, a box) for managing Swift Packages which was introduced with Swift 3.0.

Swift Package Manager.

source: Medium

A Swift package is a collection of source files, binaries, and resources. Packages can define their own targets (i.e. modules), and therefore their own namespaces.

Project modularization can then be achieved by creating different packages for different functionalities, essentially creating lots of small libraries.

If needed, these libraries can simply be used as dependencies.

It’s worth noting that SPM is just one of many tools that allow us to create projects like this.

Why should you use modularization?

Now that we’ve covered Swift packages, SPM, and modular architecture, we can discuss the benefits this approach can bring to your project.

Code Decoupling

As I’ve already mentioned, the created modules do not depend on each other directly. Instead, they depend on abstractions.

Keen readers might recognize this as the dependency inversion principle of the famous SOLID design principles.

Relation of DIP components.

Source: Wikimedia Commons

Therefore, before the development of a module even begins, one must first consider its requirements.

We can be sure that the requirements are properly defined if we can define a set of interfaces that the module will expose to the outside world. 

After we define the interfaces, we can start implementing them. This development technique is called coding to an interface

Ideally, modules are developed with a single responsibility in mind. 

Loose coupling lets us create modules with more independence from one another. 

Modules can be implemented with different approaches, using different libraries and even programming languages without it affecting the rest of the project.

Developers are free to experiment or use their preferred techniques.

In larger projects, teams can develop modules independently, without interfering with each other, which assigns ownership to each module.

Note that this decoupling can be taken a step further.

Abstractions can be extracted to abstract modules which only contain the interfaces of concrete modules.

In practice this would mean ModuleA, instead of depending directly on ModuleB would depend on ModuleBInterface which would be implemented by ModuleB.

Explicit Dependency Management

Creating modules as Swift packages forces developers to be explicit about the dependency graph in their projects.

For example, functionality from ModuleA cannot be used in ModuleB without explicitly writing an import statement: import ModuleB.

Dependencies between different modules can be easily identified by other developers, helping detect flaws such as circular dependencies early on.

The graph below shows an example of a dependency graph for a project that contains 4 domain-related modules, and two general ones.

Dependency graph.

Let’s illustrate this point with an example.

Say we’re starting work on a feature that lets users browse a list of images retrieved from an endpoint on our backend. Additionally, users should be able to query the images based on several parameters.

Immediately, we can conclude that this feature has to depend on a networking module in order to fetch the data from the backend.

All the domain-specific logic pertaining to images (such as querying) can then be kept in the image module, while the data fetching will be done by calling the networking module.

Let’s say that in the future we receive an additional requirement for this feature: a certain number of images has to be accessible to the user while offline. 

The solution to this problem would be to create a module that handles local data storage and use it in the image feature module.

Just by looking at the images module developers would be able to see that it interacts with the network and local storage since the networking and local storage modules would be explicitly declared as dependencies.

Reusability

Swift Package Manager supports adding Swift package dependencies as local or remote packages.

Local packages are contained within the project as directories, while remote packages are added as dependencies with a remote git repository URI and version rule.

Hypothetically, if our application needs to communicate with a REST API, we’d create a local network module that would handle all the network communication for all the features in the application.

This is nearly identical to simply creating a type-level component that handles networking.

However, remote package dependencies can unlock the full power of modular architecture.

Example of modularization.

Imagine if you were a large organization distributing multiple mobile applications (take, for example, Meta, Uber, or Google).

To shorten development time and ease maintenance, it would make sense to try to consolidate the codebase of the entire organization as much as possible.

So, if you were to develop your applications using a modular architecture, you could create libraries that would be shared across multiple projects.

Think of robust solutions for core functionalities such as networking, local storage, or security.

Maintainability

Ever-expanding projects always carry the risk of code becoming impossible to maintain. 

Not having a clear delineation between features can only add to the danger of the codebase becoming impossible to decipher.

Relying exclusively on a folder structure to group connected code is a surefire way to end up with unreadable code.

Modules instead force developers to keep code segregated making it much harder to make these kinds of mistakes.

Take, for example, encapsulation. 

Swift’s default internal access level makes code visible throughout the module, which in the case of monolithic applications means everywhere.

By contrast, if we want to expose a piece of code outside of a module, we need to explicitly mark it as public.

Therefore, pinpointing failures becomes much easier in a properly modularized application, saving plenty of time (and money!) on development.

To make things even better, Swift packages allow the creation of test targets meaning that packages can be tested independently of one another.

Drawbacks

All of this isn’t to say that any kind of modular architecture will magically solve all your problems. As with everything in software development, this is no silver bullet.

Properly defining modules takes some time and thought and improperly segregated modules can even cause significant issues with dependency management.

Smaller projects might even find the performance benefits with regard to compilation time to be negligible.

How to use modularization in Swift Package Manager?

The approach we use here at DECODE is creating an app with modules separated into local Swift packages.

This kind of project is sometimes called a modular monolith because all the modules are still located within the same top-level module.

We decided on this approach because the application was relatively low in complexity so there was no need for developing more generalized remote modules.

The project structure was the following:

  • Modular application
  • Core
  • CoreUIApplicationCoreUI
  • FeatureA
  • FeatureB
  • DependencyAModule

The Core package contained all of the functionality that would be shared between the different features in the project.

Rhink networking, local storage and remote object decoding. It’s worth noting that all of these functionalities were written to be as generic as possible.

Coupling domain objects with these abstract functionalities would hinder their reusability.

The same principle applies to the CoreUI package.

It contains various helper classes, structures and extensions related to UI functionality. Functionalities such as resource access would reside in this module.

On the other hand, ApplicationCoreUI is a module containing UI elements which are feature-agnostic and reused throughout the application.

This way the app’s UI has a single source of truth should widespread design changes occur.

Of course, the ApplicationCoreUI module depends on the CoreUI package.

This lets us separate code that is UI related and application-agnostic with code that is UI related, tied to the application but is feature-agnostic.

The feature modules are self-explanatory.

Each module has a functionality related to a specific feature of the app. This would be a vertical slice of the application, since it contains everything from UI elements all the way down to remote object fetching.

This is where the domain objects and the business logic of the feature are defined.

Lastly, integrating dependencies can be a bit of a challenge. You do’t want features to directly depend on third party packages because that would add an extra complexity level to the feature modules.

Additionally, if two different features were dependent on the same third party package, the dependency would have to be declared in two different Package.swift files making it hard to manage dependency versioning using a single source of truth.

You should separate packages which act as bridges between third party packages and your project.

Their job is to simplify the interface of the library and adapt it to the needs of our application.

A good example would be a third party authentication library (Facebook, Google etc.) and connecting it to a login feature within our application.

Conclusion

To summarize, if you’re looking to create a large iOS app, or are looking to create a whole suite of applications, you should consider implementing modular architecture.

Modularity creates testable and maintainable projects which have proven to be very beneficial the longer development goes on.

Of course, as all things in software it isn’t a silver bullet and shouldn’t be applied without consideration.

Not all modularity is good modularity, and breaking software down properly takes some overhead and experience.

If you’d like to learn more about iOS development, check out our blogs!

Categories
Written by

Luka Namacinski

iOS developer

Related articles