At WHOOP, we work on interesting yet challenging features. Unlocking human performance isn’t trivial. We’re also a subscription-based business, which means we strive to consistently deliver new value for our members.
Without a strong, scalable architecture in place, the push to consistently deliver value as a startup can dwarf the considerations of introducing tech debt. After all, it seems reasonable to make that “calculated decision” to allow tech debt in the short term while saying to yourself “I’ll come back and fix this when things settle down.”
In the past, when we set out to implement a new feature, we would do our best to balance moving fast and following our design pattern. We’d approve, merge, and roll out to production quickly.
As you might predict, we never settled down enough to circle back on a lot of the tech debt and it stacked up faster than our modest efforts could pay it off. Gradually, it became difficult to add functionality while maintaining stability.
So how did we make life easier for our mobile development teams? How are we now able to move faster without compromising on the scope of our features? The answer to these questions lay in a philosophy I’ve learned while working here:
“Complexity can’t be destroyed, but it can be distributed.”
Previously, there were many parts of the codebase where we were following the MVVM design pattern. At first, this was fine. MVVM is relatively straightforward to wrap your head around.
For relatively simple apps, this three-layer architecture is sufficient. However, for WHOOP, we went from 3 full-time mobile engineers in mid-2019 to about 30 full-time mobile engineers in just over 2 years. As the size of the team exploded and our features compounded, we ran into some common problems with the standard MVVM architecture.
Layer Bloat & Vague Borders:
With only three layers in the architecture and an elaborate product, our ViewModel and Model layers became overloaded with additional responsibilities that they shouldn’t have been concerned with.
Any logic that didn’t seem like it fit in the View or ViewModel layer just got shoved into the Model layer. This aspect created a loose definition of our borders between layers. At times, deciding what should be in the model layer versus the ViewModel layer felt discretionary. It was up to each individual developer to decide what goes where, which led to inconsistencies in how different features were implemented.
We saw all of the characteristics of legacy code: logic in the view layer, a loosely defined design pattern, overloaded ViewModels, flaky testing, unreadable code, and more. It got to the point where we couldn’t move fast anymore; moving forward at all was painful enough.
If you picture an app’s architecture as the foundation to support the weight of a complex product, our app used to rest on a structure with overlapping components and gaps.
If this doesn’t seem like a stable foundation, it’s because it isn’t. Not only did this architecture result in hard-to-read code, it also was difficult to test and led to app instability.
We have classes in the iOS and Android codebases that have 20+ dependencies(!!!) To add a feature that touches these classes, you’d need to be confident about dozens of other features spanning years of app development. Moreover, testing components that are so deeply coupled with these many dependencies is both difficult and not worthwhile. Because unit testing was so painful, we relied on flaky, time-consuming UI tests to verify our changes.
We realized that it wasn’t the developers making wrong decisions nor was it lack of technical knowledge that was causing the pain. Rather, it was the system we were operating in. It became obvious that we needed to revisit our architecture to reassign responsibilities and redefine boundaries.
Expanding on MVVM:
Instead of just three layers, we expanded our three-layer diagram to four layers and defined some of each layer’s components:
Yes, this diagram doesn’t have as clean of an acronym as MVVM.
Yes, it looks more complex, but that’s because the diagram is no longer hiding the complexity.
Yes, we’ve distributed each layer’s responsibility, but we did that by simplifying what each component is responsible for.
We now have a distributed layer with defined boundaries; the components that didn’t quite fit in our old architecture have a home in our new one.
- The View is responsible for rendering something to the screen.
- The NavigationRouter reacts to navigation events and triggers navigation through the app.
- The ViewModel ties the reducer and domain layer components together.
- The ViewStateReducer takes a domain layer object and transforms it into rendering instructions.
- The NavigationReducer takes a domain layer object and transforms it into an event to trigger app navigation.
- The Domain layer is where the brunt of the business logic is handled. The actual components (commonly known as “Interactors” or “UseCases”) vary depending on use case.
- The Data Source layer is responsible for communicating with the API or local database.
We’ve found that this architecture scales well with even our most complicated features.
I can already hear, “But wait, all I have to implement is one screen, do I really need all this extra stuff?” Ultimately, yes. I would strongly recommend it even if it’s for a simple feature. Fully understanding that it may seem like overkill to build all of these layers for a small feature, it’s important to consider how the feature will scale.
Features have a tendency to not stay small for long. As engineers, it’s important to adopt a Product engineering mindset and recognize that product requirements can change. At some point in the future – maybe it’s two weeks, a month, two months, or a year – someone may look at your simple screen and want to expand on it.
You might be thinking that the feature will stay simple indefinitely. Even if somehow the simple screen is guaranteed to stay simple, it’s beneficial to have a clean, base case example of this architecture that other developers can look at when developing new features.
We’ve seen tangible benefits from this shift including:
- Reduced developer cognitive load → on both iOS and Android, we’ve even started to implement a tool that will auto-generate the base components of the architecture when creating a new module/package
- Faster development time → with simpler components, it’s become easier to unit test our features, bolstering our testing framework and increasing our resilience
- Increased app stability → with a more robust testing framework and decreased cognitive load, we catch bugs earlier and are better suited to tackle them before accidentally leaking them into production
Another added benefit is that this design pattern is agnostic to mobile platforms. Both our iOS and Android teams have been tackling this in parallel, and this has facilitated many higher level cross-platform discussions between our mobile developers that allow for further cross-pollination of ideas.
Don’t get me wrong, we have hundreds of thousands of lines in our codebase – by no means have we solved all of our tech debt. There are still parts of the code that sometimes cause problems and need to be addressed.
But by defining this design pattern, we’ve set a standard for our code moving forward. We are no longer continuing to add debt to our credit and instead, we’ve turned the tide in cleaning up our problem areas.
The end result is that we now have a system that can scale up to support our product’s complexity.
Stay tuned for posts on topics such as revamping our testing, unidirectional data flow, and a deeper dive into each of the components within the architecture!