Modern Architecture for iOS apps

Anton Karpov
18 min readJun 16, 2024

--

Architecture is the DNA of an application

With many aspects of iOS development, the app’s architecture is one of the most important and interesting topics. The architecture is not just a set of rules, it’s a property of the system that defines not only how the app works, but also grows and evaluates. It affects every part of the app as well as it has a direct impact on the development process and the quality of the final product.

Gone are the days when monolithic codebases and tightly coupled components were the norm. The mobile-first approach has won, and today mobile applications are a serious and highly competitive business. As a result, it leverages sophisticated architectural patterns to create a more modular, testable, and resilient codebase to satisfy the high requirements of the customers.

In this article, I share my experience of how good architecture should look like and why, what benefits it brings, and how to build it using a modern technical stack to get the maximum out of it.

Disclaimer

  • I do not claim that this approach is the only correct one, in software development there are usually many ways to solve the problem, so, if another solution works well for you and you are happy, it’s fine.
  • This approach is best suited for relatively large apps developed by a team or even several teams of developers.
  • This article focuses on iOS development, but at first glance, it could be adopted for Android development as well without much effort.

What is good architecture?

Let’s define what good architecture for the app actually means. There are many slightly different opinions about it, but in common it usually focuses on the following set of requirements:

  • easy to read and understand
  • easy to reuse, extend, and scale
  • easy to test and tests are cheap
  • easy to debug, fix, and maintain
  • ease to onboard new developers

There are many easy, so the solution should be laconic with minimum boilerplate code and not be overengineered, otherwise it is hard to call it an easy one. Simultaneously, it should be very efficient in terms of the development process. And I’d add one more important quality — solidity, architecture should be rigid enough to resist being used inappropriately. For example, MVC is extremely easy to use incorrectly, and that is why we have the anti-pattern “Massive ViewController” (instead of “Model-View-Controller”). Good architecture has to protect itself and the principles it is based on.

On the other hand, the architecture should be quite flexible and not be too exhaustive. Software development is not pure math abstraction, apps are developed by real people (who can make mistakes), and for real people (who don’t care about architecture at all), apps are used on real hardware (which has limitations), often with 3rd party frameworks (which sometimes are not under our control). It should be relatively easy to adapt the app to the external environment and conditions. In other words, good architecture should be as simple as possible and as complicated as necessary.

Diving deeper into Architecture

Architecture is a kind of tradeoff, and there are consequences. Such architectural patterns like MVC, MVP, and MVVM are very simple and it’s easy to start to use them just after short learning. But the other side of this simplicity is these patterns describe a common approach of segregation codebase only and could be understood and implemented very differently. As a result, each team has its own rules on how they try to keep a separation between layers, and these rules could usually be (in practice this means most likely will be) broken quite easily. A typical problem of these patterns is a massive business logic layer, which is hard to test properly because, without clear boundaries between layers, it tends to use service layers and other entities directly without dependency injection.

There are some advanced architectural patterns also, like RIBs and VIPER, but both are technically outdated, it was developed to work with UIKit and doesn’t work well with SwiftUI. Additionally, VIPER has a lot of boilerplate code without much benefits. RIBs is still a good architecture if you work only with UIKit and have no plans to use SwiftUI, but it seems it would be hard in practice — some new cool iOS features can be implemented only with SwiftUI.

The Composable Architecture (TCA) is probably the only architecture designed specifically to work with SwiftUI and it’s done quite well. The main downside is it’s supposed to use one scope for the whole application, so it’s hard to isolate one piece of the app from another, which is important if the app is more than just MVP or developed by a team of developers. Additionally, in some places, the implementation of TCA looks quite massive (many custom built-in tools) and over-engineering (boilerplate code). Also, many people note a steep learning curve and performance issues. But if you have to use SwiftUI and have no clue what kind of architecture you need, TCA is a good starting point.

Modern software is a Teamwork

All modern competitive mobile apps are developed by teams, in some cases by dozens of teams, not individuals. Of course, there are many indie developers on the market, but it usually isn’t about business apps.

Teamwork means the app must allow parallel development and must do it easily. It also means that each part of the app is developed by many developers. It leads that the architecture must have strong rules about where each specific part of code must be located and how implemented. Also, the architecture must have a high level of consistency, otherwise, one developer will not be able to continue another developer’s work. Taking it all into account, the architecture must provide an exceptional level of testability. In the case of intensive development, the architecture should also provide a good level of horizontal scalability without increasing the cognitive load for reading and understanding the codebase.

Two levels of architectures

Usually, nobody speaks about it, but a well-structured app with a high level of modularity has two levels of architecture. The first one is a kind of building blocks, something like MVC, MVP, MVVM, etc — architecture that is responsible for building some piece of the app, typically one particular screen or a part of this (in case of a complex screen). The second one is responsible for composing all building blocks together and providing all necessary dependencies for these, including routing between different parts of the app.

For any mobile app that is medium-sized or larger, both architectures are needed to achieve all the requirements that were mentioned for a good app architecture above. For example, without this second architecture, it would be impossible to set clear and strong boundaries for each part of the app, and without it, such things as horizontal scalability, testability, debugging, and maintenance would be extremely hard to achieve. One of the best candidates for this role is the “Composition Root” pattern and I will show why later in the article.

The Elm architecture

The Elm architecture is well known in web development, but rarely considered for something else, including mobile development. Obviously, it’s not possible to use an architecture that was developed for the web in mobile development as it is. However, it is possible to take out the best ideas, extend them according to mobile specifics, and implement a working prototype.

What is the Elm architecture in a nutshell?

Any Elm program is always split into the following:

  • Model, that keeps the current state of the app
  • View, which converts the Model to the User Interface (UI)
  • Message, which is an event that comes from the UI to Update
  • Update, which produces a new Model, based on a Message
Pure Elm

Based on this, there are a few very important consequences:

  • Model, it’s usually named State in mobile development, — the single source of truth that keeps everything needed to represent the current state of the app or the current screen or whatever
  • View, in mobile development, it’s rather View Builder, which is a pure function, that has the State as an input and the User Interface as the output
  • Messages or Events is a limited set, that a user (or another part of the app) can send, and a keyword here is limited, so it’s just impossible that something unpredictable can come
  • Update function or Reducer is a pure function, that has a current state and new event as an input and a new state as output; this is the only place where a state can be changed

In practice, any part of the app works in collaboration with others, not in a vacuum, and it also has some dependencies, all this together — a kind of Environment. Additionally, each event can produce a few side effects that will change the state. In fact, a new state is just a result of applying the side effects of an event to the current state. And we need some mechanism to get any events from the User interface, an abstraction that will produce the user’s events — “UI Feedback”.

The Elm in Mobile Development

For example, if a user does a Pull-to-Refresh gesture, the event “User did request update” comes to a reducer function. The Reducer will produce a first side effect - “Loading did start” and make a network API call to get new data. Then, when the API call is finished, Reducer produces the second side effect - “Loading did stop”. The third side-effect will depend on the result of the API call, if it is a success then Reducer produces the “New data did come” effect, otherwise, it will be the “Error did occur” effect. Each effect will be applied to the current State and after each state’s changes, View Builder generates an updated actual UI for the user.

A practical example of the Elm

Let’s sum up what we have for now:

  • State — a single source of truth
  • Event — any external (for Reducer) action (typically user input or system event like “App Did Become Inactive”)
  • Effect — internal action, a reason to change a State
  • Environment — all needed dependencies that help to convert Event to Effect
  • UI-Feedback — a function that produces Events
  • Reducer — a finite-state machine that contains two pure functions: “Events handler” that converts Event to Effects by Environment and the current State, and ”Effect handler” that applies a side Effect to the current State to produce a new State

It could look a bit complicated at the very first glance, but it’s very easy to use in practice because we have a limited set of particular entities, and every one of them is responsible only for one specific task. There is a unidirectional flow, so it’s easy to follow it and understand what is going on. Most of the logic is pure functions, one single source of truth, and business and presentation logic are strongly separated. Taking this all into account, this approach has a fantastic level of testability.

Implementation

There is not a canonical implementation of Elm architecture for iOS, but there are at least two very good attempts to do it:

  • RxFeedback: technically outdated, no separation to event/effect, no SwiftUI support
  • The Composable Architecture (TCA): uses one scope, no clear boundaries between different parts of the app, no separation to event/effect

Why separation of event/effect is important? — Just for the protection of the business logic, if the system doesn’t have this separation, it means the system should extend events (which are public) by internal effects (which are private). It leads to the possibility of sending internal event-effect from outside and breaking the logic.

Why clear boundaries are important? — The possibility of splitting the app into small independent pieces is crucial. Segregation of a whole codebase into separated packages and targets leads to low coupling and limited scope of each part of the functionality, which is necessary for a good level of testability, easier debugging, and maintenance in general.

Technical stack

iOS development from a technical point of view does not stand still and is constantly evolving, and looking back over recent years, we can distinguish several stages:

  • UIKit + GCD (iOS 9–12): a technical stack of iOS development was mainly presented by Grand Central Dispatch (GCD)and Operation Queue which were working together with UIKit (some used Storyboards, but more advanced developers did UI programmatically).
  • UIKit + RxSwift (iOS 10–13): a rise of reactive programming: MVVM and UI-binding weren’t something new for me because I used them before iOS development in Microsoft Windows Phone pet projects, but for most mobile developers it was a kind of a mind-changing revolution.
  • UIKit + Combine (iOS 13–16): Apple finally presented a native reactive framework — Combine, not so powerful in some areas like RxSwift, but good enough for daily routines, and it is still actively used in most apps.
  • SwiftUI + Async/Await (iOS 15+): the beginning of a new era of declarative UI with native SwiftUI, which wasn’t stable enough initially, but it becomes more stable and more attractive with every new iOS release. The Async/Await is a most wanted language feature for many years is finally released and its implementation is great and powerful. Despite it is still improving, it is definitely ready for production usage and will be the technical standard at least for the next few years.

If the application is under good supervision and the required part of the refactoring is performed on an ongoing basis, these stages represent incremental transitions from one to the other. If your app has a minimum deployment version of iOS 16, but you are still actively using RxSwift, it is highly likely that you are doing something wrong and not paying attention to the technical side of the app.

Nowadays, not many popular apps support iOS versions below iOS 13, so we can basically already forget about manual management of threads with GCD (excepting some rare cases where it’s still needed) as well as RxSwift and its competitors like ReactiveX, etc. Today’s mainstream is UIKit + Combine, but tomorrow’s is SwiftUI + Async/Await.

Source code

As we all know — “Talk is cheap. Show me the code.”; based on the Elm ideas and keeping in mind SOLID and Clean Architecture principles I implemented my vision of the Elm architecture with the most popular technical stack today — Combine + UIKit. The implementation can be found here: https://github.com/angryscorp/TEA-Combine.

You can easily evaluate this implementation by yourself but I would like to highlight a few moments.

All entities are structures and enums, with no classes in the business logic layer, which means better performance without potential inheritance issues.

public struct SceneEnvironment {
let getRandomNumber: () -> AnyPublisher<Int, Never>
let selectSomething: () -> AnyPublisher<Int?, Never>
}

public struct SceneState: Equatable {
var something: Int? = nil
var currentValue = 0
var counter = 0
}

public enum SceneEvent {
case userDidRequestIncrease
case userDidRequestDecrease
case userDidRequestSomething
}

public enum SceneEffect {
case resultDidReceive(Int)
case somethingDidReceive(Int?)
}

The assembly function is a pure function without any side effects, it’s easily visible everything that the current scene depends on.

public struct MainScene {

public static func create(
setRootVC: (UIViewController) -> Void,
selectSomething: @escaping (UIViewController) -> AnyPublisher<Int?, Never>
) {
let vc = ViewController()

let env = SceneEnvironment(
getRandomNumber: { Just(Int.random(in: 1...9)).eraseToAnyPublisher() },
selectSomething: { selectSomething(vc) }
)

TEA.start(
initialState: SceneState(),
environment: env,
feedback: vc.bind,
transform: SceneReducer.transform,
apply: SceneReducer.apply
).store(in: &vc.subscriptions)

setRootVC(vc)
}
}

The Reducer’s functions are pure as well, and since Event and Effect are just enums, we can effectively use the switch operator here and make sure that any changes in Event or Effect will not escape our attention (the source code will simply stop compiling).

public enum SceneReducer {

public static func transform(
state: SceneState,
event: SceneEvent,
env: SceneEnvironment
) -> AnyPublisher<SceneEffect, Never> {
switch event {
case .userDidRequestDecrease:
return env.getRandomNumber()
.map { SceneEffect.resultDidReceive(-$0) }
.eraseToAnyPublisher()

case .userDidRequestIncrease:
return env.getRandomNumber()
.map { SceneEffect.resultDidReceive($0) }
.eraseToAnyPublisher()

case .userDidRequestSomething:
return env.selectSomething()
.map(SceneEffect.somethingDidReceive)
.eraseToAnyPublisher()
}
}

public static func apply(
state: SceneState,
effect: SceneEffect
) -> SceneState {
switch effect {
case let .resultDidReceive(value):
return .init(
something: state.something,
currentValue: state.currentValue + value,
counter: state.counter + 1
)

case let .somethingDidReceive(value):
return .init(
something: value,
currentValue: state.currentValue,
counter: state.counter
)
}
}
}

All that connects the User Interface and the business logic is just one function, so the UI part can be easily replaced with another one.

func bind(state: AnyPublisher<SceneState, Never>) -> AnyPublisher<SceneEvent, Never> {
state
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [unowned self] state in
self.somethingLabel.text = "Something: " + (state.something.map {"\($0)"} ?? "nil")
self.counterLabel.text = "Counter: \(state.counter)"
self.currentValueLabel.text = "Current value: \(state.currentValue)"

})
.store(in: &subscriptions)

return eventSubject.eraseToAnyPublisher()
}

The Environment includes all dependencies and routing, from the scene point of view, there is basically no difference between service dependency and getting value from another screen — both dependencies could be presented by pure functions.

public struct SceneEnvironment {
let getRandomNumber: () -> AnyPublisher<Int, Never>
let selectSomething: () -> AnyPublisher<Int?, Never>
}

Further development

But time does not stand still and it’s time to move from Combine to Async/Await and SwiftUI. Here is a proof of concept that the Elm ideas work well here as well: https://github.com/angryscorp/TEA-AsyncAwait.

Everything looks similar and is already familiar, only the reactive approach has been replaced with the new Swift Modern Concurrency which looks even more laconic and clearer.

public enum ExampleReducer {

public static func transform(
state: ExampleState,
event: ExampleEvent,
env: ExampleEnvironment
) async -> ExampleEffect {
switch event {
case .increase:
let newValue = await env.increment(state.currentValue)
return .newValue(newValue)
case .decrease:
let newValue = await env.decrement(state.currentValue)
return .newValue(newValue)
}
}

public static func apply(
state: inout ExampleState,
effect: ExampleEffect
) async -> ExampleState {
switch effect {
case .newValue(let newValue):
state.currentValue = newValue
return state
}
}
}

All the benefits are the same:

  • Pure functions for Reducer
  • Views without business logic
  • State is a single source of truth
  • Events and Effects are just enums
  • Proper dependencies injection
  • Business logic and UI are strongly separated
  • Clear boundaries for every piece of the app
  • Everything is easy to test, debug, and maintain
  • Strong and easy-to-follow structure

These implementations are more like examples and proof that the Elm architecture works excellently for iOS development as well. Of course, you can use it as it is, but you can also take it as a draft and improve it or implement your own solution based on these principles.

Testing

I’ve already mentioned testability a little bit, but I’d like to re-emphasize the importance of tests one more time. A fairly common problem in mobile development is the unpopularity of writing tests. And the real reason usually lies in poor application architecture. Poor architecture means the functionality is hard to test. Hard to test means it needs a lot of time to test properly. A lot of time means it’s very expensive. Very expensive means it will not be implemented, because, from a business point of view, there is no strong reason to do it — everything seems to be working already correctly.

Why nobody writes tests

So, the main outcome of this is writing tests must be cheap. Ideally, tests can be implemented in a few lines of code. And with the Elm architecture, it’s possible. Let me show how, for example, business logic can be tested.

That is what a typical unit test in the Elm architecture looks like — it’s literally three lines of code:

  • what Event(s) comes
  • Initial State
  • Expected State
    func test_userDidRequestIncrease() {
test(
event: .userDidRequestIncrease,
initialState: .init(currentValue: 2, counter: 2),
expectedState: .init(currentValue: 7, counter: 3)
)
}

And that is all, by testing different combinations of Event and State you can test all business logic and make sure that everything works correctly. It’s also possible to test a chain of Effects for more comprehensive testing, including some corner cases.

    func test_userDidRequestDoubleIncreaseAndDecrease() {
test(
events: [.userDidRequestIncrease, .userDidRequestIncrease, .userDidRequestDecrease],
initialState: .init(currentValue: 23, counter: 2),
expectedState: .init(currentValue: 28, counter: 5)
)
}

The full implementation of XCTestCase for the Elm architecture can be found here.

Besides unit tests, there are snapshot tests are exist as well. Despite many benefits, snapshot tests are not very popular in mobile development, and the reasons are usually the same. To implement snapshot tests it’s necessary to initiate View which could be not easy with poor architecture and without proper dependency injection mechanism.

But with the Elm, it’s very easy. Thanks to the strong layer separation View is super simple and depends basically only on the State (which often has an initial or a default value). So, to make snapshot tests it’s necessary to write a few lines of code, the same as with unit tests.

Snapshot tests are very useful and important for two reasons:

  • protect the UI layer from regression, any changes and refactoring could be done without fear of breaking something
  • keeping in mind the necessity of testing makes developers implement the view in the right way (simple, and without any logic), which increases the level of maintenance and debugging of the system

Composition Root

The Elm architecture perfectly solves issues on the scene level (a scene usually means one screen), but besides this, the app could have other problems and two of them are the most important:

  • no proper dependencies injection
  • no modularization

The absence of proper dependencies injection leads to the following:

  • resolving dependencies inside the scene (Service-Locator anti-pattern)
  • using dependencies directly without injection, which makes it impossible to test the scene

The lack of modularization (which means the app is a monolith and has no separation into modules) leads to the following:

  • the single global scope which makes maintenance and debugging of the app or any of its parts much harder
  • any small change can affect a very different part of the app and without strong regression tests, it could go unnoticed.

All these problems can be successfully resolved by the “Composition Root” pattern and splitting the whole code base into separate independent modules (packages and targets in Swift Package Manager (SPM) terminology).

Composition Root is a unique location in an application where modules are composed together.

Composition Root

Let me show for greater clarity what the “Composition Root” pattern will look like in the real application.

Composition Root in the real app

Every component is a separate target in Swift Package, and ideally, a few related components/targets (they are together are a user flow, like “Onboarding” flow for example) are located in separate Swift packages.

You can find a simple example of “Composition Root” implementation here.

Composition Root and Modularization together have the following unbeatable pros:

  • Independent parts of the app are located in different modules
  • Clear and strong boundaries between modules
  • Changes in one module cannot affect other modules
  • Lightweight executable layer that can also be easily optimized
  • Significantly improving of stability, maintenance, and debugging of the app in general and any of its parts
  • Static compile-time guarantees that all dependencies are resolving safe
  • Excluding unintentional usage or interference with other modules
  • Low coupling between all layers and parts of the app
  • Faster compilation process by caching modules that haven’t changed

How to Stop Worrying and Start Living

If you think that all this is maybe interesting, but quite far from the current state of your app to try it in real life — you are mistaken. Depending on the app’s state your journey to the Elm ideas and Clear Architecture principles could be longer or shorter but it is definitely possible and highly likely should be done and the faster the better.

You don’t actually need any special refactoring tasks for this. Just create a Core package with a Domain target (even empty) and start to fill it step-by-step as a part of your everyday feature tasks. Gonna implement a new super cool feature — create a new Swift package for this and write all code into this package (one screen — one target). Need a new Domain entity specifically for this feature — create a FeatureDomain target and put it into this. Have to reuse some logic in a few screens — create a new UseCase target and move it to this. Ready to try Composition Root — it’s not necessary to rewrite the whole app in one step — just find an appropriate place for the entry point and everything after this entry point automatically gets all the pros of Modularity and Composition Root.

I want to repeat — you don’t need a special task to start improving your app, just do it as a part of your daily routine.

And all of it is not just a theoretical approach or fantasy — I did it a few times: took a legacy problem app with a lot of issues and anti-patterns and successfully converted it into a top-notch robust and efficient solution.

Conclusion

The ideas behind the Elm architecture and Composition Root pattern are nothing new, but I see them underused in mobile development despite being extremely good. This article aims to share my knowledge and experience to fill the gap in understanding how these principles could be applied in practice. I use these approaches in my daily work and I am extremely satisfied with it as well as my team.

Although I have provided a working implementation that has been proven in real production development, it is always better to tailor the solution and ideas to your specific situation and your technical stack. You can also consider it as a draft to be evolute to make the ideas lie in its foundation even stronger.

I do recommend and encourage you to dive a bit deeper into the Architecture topic and try out the ideas from this article in your projects. See for yourself that it works well and can bring your app to the next level, and you’ll probably wonder how you ever lived without it.

--

--