By Yohan Hartanto and Sami Aref
This article is the second in a series covering how Uber’s mobile engineering team developed the newest version of our driver app, codenamed Carbon, a core component of our ridesharing business. Among other new features, the app lets our population of over three million driver-partners find fares, get directions, and track their earnings. We began designing the new app in conjunction with feedback from our driver-partners in 2017 and began rolling it out for production in September 2018.
Building an app from the ground up raises numerous questions concerning architecture and design. By far the most simple trap for engineers is to develop in a bubble, working from a fixed idea of how the app should work. Fortunately, customer-focused design has become a strong theme in software development, but identifying your customer isn’t always a straightforward question.
Once we decided to rewrite Uber’s driver app, we turned to our extensive and diverse user base for insights on how to design workflows and which new features would be most useful. The feedback from our driver-partners in cities around the world served as a crucial motivation for our initial UX design.
At the same time, we had another population to consider: the hundreds of engineers, now and in the future, who would be building the app and iterating on features. A well-thought-out app architecture would help these engineers to work more efficiently, delivering features quickly while maintaining reliability.
Fortunately, serving both groups, including implementing a smart workflow and improved features for drivers and a flexible, reliable architecture for our engineers, does not create conflict. In fact, the needs of both groups dovetail nicely.
In this article, we outline how we came up with the core requirements for our new driver app (codenamed Carbon) and discuss how we leveraged a combination of RIBs architecture and plugin design patterns to build our application logic.
Developing at scale
First released in 2013, the Uber driver app would accumulate many features over the next four years that would become essential to our driver-partners. As the app grew in complexity, so did Uber as an organization. Hundreds of features in the app were built and maintained by more than 40 different sub-teams across the company. By January 2017, our Android driver app codebase had 428,685 lines of code, contributed by nearly 200 engineers. The iOS app had 720,273 lines of code, contributed by over 200 engineers. More importantly, our apps were installed in more than three million devices and used daily by one million drivers across more than 100 countries.
For Carbon to be successful, we knew we had to deliver all of these existing features (as well as a few new ones) in a timely manner, and parallelize as much as we could.
Product development works best when keeping the end user in mind. To that end, we wanted to build the app together with the Uber driver-partner community. During the initial stage of development, we invested heavily in user research, interviewing 500 drivers in 12 cities across 11 countries.
These interviews helped us design the new app’s UX and determine its most important features. However, there is no way to capture the full experience unless drivers are able to take the app on the road and test in real-world conditions. We needed to build Carbon in a way that would allow us to gather feedback, iterate quickly, and release new versions on a weekly cadence.
The hardest and most important challenge involves reliability during real-world testing. When you introduce a new app, there is an established tolerance for faults or issues during the alpha and beta stages. In our case, the beta phase involved real drivers on the road trying to make money. Achieving reliability was a key goal for Carbon. So, as we moved into the rollout stage, we had to ensure that Carbon would be, at a minimum, as reliable as the existing app even during beta.
Unlocking development at scale
Having laid out the constraints of our project, we adopted a staged approach by dividing the project into four phases. The goal of each phase was to unlock the development of the next phase.
Phase 0: Infrastructure
Internally, we had a general template describing what all of our apps must include. This template consisted of libraries for networking, storage, ReactiveX, analytics tracking, crash reporting, and our homegrown application architecture framework, RIBs. Leveraging this template, we built an initial skeleton app with the capacity for storage, networking, crash reporting, and infrastructure components. However, in this phase, the skeleton app lacked features or any functionality for drivers, merely serving as the scaffold on which we would build our features.
Phase 1: Application root
One of the main benefits of using RIBs architecture is how it puts the emphasis on business logic as the core of the application architecture. In the case of Carbon, a good starting point would be to define the high-level user states for the driver. This led us to define these foundational RIBs:
- Root: When bootstrapping our application, the root contains all the necessary boilerplate to launch a RIBs-based application
- Logged Out: If the user does not have a valid session, we need a RIB for them to create an account, log in, and obtain valid credentials.
- Logged In: Once a user is authenticated, the Logged Out RIB is detached, and the Logged In RIB is attached with a valid session.
- Active: Sometimes a driver can be logged in, but not active (their account could have been locked out for a variety of reasons). This RIB ensures that they have a valid session and are allowed to use the application.
Internally, we leverage a RIBs tree diagram, as depicted in Figure 2, below, to represent the app architecture. This simple tree-like diagram shows how RIBs components relate to each other.
By focusing on the user state, we can decouple ourselves from the UI. This approach lets us more easily incorporate ongoing feedback from driver-partners into the application design, while enabling us to maintain the fundamental root of the application.
Phase 2: Feature frameworks
With some base-level scaffolding in place, our focus shifted to collaboration. In phase 2, our goal was to scale development to allow around 40 teams to reliably and seamlessly work in parallel on the same app. Based on our user feedback and in collaboration with design and product teams, we were able to define more detailed RIBs and components:
- On Task: How drivers experience the app when they are online and working (i.e., navigating to riders, beginning a trip, dropping off riders, picking up an order, etc.).
- Agenda (Trip Planner): The key place where drivers manage all of their upcoming tasks. Here, they can see everything they need to do, from picking up riders to dropping off food. And when it’s empty, this is where we offer suggestions on where and when to drive.
- My Hub: This is the area where drivers can manage their business. It’s where everything outside of the actual driving is contained: important notifications, ratings, earnings, and more.
- Map: Many map-related features in our driver app, such as navigation, surge indicators and others were built using our new RIBs-oriented map library, which is an abstraction layer built on top of our mapping frameworks. An interesting note, as illustrated below, is that this map library is considered a non-core feature in the app, meaning that even if we run into a catastrophic failure with our map functionality (which we hope never happens!), we can disable it and allow drivers to progress through the job flow.
After incorporating these feature frameworks, our little RIBs tree grew larger, as shown in Figure 3, below.
Before we dive into the specifics, let’s clarify some of the concepts we use in our RIBs architecture:
This is is an object that has start/stop lifecycle methods that directly correlate to a RIBs lifecycle. In other words, a worker added to to a RIB starts when that RIB is attached, and stops when that RIB is detached. Workers ensure that our interactors (the business logic component for a RIB) don’t get too large, and allow for better separation of concerns. (Available in our Android and iOS repositories.)
Plugins are a design pattern that allows us to feature flag our code in a scalable manner. (Learn more about how Uber leverages plugins in a previous article). We first define a public API for our plugins in our core code for integration, and developers can then implement their own instance of this API knowing that this code is implicitly guarded by a feature flag conforming to this interface. Think of each plugin point as if it were a service in a microservice architecture, and the plugin factory as the consumer to that service, which would make the plugins similar to the API or the contract between them.
Combining RIBs, Plugins, and Workers, we now can define what we consider to be core and non-core components in our architecture. Core components are considered essential and can’t be disabled by a feature flag. Non-Core components, on the other hand, can be disabled if they introduce a major issue or regression to the app. In Figure 4, above, Map and MyHub are examples of non-core components, which can be disabled without shutting down the app’s basic functionality.
If we were to zoom into a section of our RIBs tree, as in Figure 5, below, we can see how we use Core RIBs with plugins and workers to support the overall functionality of the agenda using a worker/plugin pattern. The Agenda feature exposes two different plugin points, Agenda Worker and Agenda Section. We use the Worker plugin point to facilitate non-UX integrations to the Agenda RIBs, and the Section plugin point to extend the Agenda’s UX.
Using this design pattern, we could unlock development for most areas of the app. For instance while some engineers build the login and signup screens, others can focus on exposing the map framework to the active non core RIBs.
Phase 3: All aboard
In phase 3, we opened up the Carbon development floodgate by onboarding our feature teams. Since we created our plugin framework to ensure that every feature built moving forward is independent of one another, feature incorporation was a relatively frictionless process. If necessary, a few RIBs were promoted to core status, but the majority of our code remained wrapped inside plugins, and therefore optional in our architecture. (We plan to discuss some of these exciting new features in future articles).
Architecture in software engineering is most often about prioritizing the values that are most important to your organization and making compromises on others. While our approach allowed us to optimize for reliability, scalability, and modularity, there were other areas where we had to compromise. In our disciplined and rigorous process, we allow very few components in our app to be core. To uphold the quality of core components, we have a group of internal reviewers that review every code submission wishing to change core code. This process requires some engineers to dedicate their time to these core reviews, and it slows down the submission process for other engineers.
Most application developers are familiar with the concept of Model View Presenter or Model View Controller. Compared to them, RIBs seems more verbose. It has more components and requires more upfront planning. A smaller engineering organization may not need to employ a similar process. Having previously gone through a rewrite with our rider app, we had a clearer sense of how we wanted to build Carbon, and we can summarize our learnings as follows:
- Working at scale: At Uber, scale is both a top constraint and most valuable resource. Three million drivers use our app and we can’t fail them. At the same time, as an engineering organization we have grown to a point where we have hundreds of engineers available to work together building the app. Scale is key to all of our decision-making, from the planning all the way to the rollout.
- RIBs to the rescue: By utilizing RIBs and the plugin infrastructure for Carbon, we gained the benefits of the architure in key ways:
- Modularity: Each feature is developed in a way that does not depend on another unless it is Core RIBs. This architecture makes it easier for engineering teams to develop feature RIBs in parallel, without worrying how one affects another.
- Extensibility: The tree-like architecture can be extended vertically by adding children and horizontally by adding siblings or segregating business logic in different workers.
- Reliability: The core vs non-core concept implemented using the plugin framework allows us to disable non-core parts of the code, so we can move fast but minimize breaking things.
- Building together: We could not have done this without the help provided by our driver-partners, and the key insights they provided to us during our user research and beta testing phases.
Index to driver app article series
- Why We Decided to Rewrite Uber’s Driver App
- Architecting Uber’s New Driver App in RIBs