June 30, 2014 was my first day at Lyft as the first iOS hire on the ~3 person team. The app was written in Objective-C, and the architecture was a 5000-line nested switch statement.
Since then, the team has grown to about 70 people and the codebase to 1.5M lines of code. This required some major changes to how we architect our code, and since it had been a while since we've given an update like this, now seems as good a time as any.
The effort to overhaul and modernize the architecture began around mid-2017. We started to reach the limits of the patterns we established in the 2015 rewrite of the app, and it was clear the codebase and the team would continue to grow and probably more rapidly than it had in the past.
The primary problems that the lack of a more mature architecture presented and that we wanted to solve were:
- Isolation: Features were heavily intertwined, which made it difficult to safely make changes
- Testability: We were still mostly following MVC with view controllers being the main source of business logic which made it difficult to test that logic
- State management: The navigation in most of our app relied on local + server state, which grew with the number of features. How and when state changed then became too difficult to manage.
There was not going to be one solution that would solve all of this inherently, but over the course of a few years we developed a number of processes and technical solutions to reduce these problems.
First, to provide better feature separation, we introduced modules. Every feature had its own module, with its own test suite, that could be developed in isolation from other modules. This forced us to think more about public APIs and hiding implementation details behind them. Compile times improved, and it required much less collaboration with other teams to make changes.
We also introduced an ownership model that ensured each module has at least one team that's responsible for that module's tech debt, documentation, etc.
After fully modularizing the app and having 700 modules worth of code, we took this a step further and introduced a number of module types that each module would follow.
UImodules only contain UI elements (views, view controllers)
Flowmodules contain routing infrastructure
Servicemodules contain code to interact with endpoints related to the feature's functionality
Logicmodules contain pure business logic, data transformations, etc.
Breaking modules down this way enabled us to implement dependency validators: we
can validate that certain modules can't depend on others. For example, a logic
module can't depend on a
UI module, and a
Service module can't import UIKit.
This module structure also prevents complicated circular dependencies,
Coupons module depending on
Payments and vice versa. Instead, the
Payments module can now import
CouponsUI without needing to import the full
Coupons feature. It's led to micromodules in some areas, but we've generally
been able to provide good tooling to make this easier to deal with.
All in all we now have almost 2000 modules total for all Lyft apps.
Module types solved many of our dependency tree problems at the module level, but we also needed something more scalable than singletons at the code level.
For that we've built a lightweight dependency injection framework which we detailed in a SLUG talk. It resembles a service locator pattern, with a basic dictionary mapping protocols to instantiations:
The implementation of
bind() doesn't immediately return
but requires the object be mocked if we're in a testing environment:
1 2 3 4 5 6 7 8 9 10 11 12
In tests, the mock is required or the test will crash:
1 2 3 4 5 6 7 8 9
This brings two benefits:
- It forces developers to mock objects in tests, avoiding production side effects like making network requests
- It provided a gradual adoption path rather than updating the entire app at once through some more advanced system
Although this framework has some of the same problems as other Service Locator implementations, it works well enough for us and the limitations are generally acceptable.
Flows, inspired by Square's Workflow, are the backbone of all Lyft apps.
Flows define the navigation rules around a number of related screens the user
can navigate to. The term
flow was already common in everyday communications
("after finishing the in-ride flow we present the user with the payments flow")
so this terminology mapped nicely to familiar terminology.
Flows rely on state-driven routers that can either show a screen, or route to other routers that driven by different state. This makes them easy to compose, which promoted the goal of feature isolation.
At the core of flows lies the
1 2 3
It just has to be able to produce a view controller. The (simplified) router part of a flow is implemented like this:
1 2 3 4 5 6 7 8 9 10 11
In other words: it takes a bunch of rules where if the condition is true
(accepting the flow's state as input) it provides a
Routable. Each flow defines
its own possible routes and matches those to a
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
We're then composing flows by adding
Routable conformance to each flow and
have it provide a view controller, adding its current
controller as a child:
1 2 3 4 5 6 7
Now a flow can also route to another flow by adding an entry to its router:
This pattern could let you build entire trees of flows:
When we first conceptualized flows we imagined having a tree of about 20 flows total; today we have more than 80. Flows have become the "unit of development" of our apps: developers no longer need to care about the full application or a single module, but can build an ad-hoc app with just the flow they're working on.
Although flows simplify state management and navigation, the logic of the individual screens within a flow could still be very intertwined. To mitigate that problem, we've introduced plugins. Plugins allow for attaching functionality to a flow without the flow even knowing that the plugin exists.
For example, to add more screens to the OnboardingFlow from above, we can expose a method on it that would call into its router:
1 2 3 4 5 6 7 8
Since this method is public, any plugin that imports it can add a new screen. The flow doesn't know anything about this plugin, so the entire dependency tree is inverted with plugins. Instead of a flow depending on all the functionalities of all of its plugins, it provides a simple interface that lets plugins extend this functionality in isolation by having them depend on the flow.
Since all Lyft apps operate on a tree of flows, the overall dependency graph changes from a tree shape to a "bubble" shape:
This setup provides feature isolation at the compiler level which makes it much harder to accidentally intertwine features. Each plugin also has its own feature flag, making it very easy to disable a feature if necessary.
In addition to routing plugins, we also provide interfaces to add additional views to any view controller, deep link plugins to deep link to any arbitrary part of the app, list plugins to build lists with custom content, and a few others very unique to Lyft's use cases.
Unidirectional Data Flow
More recently we introduced a redux-like unidirectional data flow (UDF) for screens and views within flows. Flows were optimized for state management within a collection of screens, the UDF brings the same benefits we saw there to individual screens.
A typical redux implementation has state flowing into the UI and actions that modify state coming out of the UI. Influenced by The Composable Architecture, our implementation of redux actions also includes executing side effects to interact with the environment (network, disk, notifications, etc.).
In 2018, we began building out our Design System. At the time, it was a layer on top of UIKit, often with a slightly modernized API, that would provide UI elements with common defaults like fonts, colors, icons, dimensions, etc.
When Apple introduced SwiftUI in mid-2019, it required a deployment target of iOS 13. At the time, we still supported iOS 10 and even today we still support iOS 12 so we still can't use it.
However, we did write an internal library called
DeclarativeUI, which provides
the same declarative APIs that SwiftUI brings but leveraging the Design System
we had already built. Even better, we've built
binding conveniences into both
DeclarativeUI and our UDF
Store types to make them work together seamlessly:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
Putting it all together
All these technologies combined make for a completely different developer experience now than five years ago. Doing the right thing is easy, doing the wrong thing is difficult. Features are isolated from each other, and even feature components are separated from each other in different modules.
Testing was never easier: unit tests for modules with pure business logic, snapshot tests for UI modules, and for integration tests it takes little effort to sping up a standalone app with just the flow you're interested in.
State is easy to track with debug conveniences built into the architectures, building UI is more enjoyable than it was with plain UIKit, and adding a feature from 1 app into another is often just a matter of attaching the plugin to a second flow without detangling it from all other features on that screen.
It's amazing to look back at where the codebase started some 6 years ago, and where it is now. Who knows where it will be in another 6 years!
Note: If you're interested in hearing more, I also talked about many of these technologies on the Lyft Mobile Podcast!