Where to start with cross-platform architecture
In this post, we will discuss how we approached building mobile products on both iOS and Android in parallel and the benefits we’ve gained doing so.
Novoda, in its origins, has consistently built unique products for Android. Since we adopted iOS as a part of our core offering, we’ve experimented with different Multi-Platform technologies such as React Native, Flutter or Kotlin Multiplatform, and exercised Cross-Platform development. Here we will be talking about the latest and greatest in the area. But first, let’s look at our approach in our first-ever dual iOS and Android project.
Back in the day, when we incorporated iOS into our development team, the goal was to bring to the new platform the same engineering focus and quality that Novoda was then known for on Android. In addition, with a small, brand-new team of iOS developers, we wanted to work alongside the Android developers and benefit from the existing expertise, processes and culture that have contributed to Novoda’s success.
In this post, we will analyse high-level architecture, the project structure decisions we made, and the concerted efforts we took to align them on both platforms.
We will discuss why we took the approach, what that architecture looks like, and some of the key benefits it provides for both Novoda and our clients.
An app is an app. Underneath the cosmetic and slight navigation pattern differences, iOS and Android applications offer similar experiences to their users. Creating great applications is time-consuming and expensive; anything that speeds up the development of exceptional, robust and maintainable applications is a victory for our developers and clients.
Nowadays, we can think about various solutions, Flutter, React Native, and KMM. All of them are valid for different scenarios; however, back in the days when we had to approach our first application on Android on iOS, those technologies were not as mature as they are today, or in the case of KMM, it wasn’t even a reality. Even today, they might only fit some necessities and teams, but an alternative might still be possible.
How could we facilitate the development process on multiple platforms simultaneously?
Language and Tools
Whenever we build apps, we must establish a domain language for talking with the business and ensure we are working towards the same goal. We first ensured we did the same across platforms. Rather than look to share code, we looked to establish a shared mental model and a standard set of terms. This meant creating similar layers in the application (more on that later), but it also went down to how we structured the project and what frameworks we used.
We had to deal with a particularly arcane and complex domain, so we all needed to agree on the meaning of various common domain terms and how we refer to the different parts of the app.
We decided to group things by these domains (e.g. Account, Receipt) rather than by layer (e.g. Model, Service, View). Beyond the fact that we generally consider this a sensible way to organise a project, it also makes it much easier to deal with slight differences across platforms. For example, Android has Activities, and iOS has ViewControllers. Knowledge of these differences isn’t necessary to successfully navigate the codebase. If you’re interested in any aspect of account management or receipts, you can find the relevant folder, and the appropriate layers and components will soon become apparent.
Regardless of the technologies being used, it was important for us to define a clear pattern and a mechanism for how the data flows between the different layers of the application we were building.
We needed to reach alignment of the data flow from very different angles, and the team needed to find consensus in all of them.
- What paradigms are we using; Reactive programming or Structured Concurrency?
- How many sources of truth do we define for every single component, one or many?
- What are our constraints for error/loading states propagation?
- Do we allow actions on a component to return data or not?
The critical aspect here is consistency. Otherwise, the implementation could have diverged between platforms, the communication between the different team members would have been more complicated, and the cooperation between team members would have degraded.
Layers of Abstraction
Let’s get more concrete about our layers of abstraction. Again, the exact terms and architecture are not crucial here. The fact that they are platform-agnostic and were agreed upon for both platforms throughout the project is vital.
Service, Repositories and Data Sources
Whatever you call the business logic layer of your application, somewhere along the line, you’ll need to fetch data from a network, make some requests, store data to a disk or fetch data from a disk, etc. This is where it happens. Anything above the networking or database layer is entirely platform-agnostic.
For us, this meant all data was exposed by a single source of truth containing the latest known state of a given component (idle, loading, error). Other parts of the application can subscribe to updates, and the UI layer can make sensible decisions about displaying this to the user.
On the other hand, actions on the domain had no return values. That means no result is provided directly. Instead, any changes to the state, and thus to the UI, are reflected in changes to the various DataStates that can be observed. We started using this approach years ago, inspired by React-like architectures, and we couldn’t be happier that both Apple and Google have begun encouraging the usage of this type of pattern in their recent frameworks. SwiftUI and Jetpack Compose. So is good that we have a uniform approach adopted by the community. But still, the most significant advantage is for the teams; they can work efficiently with this approach regardless of the platform, which leads to a common ground in developing the application.
Façades and View Models
Facades are objects that convert the generic-domain APIs into the requirements for a particular context, generally corresponding to a “screen” in the application. This is where we combine various data streams into a ViewModel containing all the current context’s presentation data. Note that in our case, a ViewModel is a simple value type. The Facade is what receives user commands from the UI layer and interacts with the Service layer.
On iOS, ViewControllers would subscribe to updates and bind themselves to this ViewModel. On Android, the Activity would use a Presenter to bind the ViewModel and the View. So there is scope for further alignment between the platforms nearer the UI layer. Still, nonetheless, the differences are minor and localised enough not to get in the way of fruitful collaboration.
Core and Mobile
Novoda has long had a clear separation between the “Core” and the “Mobile” layer of any application. This started partly from a desire to unit test back when it was almost impossible on Android, but the pure Kotlin Core used by an Android layer leads to a clean separation between the domain and UI concerns. Something that all applications should strive to achieve.
On iOS, in Swift, this has meant a Core that relies only on Foundation and the Swift Standard Library (and in our case, RxSwift). However, since iOS 8 and dynamic frameworks, Core can be a separate target that needs to clearly define its public API, thus enforcing a clean separation between the layers.
For the architecture described above, this means Services, Repositories and DataSources living in Core and Facades, and ViewModels and Views living in Mobile.
This simple separation is a good step towards a proper hexagonal architecture, decoupling your business logic from the platform detail. In the ideal case, this Core would be truly cross-platform. KMM is a good approach in this direction, but it still requires fine-tuning and Gradle expertise to set up the project for Android and iOS correctly. It could, however, represent a Core that is shareable across iOS, Android, the Desktop and the server.
One of the worries of working so closely across platforms is that the resulting product could only reach the lowest common denominator regarding the feature set. This is most notably true as our entire process is shared. Right down to having a single user story which will be implemented on both platforms.
To put this another way:
“If we’re always working on the same app, how can we make the best of what each platform has to offer?”
Not only do differences in capabilities between the platforms get smaller every year, but we can easily avoid this issue if the team is encouraged (and willing) to be flexible when necessary. A feature available natively on one platform might trigger some creative thinking about how to provide similar functionality on another, improving the experience for all users.
More than the sum of its parts
Whilst we aren’t sharing code across platforms, a shared architecture and a shared domain language has provided us with numerous advantages. For one, it enabled developers who are proficient on both platforms to effortlessly switch between them, maintaining the same mental model of the domain and the application structure.
But as mentioned throughout this post, even without cross-platform developers, such an approach allows the entire team to discuss implementation decisions, potential issues, and estimate the complexity of upcoming features. Moreover, this ceasefire in the platform wars has enabled traditionally silo-ed teams to communicate and collaborate. As a result, two pairs of developers can operate as a team of four, sharing the impact of effort, insights and discoveries to both platforms whilst maintaining the platform-specific expertise required for top-quality applications.
This general collaborative approach extends to how we always aim to include backend developers, designers, testers and stakeholders when creating applications. If you’re looking to offer high-quality experiences to your users on both platforms and want to deliver these timely and cost-effectively, you need a single, cross-functional team.
- Agree on terminology and keep the same project structure
- Use similar tools and frameworks where possible
- Have consistent interfaces/protocols
- Separate your business logic from your application layer in a consistent manner
- Be flexible and creative
- Have one single, happy team 🙂