How to build a robust and scalable modular iOS app?

Recently, modularity has gained a lot of popularity in our field over the years. Architectures like TCA and VIPER pride themselves on their inherently modular design. Compared to a monolithic design, modularity offers various benefits: efficient work parallelization (especially in large teams), interchangeability, superior separation of concerns and responsibilities, and stricter enforcement of clean code.

On the other hand, it can also be more complex to set up, has a steeper learning curve, and requires discipline and thorough app design. As a result, it might slow down app development initially. Can this be compensated for at the finish line though? Are there apps that shouldn’t rely on a modular design? Let’s find out!

What does modularity in iOS apps look like in practice?

Essentially, modularity involves separating application code into distinct, isolated segments. Each segment is its own entity, potentially featuring a unique architecture, user interface, and even implementation in different programming languages. This allows every module to be managed by dedicated developer teams, having their own roadmap and delivery schedules. In short: modularity grants near-perfect separation of app features. 

Perfection - How to build a robust and scalable modular iOS app?

A suitable analogy might be the third-party libraries we incorporate into our apps, like networking and Keychain storage wrappers. Ideally, these libraries should be self-contained and address concrete problems. They should also include a comprehensive integration guide and a well-documented API.

It sounds ideal, but modules can’t be perfectly isolated, can they? They need to communicate with each other. That much is clear, but how should we facilitate that? We can instinctively realize that allowing every module to “see” and “talk” to all others may not be a good idea. That is unless you’re comfortable with circular dependencies and other signs of dependency hell

To avoid that, there are a couple of simple rules to follow:

  1. Modules, unlike people, are not equal.
    Modules typically display a hierarchy based on their function and complexity. Generally, there are three types of modules: Core, Services, and Features. We’ll explore these more in the next section.
  2. A module should only interact with modules situated lower in the hierarchy.
    As you move up the module hierarchy, the code becomes more abstract. Instead of directly interacting with the Keychain, User Defaults, or URLSession, you deal with abstractions like LocalStorage or NetworkingController. For instance, an authentication feature doesn’t need to understand the physical persistence of user data on the device, etc. A word of caution: It’s sometimes acceptable for modules at the same level of the hierarchy to interact with each other. For instance, the CommonUI module (outlined in the next section) might require some simple utilities located in the Common module, etc. However, be careful not to accidentally create circular dependencies!
  3. A module should offer a clear, comprehensive, and stable API.
    This can be thought of as a form of contract, outlining how a module communicates with the “outside world.” Primarily, it specifies the data needed for a module to function properly. The API should also clearly detail the services a module can provide, how to request these services, and how to receive feedback. For instance, a typical networking module might require basic configuration, including base URL, request timeout, and caching policy. It might expose a generic performRequest function, allowing you to provide a URLRequest, and an expected response type that conforms to the Codable protocol. Finally, and I think it goes without saying, the API should change as little as possible over time.
  4. A module should not expose its implementation details.
    Point 3 naturally leads to this concept. We should prevent the consumer of a module’s features from needing to understand its implementation details. Ideally, we would expose a factory that creates a feature implementation and returns it to the consumer, wrapped in an abstraction. In this scenario, the only code annotated with public or open access modifiers should be the abstraction itself and the data types used to configure and receive feedback from a module’s feature.

Sounds simple enough? That’s because it is. Almost all important concepts in software engineering are simple, but few of them are easy to implement…

Which rules are the most crucial? I’d suggest focusing on rules no. 1 and 3 initially. Try using a pen and paper to draw your modules in a vertical structure, with e.g. Core at the bottom and Features at the top. Define their responsibilities, how they should communicate, and so on. I’m old-fashioned in this regard but trust me, this can save you numerous hours later. This is especially vital when designing a module’s API. What data will it expose? Where should this data be located (within the Feature module or as a part of the Core modules)? Which implementations should the API expose, if any? Answering at least some of these questions is advised before you begin writing code…

Finally, please remember that Modularity does not have to be an “all or nothing” approach!
You don’t need to abandon your current architectural setup or rewrite your app to explore the concept of modularity. Instead, I recommend starting by extracting a single service or feature into a module. If you find that introducing modularity to your app is not feasible at the moment (due to e.g. legacy code or numerous dependencies), you can always revert to the original app setup.

What kind of app modules are there?

We briefly mentioned that there are different kinds of modules arranged in a hierarchy. Let’s take a look at this hierarchy now.

  1. Core Modules:
    A set of universal and reusable views, tools, extensions, helpers, data structures, abstractions, etc. They serve as the basic building blocks for all the modules located above it in the structure. Depending on the complexity of the application or number of the platforms it is developed on, we might need to divide the Commons into smaller components. The easiest strategy could be moving all reusable, UI-related code to the CommonUI module.
  2. Services:
    Advanced toolsets, specialized in performing specific functions in the app such as storage, networking, and biometric authentication. Very often depend on the Core modules, but they can also use other Services. For instance, a biometric authentication module might depend on the storage module to store or retrieve specific user flags from e.g. User Defaults. However, caution is necessary to avoid creating circular dependencies. For example, a situation where the authentication module depends on storage, which in turn depends on the Keychain abstraction defined in… the authentication module.
  3. Features:
    An all-in-one package containing both: the UI and the logic of a selected app feature like onboarding, authentication, etc. To function properly, a Feature module must depend on various Services and Core modules to avoid code duplication. However, it should not be aware of any other Feature modules, so the main app is responsible for switching between the features. Additionally, a Feature module should be self-reliant. It should configure itself based on the provided data, interact with the external world, and manage user input. This allows a Feature module to determine which view in the navigation flow to show, etc.

As you can see, it’s similar to DIY tools. The simpler the tool, the more applications it has. For instance, a screwdriver can be used for various purposes: driving a screw, prying open doors, scratching your back, etc.. In the context of modular iOS apps, the tools contained within the Core modules are like screwdrivers – multipurpose, simple, and useful.

Next, we have slightly more complex tools, like an electric drill. It offers greater configurability – by merely changing a bit, we can drive different screws or drill into various surfaces. However, with increased configurability comes susceptibility to change. While Core modules, being simpler, might not change much over time, Services most likely will. Therefore, they need to be built in a way that is change-friendly and ideally covered with extensive unit tests to prevent regressions.

Finally, we have the most complex tools – Feature modules. Their real-life equivalent could be automated deep-well drilling systems. Though technically still a drill, it’s highly specialized and typically fully automated. As an operator, you don’t even need to know how it’s built or how it works. Features of our apps operate on the similar principles – we set them up, let them work, and wait for the result.

And we don’t need to know how they are implemented!

How can I set it up?

There are several methods to set up a modular iOS app. After extensive trial and error, I believe the following solution provides an excellent balance of clarity, convenience, and flexibility. This approach was heavily inspired by Krzysztof Zabłocki’s Swifty Stack and has been adapted to support a white label application.

What does it offer?

  • Single package, multiple targets and products:
    Rather than creating a separate package for each module, we can consolidate them into a single package. In this setup, each module is defined as a package target, allowing for convenient arrangement in the dependency structure. Additionally, the feature modules are exposed as package products, making them “visible” to the main app.
  • Flexibility:
    A module can be easily replaced with another, as long as it accepts the same input and produces the same output. For instance, if a Client requests a custom-made video onboarding for their app, we can replace the “basic” onboarding feature with a newly implemented one. The rest of the application, as well as those of other Clients, would not notice the change. Perfect!
  • Freedom:
    You have the liberty to explore various architectures, design patterns, or frameworks within a particular module. As long as the implementation details remain encapsulated within a given module, you should be in the clear. Always wanted to try out TCA or any other shiny new framework? Now you have a chance!

Let’s examine some code, starting with the Package.swift file:

products: [ // (1)
    .singleTargetLibrary("LobbyFeature"),
    .singleTargetLibrary("OnboardingFeature"),
    ...
],
dependencies: [ // (2)
    .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.12.0"),
    .package(url: "https://github.com/realm/SwiftLint", from: "0.54.0"),
    ...
],

As illustrated, only the app features are presented as products in our Swift Package Manager (SPM) package (1). This approach is appropriate as the main app focuses on resolving high-level challenges (e.g., user authentication) rather than implementation specifics (e.g., the location of a decryption key in the app storage).

Additionally, we establish “global” dependencies that can be shared among all package targets (2). Pertaining to this, the targets section outlines all app modules: products (3), internal modules (4), and test targets, including their helpers (5).

targets: [
    // (3)
    .target(
        name: "OnboardingFeature",
        dependencies: ...
    ),

    ...
    
    // (4)
    .target(
        name: "Common",
        dependencies: [
            .product(name: "KeychainAccess", package: "KeychainAccess"),
            ...
        ]
    ),
    
    ...
    
    // (5)
    .testTarget(
        name: "CommonTests",
        dependencies: ... + ["Common"]
    ),
    .testTarget(
        name: "OnboardingFeatureTests",
        dependencies: ... + ["OnboardingFeature"]
    ),
]

As you can see, we can neatly and clearly define a complete network of modules and their relationships within our app. Next, let’s try to make use of these modules in our app.

The first step is to create a workspace and then simply drag and drop a folder containing your modules into it (1):

Next, add all the desired features for your app as Frameworks (2). There’s no need to add any Core or Services modules, as they will become automatically available to the app once it imports one of the feature modules.

Arguably, a significant advantage of a well-designed modular app is the ability to develop a specific feature independently. To facilitate this, we can create Showcase Apps. These are straightforward containers that initialize and display a given feature, typically using fake or mocked data. To create a Showcase App, we need to follow these steps:

  1. Create a new application target. Assign it a unique name and app icon. You’ll thank me later 😉
  2. Embed the feature you want to highlight in the app. Navigate to target settings. In the Frameworks, Libraries, and Embedded Content section, select the feature you wish to include.
  3. Embed shared assets. If the feature uses assets from the main app, ensure the assets bundle is also shared with the showcase app’s target.
  4. Initialize and display your feature. If you’ve implemented a factory for the feature, you can now use it (1). However, make sure to set up all dependencies (2) before launching the feature (3)! Feel free to use mocked data if necessary.

A sample starting point of a Showcase App might look like this:

@main
struct SignUpFeatureShowcaseApp: App {
    let navigtor: UINavigationController
    let flow: FlowCoordinator

    init() {
        navigtor = UINavigationController()
        flow = SignUpFeatureFactory.makeSignUpFlowCoordinator(
	          navigator: navigtor, 
	          parentFlow: nil
	      ) // (1)
        initializeDependencies() // (2)
    }

    var body: some Scene {
        WindowGroup {
            navigtor
                .swiftUIView
                .onAppear {
                    let router: NavigationRouter = resolve()
                    router.start(initialFlow: flow, animated: false) // (3)
                }
        }
    }
}

In this application, I explored UIKit navigation using the Flow Coordinator concept. If you opt for SwiftUI navigation, the feature initialization process will be much simpler.

How can we implement communication between different app features?

Up to this point, we’ve devoted significant attention to dividing our app into distinct features. However, these features hardly provide any value to the user independently.

Do you recall our discussion about how an ideal feature module should only take initial configuration and notify the consumer upon completion of its task? Arguably, the best way to signal that completion is to ask a navigation component to switch to a different feature.

In this example, we’ll use explicit navigation with UIKit, but a similar approach can be implemented in SwiftUI as well. The concept involves passing a Router object into a feature:

public protocol NavigationRouter: AnyObject {
    var currentFlow: FlowCoordinator? { get }
    func show(route: any Route, withData: AnyHashable?, introspective: Bool) // (1)
    func `switch`(toRoute route: any Route, withData: AnyHashable?) // (2)
    func navigateBack(animated: Bool)
    ...
}

If you’re interested in understanding the Router component in greater detail, refer to my post about SwiftUI navigation. For the moment, it’s enough to know that it facilitates initiating (1) or transitioning to (2) a specific app navigation flow, represented by a Route:

public protocol Route: Equatable {
    var name: String { get }
    var isFlow: Bool { get }
    var popupPresentationStyle: PopupPresentationStyle { get }
}

Simply call router.switch(...) and pass the Route associated with the feature you want to display. That’s all it takes.

Alternatively, consider incorporating the navigation stack state into the application state. Architectures that support unidirectional data flow, such as TCA, are ideal for this approach. TCA ensures that the app state is updated only through executing an action, triggered by the user or external events, resulting in the UI being redrawn.

The simplest method to link the navigation stack with the app state involves adding a destination property to a given view’s scoped state. This property could represent the next UI element to display, such as a popup or details view:

MeetingsList(
  model: MeetingsListModel(
    destination: MeetingsListModel.Destination
  )
)

In TCA every app component (starting with features, down to single views) operates only on a chunk of global app state. So, if we append the chunk with destination property, the view would have to present only its direct “children”. TCA offers convenient and easy-to-use view modifiers for such an occasion:

import RecordMeeting

struct MeetingsList: View {
    let model: MeetingsListModel
    ...

    var body: some View {
        ...
    }
    .navigationDestination(
      unwrapping: self.$model.destination,
      case: /MeetingListModel.Destination.recordMeeting // (1)
    ) { $meeting in
      RecordMeetingFeatureFactory.makeInitialView(with: $meeting, dependencies: ...) // (2)
    }
    .alert(
      unwrapping: self.$model.destination,
      case: /MeetingListModel.Destination.alert // (3)
    ) { action in
      MeetingAlertView(action: action)
    }
    
    ...
        
}

In this specific case, we utilize CasePaths to select a particular navigation destination (1) we wish to handle. We then engage the RecordMeetingFeatureFactory to generate an initial view of the meeting recording navigation flow (2). This is because the Record Meeting is a separate feature from the Meetings, and it is housed in its own module.

Naturally, to display a different view within the Meetings feature, we can associate it with another Destination enum case and attach another navigationDestination view modifier. Neat and simple!

Additionally, if there’s a need to display a more generic view like an alert, TCA provides another convenient view modifier for this purpose.

The main concern with this approach is causing circular dependencies. It’s surprisingly easy to have one feature module trigger another, which in turn tries to launch… its “parent”. Fortunately, modular dependencies are evaluated at compile time, so we should receive immediate feedback if we accidentally “close the circle”.

Of course, there are many other methods to communicate between different feature modules. However, most of them either use a central “navigation dispatch” component (Approach No. 1), or adopt a drill-down navigation system, where each view is responsible for creating the next view(s) in the flow (Approach No. 2). Feel free to choose the approach that best fits your app.

And just like that, we’ve successfully tackled the most complex challenge in modular applications – transitioning between features. Well done!

When is a good moment to start thinking about making your app modular?

Paraphrasing the old Chinese proverb: “The best time to make your app modular was when you started implementing it. The next best time is today.”

Jokes aside, our applications often exceed even the highest expectations in terms of longevity. I’ve been involved with apps that have been in active development for over 8 years! As you might imagine, working on such an app without dividing it into independent parts is quite challenging. Before the advent of the SPM, we usually created SDKs distributed through CocoaPods. Later, we transitioned to XCFrameworks, and now we utilize SPM modules.

So when should be consider transitioning to modular app architecture:

  • Whenever we start a non-trivial project.
    That is unless we are developing an application for a startup where speed is paramount. Review the project roadmap and backlog, and consult with stakeholders. If their plans span more than six months, I suggest at least considering a modular design.
  • When you have a big team.
    In certain situations, even three or more developers can seem like a crowd. This feeling can intensify when you consider the number of clients for whom the app is being developed, their specific features, branding, and release schedules. Sometimes, working on a feature in isolation isn’t merely a luxury – it’s a necessity.
  • When you want to share your code between the platforms.
    With the help of technologies like KMM, it’s now easier to share the data layer, networking clients, encryption tools, etc., between iOS and Android. Naturally, you’ll need to design your application’s architecture in a way that supports it.
  • When you support multiple devices.
    Sometimes, you may want features in your app to look and behave differently across various devices, such as iPhones, iPads, and MacBooks. However, maintaining these features without extracting them into dedicated modules can be a frustrating task. Just imagine connecting a device, recompiling the entire application, and drilling down to the feature every time you want to verify if your changes have worked as intended. A nightmare…
  • When doing a white-label app.
    White-label applications, by definition, use a single codebase to cater to multiple clients, each displaying their unique branding. Typically, the differences between these branded apps are subtle. However, at times, a client may request an additional feature or require modifications to one of the existing features that simply aren’t compatible. In such instances, we can create a dedicated module for that specific client, allowing the rest of the white-label “fleet” to use the “default” one. Neat and simple.

And are there cases where modular design would be over-engineering?

  • A POC or other short-lived app.
    The time factor is always crucial in software engineering. Over-engineering, or investing time and effort into a short-lived project, is typically a waste. An important exception could be the scale on which you produce such experimental apps. Creating a modular blueprint or template could be quite beneficial, as it allows you to reuse existing code and speed up project setup.
  • When your team is less experienced.
    As demonstrated above, modularity is a simple concept. It merely requires some practice and an understanding of the broader scope of software architecture. These are aspects you can’t simply “buy“; they need to be personally experienced. So, if your team has not yet done so, brace yourselves for the challenging task of getting them on board with the concept of modularity.
  • End-of-life, legacy or rarely modified app.
    If it works, don’t touch it.” right? But seriously, pragmatism is a key trait of a successful developer. In my opinion, if an app or library rarely requires modification, investing substantial time and effort just to enhance the code aesthetics is far from pragmatic. This also applies to the code that are no longer supported or considered legacy.

Naturally, the list above is far from exhaustive. The age-old “be pragmatic” rule always applies. It’s typically easy to identify projects that will be developed for a longer time or by a larger team, but it shouldn’t be the only factor considered. If you’re in a software house, it’s wise to extract and reuse common utilities (like networking and storage) across projects. This approach allows even the smallest or experimental projects to benefit from faster setup and quicker onboarding of new developers. However, everything has a cost. In this case, it is maintenance. Questions arise such as who will keep the modules up-to-date, who will make decisions about adding new features, etc.

My advice? Start small. Begin by extracting a set of commonly used tools or a feature and observe the results. Modularity is not an “all or nothing” approach!

Summary

And just like that, we’ve reached the end! Thanks for sticking with me!

Through this post, I hoped to demonstrate that modularity isn’t overly complex. It primarily involves a few key rules, such as avoiding communication with modules higher in the hierarchy and hiding implementation details. Moreover, introducing modularity is often not over-engineering, but a necessity. Especially when working in a larger team or on a highly configurable or white-label application.

In addition, modularity offers several benefits: faster app development, full control over the development environment, and extensive code reusability across projects.

Before you jump in to refactor your entire app stack to be modular, consider how this decision might impact the business side of the project. As developers, we need to be pragmatic. Personally, before making an important technical decision, I like to ask: “How can this fail?”. Typically, your team and the business will identify multiple valid concerns. Does this mean you shouldn’t introduce modularity into your app? In most cases, absolutely not! But you should address these concerns as early as possible. Good luck!

Don’t miss any new blogposts!

    By subscribing, you agree with our privacy policy and our terms and conditions.

    Disclaimer: All memes used in the post were made for fun and educational purposes only 😎
    They were not meant to offend anyone 🙌
    All copyrights belong to their respective owners🧐
    The thumbnail was generated with text2image ❤️ 
    Some images were generated using Leonardo AI ❤️
    Audio version generated with TTS Maker ❤️