How to start your modular iOS architecture

Xcode 16.0 beta 6 | Swift 6.0

Interested in learning how to start modularizing your iOS codebase? Let's see how you can get started with Xcode + Swift Package Manager...

architecture swift-package-manager swift

Achieving code modularization has been a frequent topic of discussion with colleagues and friends, especially when working on projects that start scaling rapidly. We’ve implemented it in various ways, leveraging the tools available to us. However, it wasn’t always a smooth ride. We made mistakes, learned from them, and adjusted as we went along. It’s always a matter of making the right trade-offs, testing, learning, and iterating. So, being proactive about modularization can bring significant benefits, even if you’re building a simple application.

The motivation for pursuing modularization varies from team to team, but I believe working in this way can make us better programmers. My goal is to share some thoughts on the topic and show you how Xcode and Swift Package Manager can play together when setting up new modules.

Let me start by stating a few of my favorite benefits from modular codebases:

  1. It encourages thoughtful design of interfaces by utilizing the full range of Swift access levels (open, public, internal), which we don’t always take advantage of.
  2. It allows us to incrementally migrate modules and adopt new features from the Swift compiler without committing the entire project at once. For example, think about adopting strict concurrency checking.
  3. While this may not apply to everyone, modularization gives us the ability to share our modules across multiple applications.

In this tutorial, you will learn how to use Xcode and Swift Package Manager to break your codebase into independent modules, with the goal of making your project easier to collaborate on, maintain, test, and scale.

Alright, let’s dive into some code… But before we start, let’s review the sample application we’ll be building in this tutorial: a simple Todos app. It will have two main screens—a Login view and a Home view. The login functionality will be simulated with a delay, and any username/password combination will be accepted, as we’re not implementing validation. The home functionality is a simple List displaying all our tasks.

You can find the source code on GitHub.

The Setup

  1. Create a brand new Xcode Project. Create new Xcode Project - screenshot
  2. From the Xcode's File menu, select NewPackage New Package screenshot

  3. Next, let's create a new Package using Xcode and name it "AppModules". Add package to project - screenshot

    Important! In order for Xcode to auto-generate the right schemes based on the manifest, Please make sure you select the option to add it to the project we created on step 1.

  4. The result should be, something like the following: Initial project setup - screenshot

Now that we have everything configured, let's start adding our first library, the Login feature. This can be achieved both manually or with the help of Swift Package Manager. I prefer to do it via the latter.

Quick Aside: Before we run our first command, let's quickly revisit the options available on SPM. Run the following command on your terminal:

swift package --help

The first few subcommands provide us a way to create new targets and libraries, which is what we will do during this tutorial.

SUBCOMMANDS:
  add-dependency          Add a package dependency to the manifest
  add-product             Add a new product to the manifest
  add-target              Add a new target to the manifest
  add-target-dependency   Add a new target dependency to the manifest

Our First Feature as library

SPM provides us with two options when we want to create a new product. We can either do a static or dynamic type depending on our needs. In this case, our intention is to separate our code into manageable modules, so our best fit is a static library. Having too many dynamic frameworks might penalize us at launch time, and we don't want that. A static library on the other hand will get linked at compile time into our final app binary. SPM's default type is what we want, but you can also be specific about it and add the --type argument and set it to static-library.

Another consideration we need to make, due to how SPM works, is that we need to create our targets and products explicitly. Creating a product, won't automatically create a target for it.

Let's run the following commands on the terminal:

# create the target containing our feature code
swift package add-target LoginFeature 

# create target containing unit tests for our feature
swift package add-target LoginFeatureTests --type test --testing-library swift-testing --dependencies LoginFeature 

# finally, create our product. Note that we can name your library something than our target
swift package add-product Login --targets LoginFeature

With this changes in place, we can now start writing our Login feature. For this example I'm just going to create a simple Login view so that we can use it within our application.

Create a new file and name it LoginView.swift, then add the following code:

public struct LoginView: View {
    var onSubmit: (String, String) -> Void
    @State private var username = ""
    @State private var password = ""
    @FocusState private var usernameIsFocused: Bool

    public init(onSubmit: @escaping (String, String) -> Void) {
        self.onSubmit = onSubmit
    }

    public var body: some View {
        ZStack {
            LinearGradient(colors: [.primary, .accentColor], startPoint: .top, endPoint: .bottom)
            VStack {
                VStack {
                    Spacer()
                    Text("Welcome")
                        .font(.system(size: 68))
                        .foregroundStyle(Color.accentColor)
                        .padding(.bottom, 100)
                }
                .frame(maxHeight: .infinity)

                VStack {
                    VStack {
                        InputTextView(input: $username, hint: "Username", systemImage: "person")
                            .padding(.bottom)
                        InputTextView(input: $password, hint: "Password", systemImage: "ellipsis.rectangle", isSecure: true)
                    }
                    .padding()
                    .foregroundStyle(.white)

                    Button {
                        onSubmit(username, password)
                    } label: {
                        Text("Log In")
                            .frame(maxWidth: .infinity)
                    }
                    .padding(.horizontal)
                    .padding(.top, 50)
                    .buttonStyle(.borderedProminent)
                    Spacer()
                }
                .frame(maxHeight: .infinity)
            }
            .onAppear {
                usernameIsFocused = true
            }
        }
        .ignoresSafeArea(edges: .all)
    }
}

Once we have our LoginView in place, we can wire it up to our main ContentView. So let's do that next.

When you try to use the view we just created, you will get an error indicating the compiler couldn't find the new symbols. In order to resolve this, we need to make the compiler aware of this new module and where to find it.

Open the project settings and with the App's target selected go to GeneralFrameworks, Libraries and Embedded Content and add it to the list:

Add library to target

Go to ContentView.swift, (which got created as part of step #1) and replace its body with the following:

import LoginFeature // <-- This will import our library

struct ContentView: View {
    // ...
    var body: some View {
        LoginView { username, password in 
            // callback closure that will be executed when the user taps the Log In button
        }
    }
}

As you can see, we are now able to use any types that we declare as public within our library. This will be more beneficial as your codebase starts to grow. For a simple example like this, it might sound overkill but we can immediately see some of the decisions we will need to make, like which types need to be public. I believe this alone provides a lot of value to your project as it will encourage you to write well designed interfaces, hiding internal details that should not be shared outside the library itself by default with the compiler's help.

Our Second feature

To wrap up this tutorial, let's create a new module that has our Home screen, I'll name it "HomeFeature".

Let's go to the terminal and repeat the commands we ran for the LoginFeature:

swift package add-target HomeFeature 
swift package add-target HomeFeatureTests --type test --testing-library swift-testing --dependencies HomeFeature 
swift package add-product Home --targets HomeFeature

For simplicity, let's move the code that was generated by Xcode to this new module...

We need to make 3 changes:

First. Let's create a new HomeView.swift file, create a new HomeView struct and add the code we got from the ContentView.

public struct HomeView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [TodoItem]
    @State private var showModal = false
    @State private var todoTitle = ""

    public init() {}

    public var body: some View {
        NavigationSplitView {
            List {
                ForEach(items) { item in
                    NavigationLink {
                        VStack {
                            Text(item.title)
                                .font(.title)
                                .bold()
                            Text("Added on \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
                        }
                    } label: {
                        Text(item.title)
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .navigationTitle("Todos")
            #if os(iOS)
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
                ToolbarItem {
                    Button {
                        showModal.toggle()
                    } label: {
                        Label("Add Todo", systemImage: "plus")
                    }
                }
            }
            #endif
        } detail: {
            Text("Select an item")
        }
        .sheet(isPresented: $showModal) {
            if !todoTitle.isEmpty {
                addItem()
                todoTitle = ""
            }
        } content: {
            VStack {
                TextField("New Todo", text: $todoTitle)
                    .textFieldStyle(.roundedBorder)
                    .padding(.bottom, 20)
                Button {
                    showModal.toggle()
                } label: {
                    Text("Add")
                        .frame(maxWidth: .infinity)
                }
                .buttonStyle(.borderedProminent)
            }
            .padding()
            .presentationDetents([.height(200)])
            .presentationDragIndicator(.visible)
        }
    }

    private func addItem() {
        withAnimation {
            let newItem = TodoItem(title: todoTitle)
            modelContext.insert(newItem)
        }
    }

    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            for index in offsets {
                modelContext.delete(items[index])
            }
        }
    }
}

Second. Move the Item.swift file to Sources/HomeFeature and rename the class from ItemTodoItem

@Model
public final class TodoItem {
    var title: String
    var timestamp: Date

    init(title: String, timestamp: Date = .now) {
        self.title = title
        self.timestamp = timestamp
    }
}

Third. Create a new file and name it DataModel.swift. This will get us access to the SwiftData's ModelContainer object we will later inject into our App.

public final class DataModel {
    public static var container: ModelContainer {
        let schema = Schema([
            TodoItem.self,
        ])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)

        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }
}

Time to use it within the main app's module again.

When you try to use the view we just created, you will get an error indicating the compiler couldn't find the symbols we just moved. In order to resolve this, we need to make the compiler aware of this new module and where to find it.

Open the project settings and with the App's target selected go to GeneralFrameworks, Libraries and Embedded Content and add it to the list: Add library to target With this change, we have achieved a modular architecture than can support development by multiple developers and even teams. 🎉

One initial observation that I want to make, is the fact that with this simple extraction of our initial code to the HomeFeature module, our app module doesn't import SwiftData anymore. All its references were moved alongside the view.

Quick Aside: Since Xcode 12, swift packages can include resources and we can also use Swift Previews right within our module without the need for a hosting application. Xcode knows how to handle normal resource types and how to optimize them Asset catalogs, Localization files, xib files. However, when you have resources that are not supported by default, you can copy them, but you will need to locating on the auto-generated Bundle.module extension.

Inter-module communication

It's time to talk about inter-module communication. For this, I will just say that I try to be pragmatic about it and try to keep it as simple as possible. Sometimes you will need to create abstractions to hide specific module details, and other times you can just use the public interfaces of your concrete types. The following article does a great job at explaining the Dependency Inversion Principle which is something that can help with this topic: https://martinfowler.com/articles/dipInTheWild.html

To wrap-up, let's quickly review benefits, challenges and some tips.

Benefits:

  • Promotes feature ownership. Particularly useful when working with multiple teams, where one team can fully own the development and maintenance or a feature.
  • Encourages developers to think carefully about their public interfaces. Since now we need to define what types are public and which ones are better off staying private or internal to the library.
  • Compilation times: Since we are dealing with a subset of our application at a time, compilation times should be improved. I haven't done an in depth benchmark for this claim, but rather just my empirical observations in past projects.
  • Testing. Since compiling the app is faster, we can write unit tests faster as well.
  • Reusability. Specially useful when your team develops multiple similar applications.

Challenges

  • Resources. Working with resources requires us to use the Bundle.module to locate files. We just need to be careful to use it only on the target that contains any resources, otherwise Xcode won't auto-generate this extension for your library.
  • Granularity. Requires a constant balance to determine how granular you want to make your codebase. If you create too many modules, the hassle to maintain too many might outweigh its benefits.
  • Versioning. This can add an additional level of complexity, so introduce it only if absolutely necessary.

Tips

  • Create new targets and modules using the Swift Package Manager, as we saw during this tutorial. The alternative is to do it manually by creating the appropriate folder structure.
  • Run tests using spm. Notice this will build your module targeting macOS, this might be problematic if you don't care about your code on that platform. But consider that it might run the tests faster as no simulator is involved.
swift test --filter HomeFeature
  • Identify cohesion and coupling to guide you in deciding what code goes where.

Final Thoughts

There are many nuances when dealing with modules, but sticking to keep things simple, avoiding premature abstractions and above all, being pragmatic about it will make working on a codebase as described in this tutorial a joy.

Hopefully you found this tutorial helpful and gave you a quick overview on a simple yet powerful way to approach app modularization.

Thanks for reading, you rock 🤘🏽!

Have some feedback or comments? Please leave a comment or email me.

References:

#the-end #first-blog-post