Maybe you’ve heard this story before: A company dreams about a promised land where code is written once and runs everywhere. In this fantasy, there are no compromises. Your team is half the size it is now, and you’re spending half the time you’d actually have to spend for a working, maintainable, state-of-the-art app.
Unfortunately, at some point the mobile team at Toggl Track joined the long list of victims of this dream.
We had some legitimate reasons for pursuing a cross-platform solution in the beginning. And in the beginning, we were able to ship a well-received, functional app. But there was still this wall we kept hitting over and over again, and we quickly realized that if we wanted to give our users that extra 5%, we’d have to go fully native.
We had a long discussion on our issues with the cross-platform solution during a mobile team meetup in Malta. We agreed to rewrite the app from scratch with native Android and native iOS tooling.
But I don’t want to turn this into another “Sunsetting X at Y” blog post. I’d much rather talk about how to bring those things we loved about our cross-platform experience to the native world. I’m talking about how both the Android and iOS teams were able to share the same vocabulary, share the same concepts, and solve difficult problems together. Fortunately, you can keep sharing all of those things without sharing the same code.
When we had this extraordinary opportunity a year and a half ago to start with a clean sheet, we knew we wanted to share, as much as possible, the architecture between our Android and iOS teams. But we were still wondering which architecture to choose. Our team had various positive experiences with Redux, MVI, and Elm Architecture, so we knew we wanted to go in a similar direction.
We needed it to be:
- Easily Testable
We also needed an architecture that would build on top of each platform’s strengths rather than imposing alien concepts on top of already mature ecosystems.
First, we took a long hard look at what was already out there:
This is a very ambitious project, trying to solve many of the same problems we had. As you might expect from Square, the library is very well put together. However, we were a little intimidated by the size of this library and the way it completely takes over your app. It requires full buy-in from the developer, and we were afraid to put our trust completely in their hands (The fact it was far from stable at the time didn’t help).
Mavericks is great for what it does. It’s undoubtedly an elegant implementation of a single screen unidirectional flow, built on top of Jetpack’s ViewModel. However, we were looking for an architecture that could handle problems on a small scale as well as on the scale of the global app state and would seamlessly cover both of these worlds with a single unifying concept.
Our iOS devs kept mentioning The Composable Architecture. TCA was designed over the course of many episodes on Point-Free, a video series on programming. It was built on a foundation of ideas started by other libraries, particularly Elm and Redux. Both provide a unifying approach to your architecture from a single screen to the whole app.
So what’s the problem with TCA? Well, the problem is that there is no Kotlin/Android/Flow equivalent.
You probably know where this is going, right?
Introducing Komposable Architecture
We came up with Komposable Architecture.
Problem: How to manage the state of your application and how to make this state observable across the app
Solution: In Komposable Architecture, you can create small sub-states derived from your main AppState. All changes to one or the other are hierarchically propagated throughout the app.
Problem: How to break down large features into smaller independent components
Solution: Thanks to the composite nature of Komposable Architecture’s state management, you can essentially mix and match different modules and their states whenever your requirements change.
Problem: How to run asynchronous operations in the most testable and understandable way with the help of coroutines
Solution: All side effects are isolated in separate Effect interfaces, which are essentially just suspending functions. This makes them easy to test and reason about.
Problem: How to test your code seamlessly, from simple reducer operations to complex side effects
Solution: Unidirectional architectures are making your app easy to test by design. Given that all state changes are contained within reducers, you just need to check the correct outputs for any given input.
Problem: How to propagate the latest data from the database and other observable sources
Solution: We took inspiration from The Elm Architecture and its subscriptions. Subscriptions are a way of leveraging all the observable(flow) APIs we know and love and making them fit right into Komposable Architecture.
We’re using many Jetpack Libraries in Toggl Track’s Android app, including Room, ViewModels, Compose, Hilt, and we made sure KA would play well with those.
In fact, there is no reason why KA shouldn’t play well with anything else you might be using. The library itself is intentionally very simple. You can think about it more like a blueprint for your own architecture. We leave a lot of the things for you to implement so that you can choose how exactly you want them to work.
Current state and future plans
Although we’ve been already using Komposable Architecture in production without issues for more than a year, the API is still subject to change as we are planning to make more improvements in the upcoming months.
Tomáš works as an Android engineer at Toggl Track. He's constantly pushing his team towards the bleeding edge of what's currently possible. In his spare time, Tomáš can be found making experimental electronic music or trying to understand his unsettled mind with the help of contemplative practices. https://github.com/semanticer