When we discuss the eBay Motors app, the question we are most often asked is, “Which state management solution does eBay Motors use?” The simple answer is we mostly manage state in StatefulWidgets, using Provider or InheritedWidgets to expose this state down the widget tree.
However, we think there is a more interesting question we should be asked: “How did eBay Motors design their codebase in a way that the choice of state management tool does not matter?”
We believe that choosing the right tool, or applying a single design pattern, is much less important than establishing clear contracts and boundaries between the unique features and components in the application. Without boundaries, it becomes too easy to write unmaintainable code that doesn’t scale as the application grows in complexity.
“Unmaintainable code” can be defined in several ways:
- changes in one area that creates bugs in seemingly unrelated areas;
- code that is difficult to reason about;
- code that is difficult to write automated tests for; or
- code with unexpected behaviors.
Any of these issues in your code will slow down your velocity and make it more difficult to deliver value to your users.
Creating clear boundaries between different areas of the code reduces these problems. This approach encourages you to break down large, complex problems into smaller, more manageable pieces. It encourages different domains to communicate via abstractions, and allows for private implementation details to be encapsulated. It also reduces unexpected coupling and side effects, ultimately leading to a more flexible design that is easier to change. Most importantly, establishing these contracts forces engineers to really understand the problem space and the features they’re building, which always yields better results.
Most of our team had already worked together for several years on codebases we inherited, and we knew from experience that it was important to add clear domain boundaries from the very start.
As a team, we agreed the best way to start codifying our boundaries was to create separate Flutter packages for each of the major screens in our app. This was a forcing function that served multiple purposes. First, it allowed our team members to work independently on separate screens without stepping on each other’s toes. We wanted to provide space for experimentation and for engineers to discover which patterns worked best for us in a new technology stack. Second, it supported our team’s goal of ensuring that all behavior was covered by tests. In order to pass continuous integration (CI) checks, each package needed to be fully covered by automated tests. Our boundaries forced each package to be independently testable, which increased our confidence as we developed.
The APIs for these packages were often simple and straightforward. Each package exposed a widget that represented the entire screen, and it would define the dependencies it needed to fulfill its purpose. Everything else in the package was private. As a result, the look and feel, user interactions and state management of the screen were implementation details that could freely evolve without impacting other parts of the application.
As we began coding, we stubbed out the few packages that represented our first set of features. This included making skeletons for a Home Screen, a Search Screen and a Vehicle Details Screen for viewing more information about a listing. Our top-level app package focused on properly stitching these packages together to create a functional user flow. With this in place, we could divide and conquer and easily work in parallel.
At this point, our team members continued to test and learn, identifying what worked best for us while using Flutter. We started almost exclusively using the BLOC pattern in each of these packages and explored adding other design patterns we had been accustomed to from traditional native development. Throughout this early period, the only things that remained constant were our package boundaries and a focus on achieving 100% test coverage via each package’s public API.
After a few weeks, our understanding of Flutter’s widget tree grew, and we started to recognize that the patterns we were applying for state management weren’t serving us well. They forced us to create extra layers of abstraction, unnecessary boilerplate and made the codebase too complex. In many cases, we learned that we could solve the same problem with a simple StatefulWidget and far less code. At this point, the value of our testing strategy became apparent. Because we were testing via the package’s public API, our tests were not coupled to the implementation details, but instead were focused on asserting the behavior of the package. This allowed us to ruthlessly refactor and swap out layers of code wholesale, often without changing a single line of testing code.
As the app grew in both size and complexity, the number of packages we have created has grown. Today, after two years of development, our monorepo consists of about 240,000 lines of Dart code and 5,500 tests spread across 80 packages. Over the past 24 months, a few patterns have emerged for us with regards to our state management.
A fair amount of state gets created during app initialization that needs to survive for the life of the application. Our Application Package is responsible for initializing and holding a reference to this state, often with a StatefulWidget near the top of our Widget Tree. It then dependency injects these classes or behaviors down the widget tree via Provider or InheritedWidgets.
Within each domain’s package, there is often state that is scoped to a particular screen. We intentionally do not apply a consistent pattern here. Each package has evolved to use whichever state management solution is appropriate for the job. We’ve applied many patterns with success (and some without success!), including BLOC, InheritedModel and exposing Streams and ValueListenables via InheritedWidgets. In many cases, we’ve swapped out state management tools, and in many more, we plan to. Our approach has been to listen to the code, and choose the best tool that fits the needs of that particular domain. It has been more a matter of style, than a key architectural decision.
To better understand the approach we’ve taken to our package structure, let’s look at an example.
One of the key features in our buying flow is the ability to search for vehicles on our Search Screen, and navigate to a Vehicle Details Screen to learn more about the vehicle and purchase it.
If we break these screens down to their simplest requirements, they look something like this:
Search Screen
- Integrates with Search API
- Provides Infinite Scrolling of Listings
- Provides mechanisms to filter and sort through results
- Needs to navigate to another screen upon tapping a listing
Vehicle Detail Screen
- Integrates with a Listing Details API
- Provides rich content about a particular listing
- Needs to navigate to other screens in order to transact on the listing (chat, buy, place bid, etc.)
These two screens feel distinct and have very different reasons to change. They are ideal candidates to be separated by clear boundaries.
Let’s start by modeling the contract for the Search Screen. For this screen to be independently tested, two dependencies should be injected. In this example, we chose to inject these dependencies into an InheritedWidget that sits closer to the root of the widget tree than our Search Screen widget.
class SearchDependencies extends InheritedWidget { const SearchDependencies({ @required this.searchApi, @required this.onSearchResultTapped, @required Widget child, }) : super(child: child); final Iterable<SearchResult> Function(SearchParameters, int offset, int limit) searchApi; final void Function(BuildContext context, String listingId) onSearchResultTapped; static SearchDependencies of(BuildContext context) => context.dependOnInheritedWidgetOfExactType<SearchDependencies>(); @override bool updateShouldNotify(covariant SearchDependencies oldWidget) => oldWidget.searchApi != searchApi || oldWidget.onSearchResultTapped != onSearchResultTapped; }
Note: In this example, we are only injecting a few dependencies. In our app, we inject many dependencies into our domain packages such as APIs for analytics reporting, feature flags, platform APIs, etc.
This enables any widget within the Search package to access these dependencies using a BuildContext: SearchDependencies.of(context). This is conceptually no different than accessing Theme.of(context) or any of the other built-in InheritedWidgets.
class SearchResultCard extends StatelessWidget { const SearchResultCard({@required this.listingId}); final String listingId; @override Widget build(BuildContext context) { return Card( child: InkWell( onTap: () => SearchDependencies.of(context).onSearchResultTapped(context, listingId), child: Column( children: [ // some UI here ], ), ), ); } }
From a testing perspective, we can simply inject whatever fake implementations are needed for a given test case and can fully test the behavior of the Search Screen package.
return SearchDependencies( searchApi: (searchParams, offset, pageSize) => [ /* Stub data here */ ], onSearchResultTapped: (context, listingId) => { /* Stubbed implementation here */}, child: SearchResultsScreen(), );
While we sometimes use this strategy to unit test individual widgets, we often test the top level public widget of the package. This helps ensure that our tests validate the overall behavior and aren’t coupled to implementation details. We even use this strategy to provide mock data to perform full screen screenshot tests. You can read more about that in our previous blog post: Screenshot Testing with Flutter.
This approach to dependency management has other benefits as well. We use the same approach for our Vehicle Detail Screen. While the primary use case is to render a live eBay listing, we have a feature in our selling flow that allows for a seller to preview their listing before publishing. This preview functionality was easily achieved by wrapping the Vehicle Detail widget with different dependencies for that use case.
Now that we have the public API for our Search Screen package, let’s look at how we might integrate it within our application.
class _AppState extends State<App> { ApiClient apiClient = EbayApiClient(); AppNavigator navigator = AppNavigator(); @override Widget build(BuildContext context) { return dependencies( [ (app) => SearchDependencies( searchApi: apiClient.search, onSearchResultTapped: navigator.goToVehicleDetails, child: app, ), (app) => VehicleDetailDependencies( vehicleApi: apiClient.getVehicleDetails, child: app, ), ], app: MaterialApp( localizationsDelegates: [ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, SearchResultsLocalizations.delegate, VehicleDetailsLocalizations.delegate, ], home: SearchResultsScreen(), ), ); } }
In this simple example, a stateful widget lives in our Application package and constructs the concrete implementation of our API client. This is one of the root widgets of our application and is responsible for constructing our MaterialApp. Note, we are configuring the dependencies and placing them above the MaterialApp. This is critical because MaterialApp provides our root navigator. This means as we navigate to new routes these same dependencies remain available from context because they are at the trunk of the widget tree.
We would then add a simple integration test in our app package to validate that we’ve wired up our packages correctly.
testWidgets('Should navigate from Search Results to Vehicle Details when I click on a result', harness((given, when, then) async { final app = AppPageObject(); // Pump the App and assert we are on the Search Screen await given.appIsPumped(); // Assert the Search Results is on screen then.findsOneWidget(app.searchResults); // Assert no Vehicle Details is on screen then.findsNothing(app.vehicleDetails); // Tap on the first search result to see its details await when.userTaps(app.searchResults.resultAt(0)); // Assert no Search Results is on screen then.findsNothing(app.searchResults); // Assert the Vehicle Details is on screen then.findsOneWidget(app.vehicleDetails); }));
You may have also noticed that each package exposes its own localization delegate. In order for each package to be independently testable, the package needs to fully own all of its resources: images, fonts and localized strings.
Obviously, this example has been heavily simplified. If taken at face value, this package structure could seem excessive. However, in our codebase, our Search Screen package has grown to 17,000 lines of code and over 500 tests – it is large enough that we are actively working to decompose it into smaller, more manageable pieces. In practice, this package boundary allows developers working on other features to completely ignore all of this complexity. Likewise, when someone does need to work on search, they are able to work exclusively in the Search Screen package and ignore the entire rest of the application.
This approach provides a foundation to scale out our codebase in a manageable way. We can easily have multiple large scale features being simultaneously developed with minimal friction. Working in a smaller package gives focus and improves developer turnaround times. If a developer makes a change, they only need to re-run the tests in the affected package, or occasionally in the app package if their change impacts the public API.
We’ve also completely avoided singletons and global state by always managing our state via the widget tree. Because of this, Flutter’s stateful hot reload works consistently throughout the entire application. It enables us to add options to our in-app developer menu to switch to our QA environment — forcing all API dependent state to be discarded and for the entire app to seamlessly switch environments without being recompiled. This has allowed us to avoid adding environment-specific build flavors. The only compile time variation we have is an optional build argument to include the developer menu.
Having decoupled packages has also led to huge benefits in our CI pipeline. If we were to build, analyze and test all of the code in our repository, it would take over 20 minutes. However, because each package is independently testable, we’ve optimized our CI pipeline to build and test packages in parallel across our build servers. We’ve gone a step further and for our pull requests (PRs) we only build and test those packages that are impacted by the affected files. We do this by evaluating which packages transitively depend on the changed packages. This means our CI turnaround time is often under five minutes for most pull requests. This, however, has not come for free; it has required continual iteration and optimizations along the way. If you plan to have a monorepo with multiple packages, you should plan to invest some of your time in developer tooling and CI automation.
Getting the right package boundaries is not always easy. The example we walked through is cut and dry, but the problem becomes much more nuanced with complex features that span multiple packages. Consider a feature where the user can “like” or “favorite” a vehicle on both the Search Results Screen and the Vehicle Details Screen. Sometimes we didn’t discover the right package boundary until late in feature development. Reworking these boundaries takes time and effort, and it can be tempting to kick the can down the road and defer cleanup. It can also be tempting to shove reusable code into a Common, Shared or Utilities package. However, the “easy” way out almost always results in the accrual of tech debt. For a long time, we resisted creating single-purpose packages under the false assumption that adding more packages was bad. We’ve finally moved past that assumption and have since almost finished decomposing the last of our dumping ground packages and couldn’t be happier.
For our team, breaking down and applying domain modeling to our application has been far more important than choosing the right state management tool. State management fads come and go, but if your app is to survive, modeling is forever.