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...
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...
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:
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.
From the Xcode's File menu, select New
→ Package
Next, let's create a new Package using Xcode and name it "AppModules".
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.
The result should be, something like the following:
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
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 General
→ Frameworks, Libraries and Embedded Content
and add it to the list:
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.
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 Item
→ TodoItem
@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 General
→ Frameworks, Libraries and Embedded Content
and add it to the list:
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-generatedBundle.module
extension.
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.
types
are public and which ones are better off staying private or internal to the library.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.Swift Package Manager
, as we saw during this tutorial. The alternative is to do it manually by creating the appropriate folder structure.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
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