Engineering Scalable, Isolated Mobile Features with Plugins at Uber
As Uber grows, we continue to refine our approach to architecting mobile applications in order to support the scale of our businesses. When apps grow it can become harder to add new features without breaking other features, experiment with existing features, and decide where new features should be integrated. Our investments in RIB architecture and plugin tooling over the last 18 months targeted these scaling problems and has allowed Uber engineers to measurably increase their productivity, and in many cases, double it.
In this article, we highlight four key aspects of our current approach to build-time plugin tooling for Android and iOS: (1) why and how our mobile framework enforces code isolation with plugins, (2) how we use plugins to encourage structuring the application as a small set of feature integration points and the benefits this provides, (3) how we use plugins to mitigate problems with experimentation flags, and (4) the ideal users for this type of plugin system.
Ultimately, plugins enable us to build and ship features quickly and efficiently regardless of scale. We hope that others can benefit from our experiences and plug into our lessons learned while working with this powerful tool.
Enforcing more code isolation
Isolating feature development is a bedrock principle of scaling application development. As such, architectures for single-screen apps with hundreds of commits a week need to promote code isolation.
Uber Engineering has waxed poetic about our love of RIBs, an architecture that encourages code isolation. If we already know RIBs handle isolation so well, why are we still talking about it?
- Isolating features that are not contained inside RIBs can be useful; for example, isolating multiple location-search data sources from one another allows complex data sources to to be experimented on in parallel.
- Further isolation between RIBs is possible and beneficial.
Below, we discuss a few reasons why further isolation between RIBs can serve as a massive boon to mobile architecture development.
What RIBs already give us
The RIB architecture is designed around the premise of building applications as deep trees of business logic scopes, as demonstrated in Figure 1, below:
This design provides significant isolation benefits. For instance, in our rider app architecture, Airport Refinement, Location Refinement, and Confirmation RIBs must be written independently from one another since they cannot access any of the state owned by one another’s scopes.
Then what isolation are we missing?
Consider the request Refinement Steps example in Figure 1. In our real app, there are 30+ different possible children under Refinement Steps. RIBs enforce isolation between the children, but not between the parent Refinement Steps RIB and children, such as Airport Door Selection RIB.
The Refinement Steps RIB is responsible for attaching and detaching the child RIBs under it, performing ordering between those child RIBs, and passing specific data to them. When executed successfully, the Refinement Steps RIB is completely de-coupled from its children, allowing individual child RIBs to be developed and released into production without impacting the other child RIBs.
However, in a high velocity environment, most engineers have a natural tendency to code with an eye toward minimizing complexity and development time. For example, it is more convenient and faster for an individual engineer to add details that should belong inside the Airport Door Selection RIB to the Refinement Steps RIB in order to support a transition animation from the Airport Door Selection RIB to the Location Refinement RIB. This couples Refinement Steps RIB to its children. Once Refinement Steps RIB is coupled to specific children, its children are by extension coupled together as well. However, this approach results in an engineering organization losing confidence that changing one child will not break the others.
At Uber, we want to enforce isolation between parent and child RIBs, so we need an architecture that does more than enable easy feature isolation (RIBs); it also needs to ensure feature isolation.
Enforcing isolation between parent RIBs and children
A good solution should not require significant changes to the way we structure RIB hierarchies and module relationships (e.g., with dependency inversion), create huge performance costs, or break type safety. Our solution is simple: add an extra layer to enforce that the implementation details of children RIB cannot be referenced by parent RIBs.
We do this by defining a plugin point class (basically a fancy feature factory) that can be referenced by the parent RIB. Returning to the Refinement Steps RIB example above, we allow the plugin point to reference individual Refinement Step RIBs. Next, we use build tooling to ensure that Refinement Steps cannot directly reference any of the plugins, just the plugin point, as shown below in Figure 3:
We use different tooling to guarantee this on both Android and iOS, outlined below:
Similarly to how we enforce isolation between parents and children, we use basic tooling to enforce separation between plugins. We encourage development of feature plugins inside their own build targets (Android modules or iOS frameworks) and enforce that plugin build targets can not reference one another (see Figure 4). This development of separate build targets adds little programmer overhead since RIBs are naturally isolated anyway, while providing isolation benefits and compilation time improvements.
Separating glue from feature code
In many apps, glue code is mixed with feature code. When this approach is taken with an application that contains hundreds of features—at Uber, for instance, we have a lot of useful region-specific features and optimizations—you will start to observe the following:
- Difficulty adding new features. Engineers need to hunt through the codebase to find the correct place to add their feature.
- Impossible to reason about app. In order to understand the high level structure of the application, you have to read/understand both the application’s feature code and glue code. No mere mortal can keep all of this in their head at once!
- Instability. The code that glues the application together changes in parallel with the introduction of new features.
- Limits to product scalability. Engineers begin to integrate features wherever is most convenient. As a result, features can get integrated into the same screen in multiple ways. This creates screens like the example below:
How do we separate the glue?
One way to ensure that application development and product experience stay rational in the long term is to set up strong rails in the app to guide the development of new features.
As Uber refines existing apps and builds new ones, we encourage features to be written as integrations into existing plugin points as much as possible. Each plugin point acts like an integration “rail.” As a result, 80 percent of the Uber rider app’s application layer now lives inside plugins. The remaining 20 percent of the code glues the application together, as shown in Figure 6, below:
We differentiate between plugin code and non-plugin code based on directory structure and add additional code reviewers to all code reviews that touch non-plugin code. This encourages engineers to write their code inside plugins in existing plugin points instead of ad hoc locations.
As a result, our rider app’s hundreds of features are integrated in 50 different ways instead of hundreds. This achieves:
- Ease of feature development. Adding the 501th feature only requires us to consider approximately 50 places to find the correct integration point, instead of evaluating the entire codebase.
- Easy-to-understand app architecture. The 20 percent of code that glues the app together can be reasoned about independently from the features in the app. This is the only code you need to understand in order to understand the app’s high level architecture.
- Stability. The code that glues the application together changes infrequently. When it changes, more eyes (from internal code reviewers) are on it.
- Product scalability. Encouraging engineers to reuse existing integration points in apps when adding new features encourages reuse of existing product designs, making development faster and easier.
Using plugin points: two examples of rails
We can build most features in our applications into existing product or technical rails. Here are two examples from the rider app:
Example A: Location Search
The location search screen displayed above is built around a plugin point that returns LocationRowProvider objects.
When integrating the new Calendar Events feature into the location search screen, a new LocationRowProvider subtype was created and added to the LocationSearchPluginPoint. There was no need to wire up additional data flows in the core of the app or consider major product changes because the plugin architecture encouraged separating the Location Search glue code from the Location Search features, as displayed below in Figure 6:
If the Location Search screen had not been built this way from the start, a new data provider would have been integrated one of many ways. Adding the new data source might have required changes in multiple code locations, including changing the code to add a new data source, the code that presents row views when given a data view model, the location where row view models get sorted and merged, and the datatype of the location view model.
Example B: Scoped Viewless Work
Plugins work well for RIBs, but not all plugins need to be RIBs. The most versatile rails in the app are simple ScopedWork rails. For example, suppose an engineer wants to execute code whenever the rider app’s LoggedInRIB is attached to the application’s RIB tree. The engineer can integrate a plugin into the LoggedInScopedWorkPluginPoint, as showcased in its plugin interface, below:
The LoggedInScopedWork rail has about 30 integrations inside the rider app. If not for this plugin point, the LoggedInInteractor.java would be over a thousand lines long, difficult to modify, and hard to understand.
Benefits to experimentation and safety
Whenever we roll out new features or make changes to our apps, we run A/B tests to ensure these changes improve stability and business metrics, but with hundreds of changes written each week, we can have problems maintaining nested experiments. Inconsistent A/B testing strategies means the codebase will be littered with conditionals that make code harder to reason about.
We mitigate this issue by using plugins. Every plugin is released via an A/B test, which reduces the need to write conditional A/B test branches throughout the rest of the codebase. Since the integration of plugins is consistent, we can leave the A/B plugin flags in the codebase after they have been fully rolled out without increasing its complexity. This gives us the power to remotely disable every feature in the application. For example, if experience a widespread production crash in the start-of-trip-animation, we can fix the issue by disabling the animation remotely. Implementing plugins has empowered us to quickly and effectively resolve multiple large production crashes using this strategy.
We have confidence that it is safe to disable any plugin because on each change we commit, we run UI tests that exercise the core flows of the application with all plugins disabled. Although not pretty, disabling plugins for these tests is highly functional. If we adopted the principles behind plugins (e.g., aggressive usage of abstract factory pattern and dependency inversion) without using a unified plugin framework, we would not be able to enforce this form of safety or easily perform these UI tests.
Should you plugin?
A unified plugin architecture is valuable for large applications with lots of feature integration points and intent to scale. Before you decide to make the leap, however, consider whether or not the benefits outweigh the challenges for your team.
The advantages of a plugin system may not be worthwhile for small engineering operations. Even large applications may not benefit from a formal static plugin system if there are a small number of feature integration points. For example, applications that are primarily feed-centric have a natural division between the application’s glue and feature code regardless of whether you use ad hoc patterns, a plugin system, or dependency inversion to inject feature cards into the card framework. As such, a formal plugin pattern is most useful for large apps with many feature integration points, e.g., apps that contain multiple states and subscreens presented on top of a map: Trulia, Google Maps, and the Uber Driver App.
Once you decide to structure your application as a set of plugin points, you will have to spend time considering the question: “is the app’s menu a single RIB plugin?” or “is it a framework that plugins integrate with?” The answer depends on whether you want the screens that integrate into the menu to be able evolve quickly and/or independently (likely, yes) and whether you want to discourage changes to the menu scaffolding itself.
These discussions are important for determining the best software design fit for your app. Encouraging our team to discuss the scalability of their designs early on was one of the reasons why our mobile engineers are now twice as productive inside our rewritten rider app as the old rider app. In fact, our org’s approval of the architecture following the incorporation of RIBs and plugins more than doubled.
Plugging into our takeaways
Uber’s formal plugin system provides a number of benefits, and the principles behind our decision to integrate them are widely applicable, for instance:
- It is beneficial to maintain a distinction between your app’s feature code and the code that glues your app together.
- A formal system for providing code isolation is useful for extensibility and safe experimentation.
When our engineers build features on top of our applications’ that are pluginized, they are more productive and ship faster.
Interested in scaling one of technology’s fastest growing mobile architectures? Apply for a role on Uber’s Product Platform team.
Brian Attwell is a software engineer on Uber’s Mobile Platform team. He is one of the engineers behind the cross-platform RIB architecture and a co-designer of Uber’s cross-platform plugin architecture. Brian has worked at Google, Facebook, and Apple. He originally presented this article as a talk at Uber Mobility in San Francisco.
For more on the topic, check out software engineer Manu Sridharan’s tech talk at Curry On about how architectural patterns like plugins can make static analysis more effective.