One of the key technology decisions we had to make when DoorDash acquired Caviar in 2019 involved integrating the Caviar iOS app with the existing DoorDash mobile infrastructure and platform. Maintaining a separate tech stack for Caviar was not scalable, nor would it have been efficient. However, we also needed to maintain the Caviar experience for its customer base.
We wanted to change the Caviar app’s underlying infrastructure and platform without disrupting how customers used the app. We previously accomplished a similar project for our web experience, and needed to replicate it for our mobile experiences.
Our solution required rebuilding the DoorDash iOS app on a newly architected platform that could also support theCaviar iOS app. Although an intensive strategy, it would result in a scalable mobile platform capable of supporting additional app brands in the future.
Building Caviar and DoorDash iOS apps from the same codebase
Given the decision to rebuild the Caviar iOS app to integrate with DoorDash, the first thing we needed to do was build a new architecture underneath the current DoorDash app that could support both brands. The goal of this new architecture was to:
- Gain the ability to create separate binaries for each of the apps.
- Share as much code as possible while still being able to build distinct features and experiences.
- Minimize the engineering drag on the current DoorDash consumer iOS team.
With all this in mind we came up with three options that could potentially fit our needs:
- separate xcode targets,
- separate build configurations,
- separate app wrappers on top of a shared static framework
Separate build targets
Of the three options, separate build targets was by far the easiest to set up. With this approach we would duplicate the current DoorDash app target to a Caviar app target, then repeat for the test targets.
This approach had two major highlights: it was easy to implement and it also required minimal changes on the DoorDash side of things. We had an aggressive timeline for the DoorDash/Caviar integration so a simple setup was an attractive aspect of this solution. In addition, the minimal amount of changes to DoorDash’s codebase to facilitate it was also reassuring.
However, this initial development speed comes at the expense of maintainability and future velocity. Having duplicate targets meant that when adding files, engineers would need to make the appropriate selections for target memberships for Caviar, DoorDash, or both. Setting file memberships is pretty straightforward, but is also very easy to mess up. And although these errors are pretty easy to fix, they coulds result in builds failing, which slows down development, especially when they fail on the continuous integration/continuous delivery (CI/CD) machine.
Ultimately, we decided not to take this route. The initial development speed was not worth the impact to maintainability and velocity.
Separate build configurations
Another possible solution used separate Xcode Configuration files (xcconfig) for Caviar and DoorDash. For this approach we would rename our current release and debug xcconfig files to DoorDash-Release and DoorDash-Debug, making those configurations specific to the DoorDash app. We would then duplicate those files to create separate xcconfig files for Caviar debug and release. From there we could create different build schemes that use different xcconfig files for DoorDash and Caviar. This method would allow us to have one app target that could be configured to build either the Caviar or DoorDash based on which configuration file we provided in the build scheme.
With this approach we would be able to keep a single build target for both apps, and doing so would simplify many things. For starters, a single target meant that we would not need to worry about file target membership like we would have in the previous approach. Also, it would mean minimal breaking changes to the current DoorDash environment. To customize Caviar and DoorDash we would simply add or modify the various build time variables as needed.
However, this approach still did not completely fit our needs. Expanding the configuration files to be able to customize Caviar and DoorDash to the level we wanted would have been a tedious and painstaking process. We would need to define variables for all the differences between the two apps and then map them to the correct dependencies. As we continue to expand each experience, this could easily grow to more variables than we could realistically maintain. In addition, the use of build-time variables in the configuration files would have been a pretty indirect way of customizing the the apps
Seperate app wrappers around a common app library
The solution we went with was to extract the current DoorDash iOS app into a static library, then create two separate Caviar and DoorDash app targets that would depend on the library for all shared application code.
We took the existing DoorDash project, stripped out all app-specific pieces, such as AppDelegate, xcassets, xcconfig, and app entitlements, and bundled everything together in a static library we call CommonApp. From there we created two new app targets, one for Caviar and one for DoorDash, to act as app-specific wrappers around the shared code that lives within CommonApp. These app wrapper targets include all the code and logic that are mutually exclusive from each app. Here we include elements like AppDelegate, xcassets, xcconfig, app entitlements, and implementation files unique to each experience.
This approach gave us the ability to easily customize each experience with minimal impact on the other. For cases where we wanted to have different implementations of features between the experiences, we could simply create those implementations in each of the app wrappers and have them used in CommonApp through dependency injection. With this approach, there was a clear separation of the code that nicely reflected reality. Shared code lived in CommonApp and app-specific code lived in the appropriate app targets.
Overall, the only downside to this approach was the amount of effort required on initial setup to extract all the shared code to the static library, CommonApp, and configuring the two new thin targets to inject the proper dependencies to CommonApp. However the maintainability and scalability of this approach was well worth the tradeoff in additional setup time.
Retaining the Caviar look and feel
Now that we had a way to build the apps, we needed to come up with a clean and scalable way to style each app. We had two goals in mind: we wanted to be able to fully customize theming between the Caviar and DoorDash app as well as the ability to easily develop for both experiences. The latter goal would mean being able to set different theming values depending on the experience without having a bunch of if-else statements or ternary operators (see code snippet below).
Our solution involved creating a set of user interface (UI) semantics for colors, iconography, and typography that would abstract away the underlying values and give us a way to provide different sets of values for each app without changing any code at the call sites. Luckily for us, our Design Infrastructure team had already built a design language system (DLS), providing the UI elements we needed.
For colors and icons, our DLS extended the UIColor and UIImage implementations from Apple’s iOS interface framework with static methods for all our semantics and use cases. These methods would map the corresponding underlying values provided by each app’s theme stored in each app's xcassets (Xcode asset catalogs). Similarly, for typography it mapped the corresponding semantics to the correct underlying fonts provided by each app with the appropriate additional attributes applied.
The code snippet below shows how we extend UIColor to include enumerations (enums) for the various color semantics (use cases) we have throughout the app. These enums can then be used to fetch the underlying value they are mapped to in each app’s xcassets.
typealias Color = UIColor
extension Color {
enum Border: String, CaseValueIterable {
case primary = "border/primary"
case secondary = "border/secondary"
}
static func border(_ color: Border) -> Color {
return ColorAssets(name: color.rawValue).color
}
}
Setting values implicitly allows greater flexibility
The DLS let us replace code that sets explicit values with semantics that implicitly set values which can be configured with different values based on the experience.
In the code snippet below see how we would explicitly define colors based on experience (non-semantic colors) versus implicitly defining colors (semantic color). In the non-semantic version, everytime we set a color we’re required to make a check to see which experience we’re in. While this method works, it is tedious, messy, and error prone. In the semantic version, whether we’re implementing for Caviar or DoorDash we can use the same syntax and the same language. The DLS provides the appropriate color through each app’s xcassets.
// Non-semantic colors
var borderColor:UIColor? = isCaviar ? .darkGray : .black
var backgroundColor:UIColor? = isCaviar ? .orange : .red
var foregroundColor:UIColor? = isCaviar ? .white : .lightGray
// Semantic colors via DLS
let borderColor:UIColor? = .border(.secondary)
let backgroundColor: UIColor = .button(.primary(.background))
let foregroundColor: UIColor = .button(.primary(.foreground))
// Non-semantic icons
button.setImage(UIImage(named: "arrow-gray-right"), for: .normal)
// Semantic icons
button.setImage(.small(.arrow(.right)), for: .normal)
As for typography, the DLS enumerated every use case, abstracting the specific font and style from the codebase, as seen in the code snippet below. As such, this same architecture can support both the DoorDash and Caviar iOS apps, with different styles applied to each.
extension DLSKit.Typography {
public enum Default: String, TextStyle, CaseValueIterable {
case MajorPageTitle
case PageTitle
case PageDescriptionBody
case PageSubtext
case TextFieldLabel
case TextFieldText
case TextFieldPlaceholder
case AlertTitle
case AlertText
case AlertAction
case PrimaryButtonText
......
}
}
As with typography, we apply attributes defined in the DLS, such as bolding and font size, which can be seen in the code snippet below.
public func attributes(overrides: TextAttributes = [:]) -> TextAttributes {
let paragraphStyle = NSMutableParagraphStyle()
let textAttributes: TextAttributes = {
switch self {
case .MajorPageTitle:
return DLSKit.Typography.Base.Bold32.attributes(overrides: [:])
case .PageTitle:
return DLSKit.Typography.Base.Bold24.attributes(overrides: [:])
case .PageDescriptionBody:
paragraphStyle.lineHeightMultiple = Default.bodyLineHeight
return DLSKit.Typography.Base.Medium16.attributes(overrides: [
.paragraphStyle: paragraphStyle,
.foregroundColor: Default.subtextColor
])
case .PageSubtext:
.....
}
The code snippet below shows the mapped fonts defined in each apps’ xcassets.
enum Medium: String, CaseValueIterable {
case TTNorms = "medium/TTNorms"
}
enum Regular: String, CaseValueIterable {
case TTNorms = "regular/TTNorms"
}
This architecture allowed for set fonts within the shared codebase without requiring us to do any additional work to have them render properly between experiences.
// Correct underlying font provided by each apps xcassets, so engineers can develop without
// having to worry about picking the correct font for each experience.
textLabel?.font = DLSKit.Typography.Default.ListRowTitle.font()
Building our iOS apps on this new architecture offered a number of practical advantages. When we map semantics to values, app builds are automatically themed, ensuring consistency between versions. When creating new features or other app updates, engineers no longer need to look up the values for UI elements and set them to either DoorDash and Caviar. We just need to use semantics. Future UI or branding updates, or even apps supporting new business lines, become much easier to create.
Rebuilding the Caviar experience
Leveraging the DLS let us build two separate apps from the same codebase themed differently but essentially still the same. The last piece to the project involved customizing the Caviar iOS app to make its experience match its brand.
Once we determined which pieces of the app should differ between experiences, we defined protocols for these branded components. Identifying these pieces let us replace concrete class implementations in CommonApp with abstract definitions that were not brand-aware. Then, with the use of dependency injection, each of the apps could provide their own implementations of these branded components that would customize the experience accordingly.
For example, let’s take a look at the landing view controller. Each app should have its own landing view, the screen users first see when they open the app, that matches the brand experience.
In the code snippet below, we define protocols, including that for the landing view, for brand-specific views.
public protocol LandingViewControllerProtocol: UIViewController {
var landingRouter: LandingRouterProtocol { get }
}
We also include a factory protocol to define the brand-specific views, as shown in this code snippet.
public protocol BrandedViewFactoryProtocol {
/// Use Case: Splash
func makePreSilentSignInMigrationService() -> PreSilentSignInMigrationServiceProtocol?
func makeLaunchView() -> UIView
func makeStaticSplashView() -> StaticSplashViewProtocol
/// Use Case: Landing
func makeLandingViewController() -> LandingViewControllerProtocol
func makeStorePageHeaderView() -> StorePageHeaderView
func makeLoginBannerView() -> UIView?
func makeLoginHeaderView(isSignIn: Bool) -> LoginHeaderViewProtocol
func makeVerifyEmailModule(email: String) -> VerifyEmailModuleProtocol?
func makeVerifyEmailSuccessModal() -> VerifyEmailSuccessModalProtocol?
}
Now in CommonApp we can replace instances of these concrete classes with protocols that can be injected with brand-specific implementations.
@ResolverInjected private var brandedViewFactory: BrandedViewFactoryProtocol
....
func tableView(_ tableView: UITableView, headerFor storeViewModel: StoreHeaderViewModelV2) -> UIView {
let view = brandedViewFactory.makeStorePageHeaderView()
let presenter = StoreHeaderPresenter(view: view)
view.delegate = self
presenter.present(with: storeViewModel)
storeHeaderPresenter = presenter
return view
}
With our protocols defined, we’re able to provide two separate implementations of the store page header that are customized differently between the Caviar and DoorDash iOS apps.
We defined the landing view protocols using the code shown above. The code snippet below shows how we initiate the different landing views shown in Figure 4, below, in the different instances of our iOS apps.
class LandingModule {
@ResolverInjected private var brandedViewFactory: BrandedViewFactoryProtocol
let viewController: UINavigationController
init() {
self.viewController = UINavigationController()
viewController.viewControllers = [brandedViewFactory.makeLandingViewController()]
}
}
Likewise, we’re able to provide two separate implementations of the landing screen with little modification to the shared code.
This approach lets us selectively customize portions of the codebase without impacting the surrounding code, reducing the possibility of introducing errors. The ability to customize the end-user experience between the two apps gives us great flexibility in supporting DoorDash business lines.
Conclusion
Although the addition of Caviar to DoorDash forced us to re-architect our iOS apps, we ended up with a much more scalable overall solution. With the work described above complete, we now have a single codebase we can use to build both the DoorDash and Caviar apps. These apps use distinct themes and branding, yet share 90% of their code. Our mobile team can further customize each app without muddying the shared code, increasing reliability. That shared codebase also means we can make overall improvements and add features for both apps at the same time.
It’s not uncommon for a company to launch with a single app, then grow to the point it needs to support new business lines with new apps. In the launch stage, building a DLS and architecting for multiple build targets to support a single app may not seem to make sense. However, rapid growth can make it difficult to invest the time in building a scalable architecture. Setting up such an architecture at the outset can alleviate many problems later.