DoorDash has been on a hiring binge since the company was founded, often doubling or tripling in size each year. Over the last 2-3 years, this was particularly true for our Android teams as the platform has become more critical to the company. We’ve been aggressively growing our Android teams and will continue to do so.
One of the most commonly asked questions from candidates is what our "Tech Stack" on Android looks like. It's a bit different for each candidate but almost always comes down to the following questions:
- Are you using Kotlin or Java?
- What 3rd party libraries do you use? Recently this usually boils down to interest in Retrofit, Dagger, and Rx.
- What architectural patterns are used in the app?
- Do the apps share code?
- How do you evaluate what tools to use?
We currently have three Android apps available to the public via the Google Play store.
- DoorDash - Food Delivery - Our consumer app for ordering food.
- Dasher - The app used by dashers to facilitate delivery.
- Order Manager - The app used by many of our Merchant restaurants to track orders they receive from DoorDash.
In this post we'll be answering the above questions with regard to our Android Dasher app.
Before we begin though, we want to remind you that we are currently hiring across the board for our teams, so if you find any of this interesting and want to work in a dynamic, high-growth environment please apply to one of our open positions here.
As of the writing of this blog, DoorDash has been in business for over 6 years and maintaining a growth rate of at least 2-3x/year. Like most startups, we had a scrappy beginning and spent most of our time focused on just getting apps running and shipped with minimum features needed to compete. We developed and shipped the apps as if the business depended on it because usually it did. Over time that created a lot of tech debt.
Starting ~18 months ago we found ourselves at a happy balance point where we had the scale to start re-architecting the app and also the business motivation to support the work. The information that follows is the result of that work.
Some guidelines we use for how we build things:
- KISS (Keep It Super Simple) - New people joining the team can understand it quickly. You should be able to just pull and compile without any fuss.
- Don't reinvent the wheel - We shouldn't waste time solving problems that already have solutions
- Be trend-less and expect that we have to replace everything - It should be easy to incorporate new tools/libraries. The approach should not be bound or dependent on a particular technology, tool, or trend.
Do the apps share code?
Currently the apps do share quite a bit of common, platform level code. We maintain a separate repository of common components that are shared between the apps to provide functionality that we want to be identical like feature flags, authentication/login, mapping controls, etc. To help keep things focused we've intentionally excluded any further discussions about common code from this post and put the emphasis on just the app. We'll have separate blog posts where we discuss our approach to common functionality and code.
Do you use Java or Kotlin?
Up until recently, we had
a strong and growing preference for Kotlin, but we didn't force its use and allowed using Java if a team member felt strongly about it. Large portions of the app are still written in both Java and Kotlin so over time any developer on our team ends up being fluent in both. As of the writing of this blog post it's about 67/33 according to GitHub.
Now that Google has made it generally clear that they intend to standardize more on Kotlin we've started enforcing its use.
100% of new code is written in Kotlin and we strongly encourage team members to refactor/rewrite older Java classes in Kotlin wherever possible.
What tools/libraries do you use?
We use a lot of open source tools in our Android apps. As noted above, we like the structure of the app to be trendless and aim to never bind the app to any given tool or library in a way that compromises our ability to change it.
We assume that we're going to have to replace any given technology we're using in the near future and build with that assumption in mind. Sometimes this means that it takes a little more time to make effective use of something new, but it tends to come with the benefit of allowing us to quickly try out, adopt, or swap out components with relative ease.
Developers on the team are strongly encouraged to propose and test new tools, approaches, and techniques. We don’t always end up using them but experimentation is valued and encouraged.
Here are most of the current high-profile tools we're using that candidates usually ask about:
- Github - For source control and code review
- Dagger - For dependency injection across the app
- Rx - For management of asynchronous interactions between our UI and Domain layers, and between our Domain and Data layers.
- Room - For local database caching
- Retrofit - For service and network interactions
- Bitrise - For build generation and CI
- Fabric/Firebase - For crash analysis
At a high level, the app is architected using a layered, N-Tier pattern that adheres to basic CLEAN principles. Namely:
- Clear separation of concerns
- Immutability between layers
- One-way dependency
In addition to the above, we enforce an asynchronous publish/subscribe pattern of communication between layers.
Here's the 10,000-foot view of the app architecture that we teach to every new member of the team:
At a high level the app is built on 3 core layers:
- UI - Responsible for presentation of features and managing user interaction.
- Domain - Exposes core functionality/operations on data and responsible for all business decisions/calculations.
- Data - Source of truth in the app. Provides service interactions, caching of data, and notification of data changes.
Each layer is isolated from the others and designed to work on its own.
What about MVC, MVP, MVVM, and MVI?
This gets asked a lot by candidates. We actually use these patterns throughout the apps quite a bit, but we generally view them as UI-level patterns. The "M" (aka Model) noted in all of these patterns is by far the most complex portion of each of our apps and is represented in the diagram above by the Domain & Data layers.
This StackOverflow post
provides more insight into how we view the “Model”.
In the Dasher app we use a combination of MVP and MVVM in our UI layer. A lot of legacy UI uses MVP(~30%), but at this point the majority of UI(~70%) is built using MVVM.
Our UI layer and MVVM:
architectural components from Google became publicly available right around the same time that we started overhauling the architecture of the app and the benefits offered by them were very difficult to argue against. As such, as soon as they became available, we started using them for all new UI work.
Our Dasher app has to provide UI and features to deal with the myriad issues that occur while delivering hot, perishable food(which is significant). As such the number and variety of screens and states in the app is high. Like any other complicated app, we had a lot of problems with lifecycle issues and communication between components. We decided to go with the MVVM approach for a few key reasons:
- It solves most lifecycle-related pains
- It aligns well with our asynchronous publish/subscribe patterns.
- It’s supported by Google for Android and will see continued investment.
Here's a more detailed diagram of our UI layer.
It's a pretty simple approach. We consolidate as much UI logic as possible into ViewModels and use LiveData exclusively to communicate to views. So far it’s been working really well.
Some additional UI rules and guidelines we use:
- Don’t force 1-to-1 relationships between ViewModels and Views. We want the ViewModels to represent functionality and be reusable where possible.
- Keep Views (Activities/Fragments) as simple as possible with minimal logic.
- Avoid using complex business types(eg Delivery, Dash, User) in LiveData. Instead use of either simple types or a ViewState pattern for updates.
- Favor fewer activities that host multiple Fragments. Target 1 activity per high-level feature.
- Make use of common base fragments and activities that enforce consistent behavior/views/controls.
The Domain layer:
The domain layer in the app is responsible for exposing business-level functionality, logic and managing interaction with the data layer. Most of its functionality is implemented as a set of Singleton objects that provide a related set of data, operations, and calculations to the UI.
These classes don't maintain data or state, but rather they interact with the data layer, and modify and make calculations based on information from it. In that way, we tend to think of them more as stateless abstractions for encapsulating our business rules. We call them "Managers" for historical reasons that we won’t get into here, and all interactions with them are asynchronous or fire-and-forget.
As an example, we have a “DashManager” that contains all the logic for interacting with Dashes (the dashes that dashers schedule).
As noted above, our domain layer looks like the following:
When we show this during an interview, people usually ask:
- What's Reflex?
- What’s the Facade for?
As our domain layer became more formal we needed a way to channel specific domain-level updates between the Manager classes without creating direct dependencies.
The manager class that controls dashes would want to know when an auth token became expired, or the manager for deliveries would need to know when a dasher’s dash starts, ends, or get paused. The solution was what we ended up calling "Reflex". It acts as a notification conduit and light state management mechanism for asynchronous domain updates.
What's the Facade for?
We're just making use of a simple facade pattern here. We have a lot of scenarios where we need to be able to override/restructure information that comes from the domain layer and we use "Facade" classes to do so.
When we want to manually simulate a UI experiment locally (there's a backend system that controls it globally) we use the Facade pattern to allow us to enable/disable it on the local device during testing/development.
We view our data layer as the "source of truth" in the app. It's responsible for all interactions with our services and local storage of any information we want to cache. As a general rule we cache and store all information we get from interactions between the app, user, and service locally. Depending on the type of information and how long we need to persist it that "caching" may be on the local disk, in memory, shared preferences, or our local database. At a high level our data layer is pretty straightforward. The majority of it is built using the Repository
pattern Google recommends as follows:
A few notes about how we use our data layer:
- We use Room for database storage. So far we’re happy with it.
- Data that gets passed up is abstracted. The domain layer objects making the call should never have to differentiate between Room db objects, retrofit responses, etc.
- The data layer is only concerned with how to get, store, and update information, not interpreting it.
- All interactions with the repository are asynchronous by design.
One clear lesson from the last 18 months is that while our business is growing so fast our architectural plans are going to change constantly as well. If you look at what we had planned initially vs what's we've actually built you'd find quite a bit of difference.
It's a company value to constantly observe the reality of what's going on and make changes to accommodate it. That's especially true with our architectural plans. If we find something isn't working well, we change it. If new challenges arise, we accommodate them, and we experiment constantly to help figure out what's going well or what isn't.
This post only describes what things look like right right now. That's going to change dramatically over the next 3-12 months and will be impacted by every member of the team. If that, or anything we've discussed in this post sounds interesting to you, we're hiring
! Reach out and tell us about you.