skip to content

When splitting a Flutter super-app's rides, food and wallet features into local packages, how should the packages depend on each other, and where is the app wired together?

level: seniorimportance: should knowfreq 38%

answer

  1. dependencies point down, never sideways
  2. an app shell owns main.dart and runners
  3. shared core: design system, domain, API client
  4. contracts package for cross-feature calls
  5. shell merges each feature's routes

basics

~20 s

Dependencies point one way: the app shell depends on every feature, features depend only on shared core packages, and features never depend on each other. The shell composes the router, injects cross-feature contracts and owns main.dart and the platform folders.

solid answer

~50 s

I would make the runnable Flutter app a thin **app shell**: it owns `main.dart`, the `android/` and `ios/` folders, environment config, the single `GoRouter` and dependency wiring. Each feature (`rides`, `food`, `wallet`) is a local package created with `flutter create --template=package`, marked `publish_to: none`, and depending only on shared packages such as a design-system package, domain types and an API client. Features never depend on each other: when food's checkout needs a payment, it calls an interface declared in a shared contracts package, wallet implements it, and the shell injects wallet's implementation. Each feature exports its route list, and the shell concatenates them into one router. The result is an acyclic graph where a team can change its feature's internals, run its package's tests alone, and only negotiate with other teams over the shared packages and the exported surface.

code

yaml · 15 lines
yaml
# packages/food/pubspec.yaml
name: food
publish_to: none

environment:
  sdk: ^3.13.0

dependencies:
  flutter:
    sdk: flutter
  go_router: ^18.0.0
  core_ui:
    path: ../core_ui
  core_contracts:
    path: ../core_contracts

go deeper

for a junior

Recall the direction of dependencies: the app shell depends on features, features depend on shared packages, and features never depend on each other.

for a middle

Explain how a cross-feature need is met through an interface in a shared package, and how the shell merges each feature's exported routes.

for a senior

Show how you keep the graph clean over time: pubspec review, implementation_imports, depend_on_referenced_packages, isolated feature runs and a thin core.

for a principal

Weigh how much to put in core packages against team autonomy, and decide which cross-feature contracts deserve a stable interface at all.

## The target shape A **local package** is a Dart package that lives in the same repository as the app and is referenced by path (or through a pub workspace) instead of being downloaded from pub.dev. Splitting a large Flutter app into local packages turns the folder conventions of a single package into boundaries the package system can check: a package can only use what it declares as a dependency and what the other package exports. For a super-app with **rides**, **food** and **wallet** features owned by three teams, the graph that works is layered: ```text apps/super_app app shell: main.dart, android/, ios/, router, wiring depends on -> rides, food, wallet packages/rides, packages/food, packages/wallet one per team depend on -> core packages only, never on each other packages/core_ui design system: theme, shared widgets packages/core_domain Money, UserId, shared value types packages/core_api HTTP client, auth headers packages/core_contracts interfaces features call each other through ``` ## The rules 1. **Dependencies point down.** The shell depends on features; features depend on core packages; core packages depend on nothing app-specific. 2. **No feature-to-feature edges.** If food imports wallet, the food team cannot release, test or refactor without wallet, and the split has bought nothing. 3. **Cross-feature calls go through contracts.** Food's checkout depends on an abstract `PaymentLauncher` in `core_contracts`; wallet implements it; the shell passes wallet's implementation into food. This is the dependency-inversion move applied at package level. 4. **Only the shell is an app.** Feature packages come from `flutter create --template=package`, which generates no platform runner folders; `android/`, `ios/`, signing, flavors and `main.dart` live once, in the shell. 5. **The shell composes navigation.** Each feature exports a route list; the shell builds one `GoRouter` from all of them, so no feature needs another feature's screen classes to navigate there, only a path string. 6. **Keep core thin.** Anything placed in a core package becomes a dependency of every feature. A growing "common" package is where the coupling you removed comes back. ## Wiring it in code Each feature package declares its dependencies explicitly: ```yaml # packages/food/pubspec.yaml name: food publish_to: none environment: sdk: ^3.13.0 dependencies: flutter: sdk: flutter go_router: ^18.0.0 core_ui: path: ../core_ui core_contracts: path: ../core_contracts ``` The shell merges routes and injects contracts: ```dart import 'package:food/food.dart'; import 'package:go_router/go_router.dart'; import 'package:rides/rides.dart'; import 'package:wallet/wallet.dart'; GoRouter buildRouter(WalletPaymentLauncher payments) => GoRouter( initialLocation: '/rides', routes: [ ...ridesRoutes, ...foodRoutes(payments: payments), ...walletRoutes, ], ); ``` Food never imports wallet; it receives a `PaymentLauncher` (the interface from `core_contracts`) as a parameter. ## Checking the graph stays clean | Check | What it catches | |---|---| | Review of each feature's `pubspec.yaml` | A new dependency on a sibling feature | | `implementation_imports` lint | Reaching into another package's `lib/src` | | `depend_on_referenced_packages` lint | Importing a package that is not declared in the importer's own pubspec | | Per-package `flutter test` | A feature that only works when another feature is present | The third check matters in a pub workspace: all members share one `.dart_tool/package_config.json`, so an import of a sibling package can resolve even when the importer never declared it. The lint turns that silent edge into an analysis error. ## What splitting does not change - The shipped app is still one Flutter build with one dependency resolution: pub picks one version of each package for the whole app. - Hot reload works across local path packages; it is not a reason to avoid splitting. - Assets inside a feature package are declared in that package's pubspec and loaded with the package name, a detail to agree on early. ## Migrating an existing single-package app 1. Start with feature-first folders inside the current package, so the ownership lines are visible before any package exists. 2. Extract the shared pieces first (design system, domain types, API client), because every feature will depend on them. 3. Extract one feature at a time into its own package, fixing each import that reached into another feature by introducing a contract. 4. Only then move the router composition and dependency wiring into the app shell and delete the old cross-feature imports.

  • The food team wants to show the wallet balance on its checkout screen. How do you allow that without a food-to-wallet dependency?
    Add a narrow interface such as `BalanceReader` to the shared contracts package, implement it in wallet and inject it into food from the shell. Food depends on the abstraction; wallet keeps ownership of how the balance is fetched, and the graph stays acyclic.
  • Why does a pub workspace make the depend_on_referenced_packages lint more important for feature packages?
    Workspace members share one package configuration file, so an import of a sibling package can resolve even if the importing package never declared it. The code runs, but the dependency is invisible in its pubspec. The lint reports imports of undeclared packages, restoring the declared graph as the source of truth.
  • How can a feature team run its feature in isolation without the whole super-app?
    Give the feature package a small `example/` app, or a debug entry point in the shell, that provides fakes for the contracts it needs and mounts only its routes. Because the feature depends on interfaces, not sibling features, the fakes are enough to launch it.

saying these in an interview costs you the question

  • Lets feature packages import each other for convenience
  • Gives every feature package its own android and ios folders
  • Builds one GoRouter per feature package
  • Moves all shared code into one growing common package
  • Believes each local package ships its own copy of shared dependencies