iOS App Architecture in 2022
Since we're about to start a new year, I thought it would be good to write about what I think it's the best approach for building iOS apps nowadays.
For the past couple of years the iOS landscape has changed dramatically and I finally feel like we're in a proper position to enjoy making apps again, something I didn't feel since the early days of the platform.
In this article, I want to describe what I think is the best way to build iOS apps. This ranges from how to make UIs to how to structure the overall project, of course touching on the subject of architecture.
These ideas come out directly from my experience over the years building and maintaining applications. Everything I'm suggesting here are things I've tried myself.
As a side note, pretty much all these recommendations apply as well to Android development. I've successfully deployed these ideas to both platforms with excellent results. Some things are trickier in Android due to do nature of the frameworks, but they are totally doable and very desirable.
Suffices to say that I'm not a guru that writes this to spread a dogma. This is just my opinion, my recommendation born from my experience. If there is one thing that everybody needs to learn is to analyse things from themselves, so don't follow this blindly, heck, follow nothing blindly!
Another thing I want to address is the first reaction some will have about this tech stack. If you think that I have decided this tech stack because these are the new shiny things, I encourage you to reconsider and look past that. I'm not one to jump on everything new and compromise the future of my projects. I don't think any good engineering lead should do that. But I'm also not going to pretend the world is not moving forward. When I see that something new I tried for myself to make sure I understand the tradeoffs that it proposes.
Table of Contents:
Before talking about the different points that make my ideal architecture is important to have some goals in mind. We need to have a clear solution space that we can analyse our decisions on.
One of the crucial points to get right is developer experience with fast iteration cycles. In a rapid development environment, these two characteristics help a lot to keep up with new product features in a timely manner.
Another very important aspect is the maintainability and evolution of our codebase. Whatever system we use it needs to allow us to change code rapidly and, most importantly, get rid of it when it’s no longer required. I directly relate this to the desire to facilitate the independent evolution of the different parts of the app and make it simple to have system integration.
One thing to keep in mind too is how these decisions affect hiring. Most people will very soon want to work only on modern tech stacks and the onboarding ramp is something that we need to pay attention to.
With these goals in mind, let's look at the specific points that make a desirable architecture in today's world.
A key part of the tech stack is having a fully modularised application structure. I'm not talking about having files in different folders, but about enforcing strict boundaries between the different systems in your application.
This has never been easier in iOS! I remember having different project frameworks years ago, hacking Xcode to support them on iOS before any official support. It was crazy pants. But nowadays the Swift Package Manager makes this trivial.
The aim is to have the code separated in multiple small independent packages that are combined by an “application”. This facilitates the maintainability of each package and their independent evolution. Having small packages and a clean dependency graph also improves compile times and unlocks a bunch of improvements in iteration cycles and developer experience.
The important thing to understand is that we're not just talking about modularising helpers or utilities. The goal here is to modularise the distinct features of your app. That, in turn, will force you to think about your structure and modularise the rest.
Many of the ideas you will read in this section have been directly inspired by Pointfree. Make sure you check it out!
An idealistic goal is to have your Xcode project just have the app entry point and immedatly start the code from a feature package. Yes, this makes 99% of your code live in Swift Packages! 🎉
For me, one of the most important aspects of adopting modularisation is the change of mindset that forces on the developers. It’s very important that you get into the mindset of proper modularization. This will break some existing preconceptions that you have about relationships in your code, but it is crucial to embrace them in order to get all the benefits of this approach.
Simple dependency injection and inversion of control becomes crucial. Your package may need to do some work, and it may be tempting to put the code directly in the same package. But you need to have clear responsibilities for each package and know where the walls need to be built. Another way of looking at it is to not just think about the dependencies and the graph (there are always ways to force it to do what you want) but about the knowledge (or lack of thereof) that each package should have.
I use physical metaphors often when describing these concepts. I found they help visualise where the limits of each module are. Everybody knows you can't cross through walls unless there is a door, or what's the difference between your home and the outside. I recommend you to use the same when you are trying to explain some of these concepts.
A very common example is when a package needs something that doesn’t really need to be known to it. It must provide callbacks or customisation points to the outside world. Its responsibility is just to call those hooks - not to know what happens in them. But if the packages just calls back (delegates) to the outside world to do this work, who is then doing the actual work?
That’s where the hierarchy comes in. When a communication needs to be established between two otherwise independent packages, a third package is introduced to depend on both and provide the communication channel between them (see example below). Usually this package ends up being the App target, which will import all features and link them together appropriately. But sometimes it may be beneficial to make these “parent” packages to bring other kinds of functionality together.
This solution has come up so frequently and can solve so many issues that I ended up calling "The third package rule". Making a third package that pulls together two other packages is always a valid solution. Nonetheless you should try to find other solutions to avoid an explosion of this type of package.
Imagine that feature A may want to open a screen that lives in feature B. It may be tempting to make A depend on B, after all you want to open its screen. But that’s the wrong mindset!
Feature A shouldn’t know anything about what happens outside itself. It shouldn’t know anything about that other screen, since is part of B. It should just provide a callback, a hook, to let somebody else decide what to do. Who is that somebody else? A third parent package that will pull together feature A and B, usually the App.
This third package, C, will depend on both A and B. It will hook into A’s callback, and when that callback executes, it will open B’s screen. A and B know nothing about each other. C is the one connecting them together. It’s like an old telephone operator.
Having multiple modules in a project is very beneficial, but can quickly get out of hand if they depend on each other. When that happens, you just moved the spaghetti mess of your code up one level. That's why I designed some guidelines (rules for my team ^^) to help facilitate the decisions when finding these dependencies.
It is important to realise that not all modules are equal. Even tho all of them just have some code their purpose is different and they take part in different ways on the dependency graph of the project.
The diagram tries to illustrate the different levels in the modules hierarchy.
Apps are the topmost modules. This is where your main app belongs, but also other "feature preview apps" or other targets like widgets and other system integrations. They Can import any other module but Can’t be imported by any other module.
Features are the modules that represent the different features of your app (user profile, feed, ...). This is the most crucial aspect of the graph, and the one that benefits most from this approach. It's important to remember that they can't depend on other modules at the same level or above. They Can only import non-feature modules.
Dependencies are other library like modules. Things like API layers or other subsystems. I like to think about this as my own "first party libraries", so they are not necessarily third party packages. One important detail is that if they depend on heavy third-party libraries, you should break them down in interface and implementation. And if you need to use third-party libraries directly on features, they should receive the same treatment. The Interface module will just contain the interface and dependencies needed. This is the module that will be imported on feature modules. The Live module will contain a live implementation of the dependency. This will only be imported by final apps (top layer). This has the benefit of being able to create fake implementations very easily, directly in the interface module or on their own one if required.
The bottom layer is what I like to call Foundation modules. These are special libraries that are at the bottom of the hierarchy and provide the foundational functionality needed everywhere. They Can’t import any other module and Can be imported by any other module. This one is sometimes hard to grasp since it seems like a lot of things could go here, and is quite the opposite. Usually I just end up having a couple of modules at this layer: Foundation extensions and common UI building blocks. It's important to realise that if something is needed in a lot of places but has dependencies or specific requirements, that’s not a foundational library. Don’t be afraid of making as many specific library modules as needed!
Other app modules
Having this modularisation gives you flexibility to have apps other than your main product. For example, feature preview applications are very useful for rapid development on real devices of a specific feature. They avoid having to go through your entire navigation every time.
But you can think about Playgrounds as another kind of application. It's very useful to have playgrounds to play with some business logic or some subsystem of the application without having to deal with everything else. For example, I made a small playground to play with API calls that has turned out to be quite useful! You could even make CLI applications that reuse your business logic!
It won't come as a surprise that I think a modern iOS app should be using a modern UI framework like SwiftUI. Not because is modern, but because a declarative UI framework has real benefits.
It's not because SwiftUI is the new shiny thing (is not even new anymore 😂 ) but because I truly believe that a declarative UI system reduces a lot of the friction and maintenance burden that you get from more classical imperative systems (UIKit). As you may know, I've been wishing for a declarative UI framework on Apple platforms for years before SwiftUI was announced.
My video about the announcement is still my most watched video, not because of its quality but because I was so excited about that I published it in record time and was the only video about SwiftUI in YouTube for hours.
SwiftUI directly helps with iteration cycles, not only thanks to the Xcode Preview system, but because changing things in views is way faster than with imperative code. The fact that the only thing you write are mappings from data to view makes working with UI as easy as manipulating data. The beauty of which has been lost through the years. I know SwiftUI comes with a change of mentality that not everybody is comfortable with, but it's totally worth it.
But I won't hide the fact that SwiftUI is still young and sometimes you need to fall back to UIKit. I just don’t consider that a tremendous problem! The integration works very well and thanks to the modularisation, you can provide APIs that the rest of the project uses without knowing that UIKit is still there, just like Apple does. The recommendation to my team is to be comfortable integrating UIKit, but try to avoid it like the plague.
I would also recommend that you keep under control the amount of factorization you do for your views. Coming from an OOP framework we feel like we should avoid DRY to the extreme, but composing views in a declarative framework is so easy that sometimes I much rather prefer duplicated code in conceptually different views than not tie them together too early for no reason. I like to delay that decision until it's clear that they are the same view but just with minor differences, and not force that into the code. Refactoring view code is so easy that I don't think is worth risking doing it too early.
In terms of architecture, the decision is clear, a modular uni-directional data flow architecture is the desired choice. This type of architecture it's the most crucial aspect for an easy to understand and maintainable codebase.
The more classical approaches (MVC and even MVVM) can quickly become out of hand and hard to grasp. Is not immediately obvious where a change in the application's state happens or where it comes from. It feels to me like those architectures are tied to their OOP roots and show the same defects as that paradigm. Instead, a uni-directional data flow architecture benefits from some functional programming concepts that make things way more controllable and easy to understand.
I've seen the benefits of this approach in other ecosystems for years, but since it's quite a radical departure from what the iOS community is used to do I've never felt that we had a proper library for it. For years, I've been watching this space, and I always had issues with all the candidates so I have a judgemental eye and know what to ask. But then Pointfree started designing The Composable Architecture on their videos. At the start of every episode, I saw the same issues I always had with architectures like this, but by the end of the episode, they answered with brilliant solutions. That's something that never happened to me before! Seeing how everything fitted together so well, and not the fact that was new and shiny, was what made me a big fan of the library and started using it even before it was released officially.
I don't want to make this post longer by elaborating on why the TCA is great, but let me give you a few pointers by touching on the different aspects of the architecture quickly:
- Having the State of your app (and UI!) in a plain struct makes it super clear why the app is showing what is showing in every moment. Makes it incredible debuggable and even reproducible.
- Every single event that the user or other systems perform on the app is encapsulated in an Action. This means you can know every single event that the entire app reacts to.
- Most systems fall apart when you start talking about dependencies. But the TCA comes with an integrated solution thanks to its Environment. Forget about using bulky dependency injection frameworks, the environment is just a plain struct with dependencies. It makes it incredibly easy to switch dependencies on test without having to rely on sub-classing or classical mocks that just cause issues.
- In the same way that SwiftUI makes building UIs easier thanks to its declarative nature (Data -> UI), the Reducer gives us similar benefits to our business logic. It's a pure function that lets us convert the state of the app based on what actions has received. Swift makes this incredibly nice thanks to
inoutbecause you feel like you are writing mutable code but you are not. Finally, the Effects are controlled so the purity of the function is kept intact.
- The final piece is what brings everything together, the Store. It's the responsible for keeping the flow of data going and interpreting the managed effects that come out from your reducers. It's a part of the architecture that you barely touch but for me it's crucial because it follows the "interpreter pattern" that I've grown to love so much.
The importance of Swift on the beauty of this architecture can't be underestimated. Having so aproachable value types, that even give us mutable value semantics is incredible. But also more advanced features like KeyPaths make it possible to build elegant APIs. Swift is an amazing language and libraries like this make it very clear.
You can see here the main bullet points of the architecture. But for me, the most important concept that sets this apart from any other architecture is the composability of the domains.
I use the word domain a lot when talking about features and the TCA because it defines a clear separation between different parts of the code. In my own words, a Domain refers to a specific area of the program that can be reasoned about independently. It can be as small or as big as necessary. For example, a “feed feature” is a domain for an entire feature, but the “list of posts” is the domain for just one screen, or part of. You can go even further and talk about the "post" domain, which represents a single row in the list. Is a useful word to use because it helps describe the structure of the code when we want to break down or compose domains.
The beauty of the TCA is in how easy it makes it to compose different domains. This translates into making it trivial to break down domains into smaller domains. Usually you have 1 domain per screen, but if the screen is too complex, or part of it is repeated in multiple places, you are free to break them down into its own domain and share it between the screens.
I like to classify these domains to facilitate the conversation:
- Embedded domains are those that provide functionality by embedding it into your own domain. This is often what you do when composing different screens in a flow, or when separating a reusable piece of UI. This is particularly useful for navigation or to make a domain for each individual item in a list.
- Generic domains. Domains can be built to reuse generic functionality across the application, for example, the reusable favoriting case study. These are often accomplished thanks to high order reducers, which represent the same concept as high order functions. In my mind, these generic domains come in two forms: polluting and transparent (not very good names I know).
- Polluting generic domains are those where the types of the store changes. These happen when you need to include domain specific actions and state. They are very powerful but can complicate a bit the types you need to write.
- Transparent generic domains on the other hand are those that are made out of high order reducers that just add functionality without altering the types of the store. Things like
The Feature level domain
It's important to give a special mention to the domain that encompasses an entire feature. It's the domain that provides the public interface of the feature and all the other reducers and views are hidden internally. You can think of it as the entry point of a feature.
Take a look at Observe actions in The Composable Architecture which provides a nice little tool to hook this feature domains with the rest of the application.
Concurrency is a big topic right now in the community since we finally got Swift Concurrency. It's a topic that I've been following for months and one for which I have many things to say. But for what relates to this post, I would just say that in a modern iOS app you should use Swift Concurrency as much as possible before looking at more complex alternatives. And if you really need to use a full-fledged FRP framework, I recommend you to stick with Combine instead of using a third party library.
My advice is that you really try to learn and embrace Structured Concurrency. You may be tempted to follow the same patterns and techniques you are used to with FRP or Futures, but you will hurt your codebase and future maintainability if you do that.
Swift Concurrency is a game changer and something you should embrace fully. It will make all your async code more maintainable.
Go first party
Finally, I just want to touch a bit on the topic of dependencies. This is something that sets aside our ecosystem from others. We have a "first party" provider, Apple. The relation we have with them is not always perfect, but it's very wise to follow their recommendations as close as you can.
For many years we've had to look to outside libraries for things integral to how we code (things like RxSwift, other Databases or wrappers, etc) and tools (third party package managers). But nowadays I think the situation is much better and for many things you should choose to use a first party solution instead.
We've already seen how this approach fully embraces SPM and Swift Concurrency. But besides that I would also recommend sticking with vanilla Core Data (although GRDB is my preference). Don't confuse this with refusing to use any third party code (TCA is not first party after all!) but about realising that Apple frameworks have come along way and more often than not they provide the tools that you need. You just have to look closely ;)
To summarise, in my opinion an iOS application in 2022 should:
- Be written in Swift (of course I didn't mention that because is obvious coming from me).
- Be fully modularised project structure.
- Use SwiftUI (a declarative UI framework).
- Use a modern uni-directional data flow architecture that is composable (TCA).
- Stick to system frameworks when possible.
- Use all these points to improve the developer experience and iteration cycles.
And these recommendations are not just empty words, but things that I've been actually putting into practice in the main application that my team maintains. An app with ~350k LOC that was born in 2013. I'm thrilled about how nice is to work with, but it's time to move it to modern times.
And here you have a couple of slides that I presented in our engineering gathering before ending the year. ^^ (as you can see, this system is followed by both platforms)
If you liked this article please consider supporting me