Pieces of a scalable iOS app architecture
The perfect iOS app architecture
MVVM, Clean Swift, VIPER? — Just invent your own!
Anyone who has already implemented some iOS apps will stumble upon the Massive-View-Controller (MVC) problem and will at some point surely ask:
What’s the perfect architecture for iOS apps?
Warning: It’s a trap and a very deep hole! 😜
Of course, nothing is “perfect” and there is certainly not “the” perfect architecture, not even “a” perfect architecture. Heck, we’re not even talking about “architecture” at all! 🤨
So, even if it’s not correct, let’s stick to the usual term “architecture”. Which one is now the best of them?
Of course, it’s always your own! 😁
If you develop a single-screen hobby app on your own, you usually need a different architecture than a decentralized 10-man team working on a 1M $ app.
The architecture has to fit the team and the project!
That’s exactly what I did, and now I want to share what I have learned in my “Pieces of a scalable iOS app architecture” series of blog posts. I hope there are some developers out there who will find this interesting and can take some inspiration for their own projects.
Of course, I always strive to improve myself and my architecture and look forward to appropriately constructive criticism. 🤗
Aim of an Architecture
An architecture should usually:
- Fix the Massive-View-Controller issue
- Increase testability
- Improve maintainability
- Scale with team size
The solution is actually quite simple: Modularization 😎
Modularization means separating a program’s functionality into independent, interchangeable units, each responsible for only one aspect of the program.
For example, in Swift, you can outsource code into frameworks, such as a server module, which is only responsible for communicating with a server. In the app, server requests are no longer performed but only via the server module. This server module can be replaced, rewritten and tested separately from the rest of the app.
But, you do not have to outsource everything into frameworks. It’s often enough if you write specialized classes that are only responsible for one aspect and are referenced via protocols. Again, the code is bundled, does only one thing and can be easily replaced thanks to the protocol.
Divided into modules, one usually quickly fixes the Massive-View-Controller problem because code that does not actually belong in a ViewController is outsourced into modules. The ViewController then shrinks automatically. 😉
Interchangeability increases testability because it makes it easy to mock dependencies and test modules separately. And if every member of the team only works on their independent code module that also has a highly standardized interface, merge conflicts should be very rare, so it scales well with the team size.
All the common architectures are usually based on modularization. Special classes such as controllers, models or workers, however you may call them, separate code and become more testable via protocols. How exactly this has to be separated is dictated by the respective design pattern.
The established architectures already solve the listed problems. Hence, nothing is wrong with working with an established architecture. Bohdan Orlov provides a good insight into established architectures in his article “iOS Architecture Patterns”. And if you like it more complex, you can look at Uber’s RIBs. 🧐
Personally, I don’t particularly like a few aspects of the established architectures.
The MVVM‘s binding is very interesting, but the separation does not go far enough.
With Clean Swift, I find the unidirectional data flow nice, but most of the time the presenter atrophies because it usually does nothing except pass data.
VIPER is still one of my favorites and apparently it’s not just me, but does everything really have to go through the presenter?
And Uber’s RIBs actually goes too far for me with its complexity. 😑
Of course, my approach is heavily inspired by the popular architectures. The goal is an architecture that works well in a decentralized team of two to five iOS developers per project and is easy to understand.
Scalability, maintainability and testability are important aspects. The SOLID principles have to be applied, view and logic especially must be separated, the ViewController should do virtually nothing and dependencies should be resolved via Dependency Injection (DI).
Here is a rough overview:
User interactions in the Display are communicated to the Logic via an Interactor. The Logic then evaluates these events through its business logic, possibly changes the internal state and transmits data via the Presenter back to the Display.
If the Logic decides to switch to another Scene, then this is done via the Navigator in the Core. The Core has a reference to the Container with the Dependencies, including a Factory. The Factory then creates new Dependencies, such as Scenes, where the Dependencies are injected.
The new Scene then has its own Core and thus a ViewController (VC), which in turn creates the associated Display and the Logic.
Summarized at the heart of this architecture are Scenes consisting of a Display, a Logic and a Core. Very simple, eh? That’s not more complex than the Model-View-Controller architecture. 😉
The Big Picture
For a better overview, here the dependency graph:
Arrows everywhere! Sure, that’s simple! 🤪
Red arrows represent direct dependencies because concrete classes are instantiated. The VC creates, for example, all major Display classes and the Factory concrete ViewControllers.
Black arrows with a filled head are dependencies because of method calls. Usually, methods are called via interfaces, which are identified by the angle brackets in the name. The Logic, for example, knows only the interface of the Presenter, not the concrete Presenter class and thus calls only methods of the Presenter’s interface.
Black arrows with white tips represent inheritances or implementations of interfaces. The concrete Presenter class in the Display module implements, for example, the Presenter interface while the VC of course inherits from UIViewController.
A Scene represents an app view, i.e. a View including ViewController and everything that goes with it. One Scene = one app screen.
In the project structure, this is then reflected by putting all Scene code into its own folder so that the associated code is not distributed but is close to each other. If two developers work on different Scenes, they will therefore rarely get in each other’s way.
As shown in the diagram, the Core module contains the VC and the Navigator. The Navigator is responsible for transitioning to other Scenes.
The Display module bundles the View as well as the Presenter and the Interactor, because they are all very much interwoven with the View. Any formatter, TableViewController and the like are also part of the Display.
The Logic contains the entire business logic, as well as the current scene state. In addition, the Logic can address any Workers. Workers are independent outsourced logic units, such as a server request framework.
Dependencies include the DependencyContainer. There is also a Factory for creating new Dependencies, such as new Scenes or sub-dependencies.
Everything clear? 🤓
The Individual Actors in Detail
The actors are best shown by a concrete implementation. Therefore, I would recommend to look at the DemoApp Project (DAP) in parallel.
In the DAP, we look at a concrete Scene named “Scene1” below. The Scene can be found in the project under “DemoApp/Scenes/Act1/Scene1”.
The Scene1VC is in the Core. Its task is to provide all interfaces of a UIViewController, e.g. to implement
viewWillAppear if necessary.
loadView is overridden here to create its own Scene1View.
A ViewController represents the root object of a Scene and thus it is also responsible for creating the other components of the Scene, such as the Presenter, the Logic, the Navigator, any TableController, etc. This is usually done in the
init method or in
However, a VC should not do more. Anything beyond the implementation of the UIKit interfaces does not belong in the VC anymore. So if you are writing methods in the VC without the
override keyword, you are probably in the wrong class. Thus there are no scene transitions, no data feeding of the view and certainly no business logic.
The only useful tests for a VC is end-to-end testing, which can be done via UITests.
The Scene1Navigator in the Core implements the Scene1NavigatorInterface protocol. A Navigator is responsible for the transition to other Scenes, which is otherwise often done in a ViewController.
The Navigator needs a reference to the Scene’s UIViewController. It also needs to know if a UINavigationController is being used, whether the next Scene is presented modally or how else the structural design is defined.
The Factory can be accessed via the Dependencies in the Act1DCInterface. It creates the UIViewController of the required Scene so that the Navigator is not directly dependent on the Scene and only cares about the presentation of a ViewController.
More information can be found in my article “Small Navigators for Scene transitions in iOS”.
The Scene1Logic implements the Scene1LogicInterface protocol. The interface lists all use cases, which may be user-initiated actions, e.g.
searchForText, or initiated by the system, e.g.
displayRotated. The Logic finally implements the associated business logic, and everything else is then outsourced into workers or subclasses.
To save the current state of the Logic, the LogicState struct in Scene1LogicState is used. The initial state is usually set when the Logic is initialized. The values are provided with a setupModel parameter.
The Logic needs access to the Presenter to communicate any status changes to the View. It also requires access to the Navigator for changing the Scene. These are assigned with the Logic’s own LoginDependencies struct via DI and referenced only by their interfaces.
By extracting all specialized code into workers, the Logic should be relatively light, leaving only state manipulation, comparison, and delegation. Less code means less can break. Of course, the Logic is literally begging for unit and integration tests. 😆
For more information about the Logic and the other Display parts, please read my article “Decoupling Display and Logic in iOS”.
The Scene1View in the Display represents the concrete subclass of a UIView. Its task is to create the view hierarchy, that is to say place subviews, provide constraints and define their default styles.
The View has no further logic, so it knows nothing about the formatting of data or of any app states. The only logic of the View would be view hierarchy manipulation, such as adding and removing subviews, e.g. for embedded sub-controllers, or maybe when activating and deactivating subviews.
It makes no sense to mock a View, so there is no interface protocol for Views. Views can be unit tested via snapshot testing or they will be tested via UITests.
The Scene1Interactor in the Display also has no interface protocol. It also does not need one, because only the VC knows about it and only because it creates the Interactor, which then hooks itself into the View and the Logic.
The task of the Interactor is to create a binding between the View and the Logic like in the MVVM architecture. So, any user input is mapped to the use case methods of the Logic. Here Rx seems to fit well, but that is not mandatory.
Just like the View, this piece of code could also possibly be replaced in the future by SwiftUI and Combine. 🤗
The Scene1Presenter in the Display implements the associated Scene1PresenterInterface protocol. A Presenter is the counterpart of the Interactor because instead of a flow from View to Logic, the Presenter allows the flow from the Logic to the View. Logic and View are completely separate from each other, and the data flow is unidirectional due to the division into Presenter and Interactor similar to the Clean Swift architecture.
The task of the Presenter is to manipulate the View so that passed data is displayed and any styles applied. The Presenter can use formatters to prepare the data. The data in the view, or the subviews, are set directly by the Presenter. The View does not need to know anything about data, and the Logic does not know anything about the View. That’s the domain of the Presenter instead.
Not all data is to be assigned to the View. Some may also be intended for the ViewController, e.g. a title for the NavigationBar. For this, the Presenter also needs a reference to the UIViewController of the Scene.
The Presenter must also be able to access any TableViewController to be able to update subviews such as TableViews or CollectionViews.
Except for any formatting, the only logic of the Presenter is to put the data in the right places in the View and possibly to style any subviews depending on state changes. However, sometimes the Presenter is also responsible for applying some animations, and that can become quite complex.
As part of the Display and tightly connected to the View, the best way to test the Presenter is via UITests or partially by unit testing the view via snapshot testing.
The Act1Dependencies with the Act1DependenciesInterface are placed in a parent group. Not part of a single Scene, but more like a theater act, the Act represents several Scenes of an app-state, e.g. a pre-login or onboarding over several views.
The task of the DependencyContainer (DC) like Act1DC is to bundle and keep all dependencies of the respective Act so that they can be assigned easily to the actors of the Scenes via DI.
A special dependency is the Factory like Act1Factory. It is responsible for creating new dependencies, e.g. Act2DC as soon as a user-object exists. The Factory also creates new ViewController and therefore Scenes for the Navigator.
All Scenes are decoupled from each other by the DC, so that no Scene needs to know anything about another, at least not directly. A Logic must, of course, know well where to navigate, and the Navigator has to know how exactly, but that happens through abstraction thanks to the DC.
Acts are not tested themselves, but they are essential to testing the other parts. By preparing special test acts, other dependencies can be mocked and injected for testing purposes.
Once you can isolate functionality from the app, you should do it and write your own Worker for it. Workers are, therefore, code from the Logic, or the ViewController extracted into classes or frameworks that can be at least theoretically used in other Scenes. Workers are, therefore, not part of a special Scene.
Every piece of logic that is not a use case directly bound to a specific Scene should, therefore, be put into an own Worker class.
Should the app perform server requests? Then a new worker is needed only for server requests! Caching? Own worker! Databases? New worker! Complicated validation of user input? Worker! 😊
Workers are specialized helpers that contain logic code and are responsible for only one aspect. Therefore, each Worker should be heavily unit tested.
The power of the modularization comes from outsourcing code into Workers. Without Workers, the massive class size problem would only shift to other parts, like to a ViewModel in the MVVM architecture. So, the key here is to divide all code into smaller modules, meaning Workers.
The presented approach should serve as an alternative to the common architectures or as an inspiration for an own architecture.
I hope that, through this post, I was able to show how modularizing code into Display, Logic and Core leads to a simple structure with Scenes. Workers help to break massive code down. For a more detailed explanation, please read my article “Decoupling Display and Logic in iOS”.
For more information about the project structure, with a complex example, read my article “An example of a scalable iOS Project”. In that article, I also provided some best practices to help increase the maintainability of a project further.
By the way, I still lack a reasonable name for this Display-Logic-Core-architecture. Any suggestions that do not sound like DownLoadable Content or Liquid Crystal Display? 😂