Working with React and React Native for the past two years has been a great experience. It’s helped us overcome many issues with our hybrid app, improved the look and feel of the app, and generally had a very positive impact on our development experience.
As great as these technologies are, having multiple teams working on the same app (a single, shared codebase) was still a difficult task and we were still dealing with some challenging issues:
- A team could easily break another team’s features without noticing
- Code was constantly added to the initialization process of the app, and it caused the app’s load time to continuously increase
- No clear responsibility for some areas of the codebase, which caused them to be unmaintained and full of hacks (“I’ll just make this tiny little change to make my feature work…”)
- Tech-debt continued to accumulate
Not our first rodeo
These issues are not unique to mobile app development. Backend development can – and usually does – suffer from the same problems. In fact, a single shared backend codebase (which usually maps to a single process being deployed) has even been given a name: “the monolith”.
In order to fix the problems caused by the monolith, the industry has settled on an architectural style known as “Microservices”. Although it invites new problems to the table, this architecture style really solves some of the most challenging issues teams used to deal with, and these days is generally considered a go-to architecture when trying to scale backend development.
Seeing the similarity between the issues with our app development and the issues we previously faced (and solved) in our backend development, we decided to apply the same patterns and principles to our app.
The thinking part
The first thing we started thinking about is: “what is a microservice in the app development world?”
In backend development, it’s usually a single, independent, and deployable unit. But this definition doesn’t work for a mobile app, as you can’t just split it to different “microservices” which are deployed independently.
What you can do, though, is to split the codebase into “parts”, which may not be deployed independently, but are independent in the sense that they:
- Own their own state
- Communicate with other “parts” only through standard protocols
- Have defined and strict contracts
In React Land, these “parts” natively map to components.
After choosing components as the React equivalent of microservices, the next thing to consider was another important aspect of a microservices architecture: the hosting environment. The environment determines the way backend services communicate with each other, and provides a solution to common requirements such as logging, monitoring, and security.
When thinking about the “environment” in app development, we identified the following aspects that it needs to handle:
We decided that those aspects will be handled by a thin core layer in the app, and be injected into the different components. Because our app is a React Native app, we decided to use higher-order components and React’s context for injecting the core services into the components.
The doing part
After deciding on a general, high-level architecture, we started splitting the app into independent npm packages. We began with our high-level components (roughly a “screen”), which were pulled out to their own packages with their own services, utils, and tests.
By extracting these packages, we gained the following benefits:
- It became easier to identify which team is responsible for which piece of code, as each package is owned by a team (we even started using Github’s code owners feature to automatically include relevant people in pull request reviews)
- Unwanted dependencies between components became easy to spot
- We now have a mechanism for teams to declare a contract for their components and services (everything that’s exposed from a package’s index.js file is part of its contract and mostly shouldn’t change)
Next, we had to tackle state. We wanted to follow a basic microservices design rule: each piece of state should be owned by a single service, and the only way to read or alter a piece of state is through the owning service. That, together with the fact that we didn’t want to enforce a specific state management library on any team, prompted us to decide not to use global state solutions like redux or mobx. Instead, we chose Rx observables as our state sharing mechanism.
This choice provided us the following benefits:
- It doesn’t dictate any specific state management solution, so each team could choose which one to use internally, as long as they expose it outside as an observable
- Rx observables are already being used heavily in our codebase (both in the app and in the backend), so we didn’t need to spend time learning it or teach the other teams how to use it
- Observables allow getting the current state of a component, while also listening for future changes, which means they’re easily integrated with React components
- Observables can be composed with each other, which allows complex state transitions
Using observables still left us with an open question: how can components alter each other’s state? We chose to go with a rather simple approach: packages will just expose functions for altering state. That way, the package that owns the state can decide which piece of its state it will allow a change to, and in what way.
What comes next
All of these changes really helped us scale our app development, and made it really easy to onboard new teams who want to add new features to the app. However, our work isn’t done yet. There are many additional improvements and changes we want to introduce, and we believe they’ll greatly benefit us in the future. Some of these changes include consumer-driven tests, independent release cycles for components, and a scaffolding tool for quickly creating new packages. Once we implement them, we’ll be sure to share the details here.