Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every two weeks.
Paid subscribers unlock Quick Hacks, my advanced tips series, and enjoy exclusive early access to my long-form articles.
No topic in mobile software engineering is more controversial, and more discussed, than architecture.
From MVC to MVVM, to VIPER to MV, to whatever the hell this is, everybody has an opinion, and everybody else is wrong. Discourse about screen-level architecture, however, is a misguided use of energy.
Architectural discussions should be about how your modules fit together, the trade-offs you’re making, and how your team is structured. Building great software is as much about human problems as it is about technology problems.
Today, we’ll journey through the lifecycle of a scaling app to understand the problems solved by modular architecture. We’ll discuss strengths and weaknesses of each approach, as well as the organisational structure for which they are best suited.
The Lifecycle of a Scaling App
You create your Android Studio or Xcode project, eyes gleaming and keen to drag your nascent MVP into the cold light of day. Perhaps you’re an indie dev, a newly-hired mobile lead, or the senior on a greenfield project.
You’re busy iterating and proving out the concept in these early, lightning-fast release cycles. No time to stop and think about architecture — you don’t even know if the project will survive the quarter.
You start to see promise with early users, revenue trickles in, and your feature set gets more complex. Build time ticks upwards, but that doesn’t stop you shipping.
With great success comes great responsibility. More engineers are assigned to your team as your app grows into a flagship product.
More lines of code, longer build times, blurred lines of responsibility. More requests from marketing and product managers. Tech debt inexorably accumulates with each release. Coordination overhead compounds with build time to strain the system you’ve built.
You pitch the business case for splitting the app up into modules. You convince the CTO, or your co-founders, or the project sponsor, that a temporary pause on feature development will accelerate your engineers and pay off quickly.
Your project is approved; and your inner circle of engineers begins to evaluate options. Which architecture works best for your business?
Core Module Architecture
After single-module apps, this is the simplest architecture. There are just two parts:
An
App
module which contains the top-level project and features.A
Core
module which contains everything else. This might include domain models, networking, persistence, authentication, utilities, analytics, and shared UI components.
If you’re unfamiliar with modular architecture, this approach helps you dip your toes into interface design and access control. Creating a Core module will also introduce you to the technical steps required to create a module and integrate it using your package manager or build system.
The killer, *ahem*, app for this architecture is multi-target apps. This is a common pattern for marketplace startups, such as Uber (which have separate apps for drivers and riders) or AirBnb (which has apps for both hosts and guests). The Core module can easily share logic and UI between the two targets. In my first foray into entrepreneurship, Patcher, we used this approach to build apps for both mechanics and car owners.
The Core module architecture is a natural fit for Kotlin Multiplatform projects, where UI code is native but business logic is written in a cross-platform Kotlin library.
Late-stage team structures often spin up a mobile platform team alongside the product teams, a team structure which leads to clear division of responsibilities and straightforward lines of communication when using the Core module architecture.
Splitting off core business logic and libraries into a separate module will quickly yield build-time benefits, since feature-level changes won’t need to re-compile the Core module. For larger projects, the Core module approach often serves as a transitionary step towards more complex forms of modularisation.
Layered Modular Architecture
This approach is a tiered architecture, analagous to the layered monolith pattern common in web servers. The core ideas of the Layered approach are to group common functionality together as layers, based on the data requirements of the layer above. This helps enforce a unidirectonal data flow through each layer.
The benefits of layered architecture are simplicity and consistency.
All networking code lives in the networking layer, all the data access work is done in the data layer, and all the features live together in the UI layer.
The number of modules, and the function of each layer, are strictly defined to avoid descending into dependency-graph hell. Top-level UI features are simple to develop since data is served up in a consistent manner.
The data layer interface enforces an abstraction: what, not how. The UI layer doesn’t usually know how the data is being retrieved, be it from the network, from local persistence, or from a cache in memory.
This is clear in Bev, my trusty open-sauce project: the top-level Bev
module imports a Repository
module — the data access layer. This in turn synthesises data from both the Database
and Networking
modules to find the most up-to-date data, as fast as possible.
This layered approach is a great option for medium-complexity projects and mid-sized teams, where engineers might specialise into building out data and networking, UI, or internal libraries. This was the architecture of choice for several banking and consumer electronics apps I’ve worked on while I worked in tech consulting.
The layered logic is another good fit for Kotlin Multiplatform or multi-target apps. Platform teams and product teams will be at home working on layered projects, but issues with build times might persist if your project is fairly top-heavy with UI-layer features.
Feature Modules Architecture
Feature modules are probably the most popular approach to modularisation in complex projects. With this approach, your top-level UI layer is split up into separate modules for each, well, feature (or each cluster of closely-related features).
This setup also utilises either a Core module or multiple modules for libraries managing UI, authentication, networking, and analytics.
The hard thing™ about Feature modules is dealing with shared entities. You will usually have several screens, services, or model objects used by multiple features in the app.
For example, you could handle a User
entity in several places in your app. Other times, you may have two separate but related features which share some screens (such as a customisable feature onboarding flow).
A common solution is an additional layer sitting between core libraries and features, which contains shared logic — in Carbn, this was the purpose of ModelKit
, which contained shared domain objects, APIs, and services.
Another approach is to create micro-modules of shared functionality imported by only the features which need it — but this is a surefire way to make your dependency graph incomprehensible. If there is a lot of shared logic, perhaps the feature modules should be merged into one. If the shared code is minimal, then it won’t hurt to place the code in a global shared module accessible by all features.
Feature modules are a natural fit for the product teams which tend to spin up as a successful app scales. Teams of engineers, product managers, and designers can take ownership over different parts of the app to concurrently support various business functions.
When you finish your migration to feature modules, you get a wow moment when you run your first incremental build. Changes to a feature module only require recompilation of that module itself, plus the (usually very thin) app layer at the top — making this the only approach so far where changing some feature code may cause less than 10% of your app to be re-compiled.
Stacked Architecture (experimental)
If the Core module is the first step on a journey, Layered architecture and Feature modules are both potential end-states for your mobile app architecture as your product matures.
But what if we could go further?
With the new package
access control modifier introduced in Swift 5.9, we can.
This new keyword allows you to cleanly modularise Swift packages into submodules. Defining a package class
in your submodule offers a public interface visible only to submodules bundled in the same Swift Package.
Stacked architecture is a hybrid approach of multiple layered feature modules — each potentially containing UI, data access, networking, persistence, and domain submodules making up each layer. Each layer imports submodules of the layer below, and can access any entity or interface denoted with the package
access control keyword. A final submodule sits at the top of the stack, defining the “interface contract” with the public
API surface for the package.
These layered feature stacks, similar to all the other architectures, each sit on a raft of shared core libraries. These libraries can, in turn, be themselves constructed of submodules. This is actually the original use case of the package
keyword — allowing SDK developers to modularise their library with Swift Package Manager without giving public
access to the consumers of the SDK.
The benefits and drawbacks are shared with the feature module approach. Build time goes way down, but you still need to be judicious about where you put shared logic. With this level of granularity, however, it’s far more comfortable to merge related data access layers into one, while supporting multiple top-level UI stacks.
You can still use stacked architecture in Android projects, and pre-Swift 5.9 projects, but your submodules will expose their public
interfaces to all modules which import them. Generally, you can combat this issue by being careful with your dependency graph (who imports what) and by carefully defining the public
interfaces in the “interface contract” modules.
If layered architecture works like a monolith, this architectural pattern is a little analagous to microservices: sexy, and probably over-engineering for all but the most complex projects.
A note on build times
The first thing you’ll notice once you’ve split an app into modules is your build times plummet, as does your blood pressure. But as your app scales to dozens of devs, tens of modules, and thousands of files, build time creeps back into view.
It’s inevitable.
Past a certain point, intelligent modularisation doesn’t always help — you will still occasionally need to update your base-layer utils module and trigger a full re-compilation!
Eventually, you may need to rely on open-source build tools like Bazel or Buck to solve this build-time bottleneck.
Conclusion
App architecture discourse should be less about screens, and more about how you arrange the pieces that make up your product. There is no cookie-cutter answer. The best architecture for your app depends on your team, the constraints you’re under, and the complexity of your project.
As your app gets more complex, build times and organisational structure gradually nudge you to split your app into modules.
You can choose to roll a Core module, split your app into layers, or create modules for each major feature. If you’re an absolute madman, or your project’s vast size makes it an attractive option, you might consider Stacked architecture (a name which I’ve coined and think sounds cool).
For the record, for my more complex indie projects, I’ll generally go with layered architecture. It just feels so nice and easy.