Scaling & code sharing

Modularizing the Dart side into packages and sharing them with standalone Flutter apps.

This page is an early draft. The structural guidance is settled; the concrete package-layout examples and the standalone-app sharing recipe will be expanded.

A Flutter module that starts as a handful of screens rarely stays that small. As more features land and more people contribute, a single flat lib/ becomes a bottleneck: everything can import everything, build times grow, and it gets unclear who owns what. Scaling the Dart side comes down to drawing package boundaries inside the module before the lack of them starts to hurt. And once those boundaries exist, they are also what lets you share code with a standalone Flutter app without copying it.

Most of this is ordinary Flutter modularization. What makes it an add-to-app concern is the boundary the module lives behind: the native hosts enter Flutter at a curated set of screens, Flutter may run across multiple engines, each its own isolate, and the module is meant to plug into both the native hosts and - eventually - a standalone Flutter app. Those three facts change where some packages belong, how the pieces communicate, and which package is the one you version. This page calls those out as they come up. The guidance is independent of any particular add-to-app framework - inlay specifics are given only as concrete examples.

This page is the internal counterpart to Project structure. That page covers where repositories live; this one covers how the Dart code is organized within the module, regardless of the repo layout.

Packages, not folders

The first instinct when a lib/ grows is to add subfolders. However, folders give you names without enforcement - any file can still import any other, so the dependency graph can quietly degrade even while the folder tree looks tidy. Packages are the unit that actually constrains the graph: a package can only see what it declares as a dependency, so the boundaries you draw are checked by the tooling rather than by code review.

The right home for these packages is one repository, organized as a Dart workspace (or a Melos monorepo) - not a repository per package. This framework's own code is laid out exactly this way: inlay, inlay_gen, and the example module are workspace members in a single tree. You get clean package separation and, at the same time, atomic changes, a single dependency resolution, and tooling that sees the whole graph. The repository-per-package anti-pattern is what happens when a package boundary gets conflated with a repository boundary.

Organizing the packages

Packages divide along two axes that usually line up: business subdomain and team ownership. Every package is owned by exactly one team, and a team can own several. Prefix every package with the app's name (app_transfers, app_design_system) rather than a generic feature_ - the prefix makes it obvious at a glance which packages belong to this app and which are third-party dependencies.

In our experience, three kinds of package tend to emerge:

  • Business packages - one per user-facing subdomain (app_transfers, app_loans, app_accounts). Each owns the screens, UI, and business logic for that subdomain and is owned by the business team responsible for it.
  • Technical and platform packages - the cross-cutting infrastructure every subdomain leans on: app_networking, app_common, app_authorization, app_lint, and the app_entrypoint package that assembles the app. This code belongs to no single subdomain, so it is owned by a dedicated platform team (usually your most senior engineers) with its own backlog. In an add-to-app project, the generated native boundary (the typed routes and state contract) lives here too.
  • Design system packages - the shared visual language: app_design_system (common widgets, theming) and app_widgetbook. Owned by a design-system team so the UI stays consistent across subdomains.
my_app/
  apps/
    app_entrypoint/     # assembles all packages into the runnable app(s)
  packages/
    app_transfers/      # business subdomain (business team)
    app_loans/
    app_accounts/
    app_design_system/  # design system (design-system team)
    app_widgetbook/
    app_networking/     # platform / technical (platform team)
    app_common/
    app_authorization/
    app_lint/

Each package encapsulates its internals and exposes only a deliberate public interface through its lib/<package>.dart barrel file. Use @internal on things that should never leak out - a design-system widget that is an implementation detail, for example - so the analyzer flags any external use. Without that, the boundary is easy to bypass without anyone noticing, and it tends to erode over time.

Don't pre-split a small module into a dozen packages on day one - premature modularization has its own cost. Split when a real pressure appears: build times, unclear ownership, or a subdomain you want to reuse elsewhere. Treat the structure above as the destination, not the starting point.

The native boundary is a package of its own

In a pure Flutter app the package graph is the whole story. An add-to-app project adds one more package that has no equivalent in a standalone app: the native boundary - the typed contract defining which screens the host can launch and what state crosses between native and Flutter, plus the code generated from it for Dart, Kotlin, and Swift. (With inlay that contract is the @InlayNativeRoute / @InlayFlutterRoute / @InlayStore schemas and the inlay_gen output, but the reasoning holds for any typed add-to-app boundary.)

This package is special for two reasons, and both argue for keeping it as a dedicated technical package owned by the platform team:

  • It is the only package the native hosts compile against. The generated Kotlin/Swift is the entire surface the iOS and Android teams see. If boundary definitions are scattered across business packages, the native contract is scattered too; concentrating them in one app_boundary package gives native a single, well-known dependency.
  • It is the unit that versions. A module artifact's version is really a boundary-contract version - regenerating it can change a signature a host links against. Keeping the boundary in one package makes "did the contract change?" a diff of a single package instead of a hunt across the codebase.

Only the screens reachable from native belong in the boundary. Most screens a subdomain owns are reached by ordinary in-Flutter navigation and never appear in the native contract - they stay internal to their business package. The boundary holds just the entry screens the host launches into, which in most cases is a small, deliberately curated set.

Dependencies between packages

A rule you often see is to forbid dependencies between feature packages altogether. In our experience this is too strict: business packages do depend on one another when there is a genuine business reason. app_transfers depending on app_accounts because a transfer needs account data is a legitimate dependency, and trying to ban it just pushes the relationship somewhere more awkward. Every business package also depends freely on the technical and design-system packages.

What you do want to guard is the shape of the graph:

  • Keep it acyclic - two packages must not depend on each other in a cycle. If app_transfers needs app_accounts and vice versa, the shared concept should be extracted into a third package they both depend on.
  • Go through the public interface, never the internals - a dependency on another package means a dependency on its exported facade, not on its private files. This is what @internal and the barrel file enforce.
  • Gate new external dependencies - in a large project, adding anything to a pubspec.yaml should pass through the platform team (a license_checker step in CI guards against license drift). The dependency graph is something the platform team should actively curate.

A modularized add-to-app project has two distinct navigation surfaces, and it helps to keep them apart:

  • Native → Flutter - the host launching an entry screen. It crosses the native boundary (and, in a multi-engine setup, spins up an engine to host it), and it is limited to the curated routes the boundary exposes. This part is handled by the add-to-app framework, not by your package graph.
  • Flutter → Flutter - navigation inside one engine, between screens owned by different packages. This is the ordinary cross-package routing described below, and it never involves the native side.

For the in-Flutter navigation, each package declares its own routes, and reaching a screen owned by another package can be done in two ways:

  1. Direct dependency - depend on the target package and use the routes or navigation methods it exports. This is natural when a real business dependency already exists between the two.
  2. A shared app_navigation package - all routes are declared in one navigation package that every package depends on. This lets a package navigate to another's screen without a direct dependency - app_loans can push a transfers screen without depending on app_transfers at all. You get one source of truth for the whole navigation tree and cleaner business boundaries, at the cost of a package that changes constantly and is shared by every team.
        app_navigation        ← all routes; everyone depends on it
        ↑              ↑
   app_accounts   app_loans   ← loans reaches transfers via navigation,
        ↑                          with no direct dependency on transfers
   app_transfers               ← needs accounts data (a real business dependency)

Cross-package communication

When packages need to share data or trigger behavior rather than just navigate, there are two common mechanisms:

  • Facades - a package's explicit public interface. A facade can expose read access to internal data or API calls, allow performing actions, expose streams of data, and offer navigation methods. Other packages interact with the package only through it.
  • Event bus - useful when several packages need to react to the same events, or emit them, without knowing about each other. It removes coupling well, but use it sparingly - heavy reliance on a global event bus produces code where cause and effect are hard to trace.

Both of these mechanisms only work within a single engine. Two packages talking through a facade or an event bus must be running in the same engine's isolate - in a multi-engine setup, a screen in one engine cannot see a facade or bus living in another (separate isolates share no Dart memory). State that must be visible across engines is a different problem and needs a mechanism that syncs through the platform side (with inlay, an @InlayStore). Keep those shared cross-engine definitions in the boundary/technical layer, not inside a single business package.

A module interface

A practical pattern for wiring everything into the entrypoint is a shared module interface that every package implements:

abstract interface class AppModule {
  List<GoRoute> get routes;
  List<SingleChildWidget> get providers;
  List<ApiService> get apiServices;
}

The entrypoint collects each package's AppModule and registers its routes, providers, and services in one place. Adding a new package then comes down to implementing the interface - its navigation, dependency injection, and services are picked up automatically, with no bespoke wiring per package.

In a multi-engine add-to-app project this assembly runs in the entrypoint package's single Dart entrypoint (with inlay, inlayMain), and because every engine runs that same entrypoint, the module list is assembled once per engine as each native container spins up. That is fine - engines are independent and short-lived - but it does mean module registration should be cheap and free of cross-engine assumptions. In particular, no "register once globally" singletons, which the isolate model does not give you anyway.

Where cross-subdomain screens live

A screen that combines several subdomains - a dashboard pulling from transfers, loans, and accounts - should get its own package that depends on the business packages it draws from, rather than being placed in the entrypoint. The entrypoint should stay a thin assembly layer; once some business logic lands there, more tends to follow, and it gets hard to untangle later. With a solid design system, the dashboard package can usually compose existing widgets rather than reaching deep into each subdomain.

Sharing with a standalone Flutter app

Once the app is a set of packages behind a thin entrypoint, a second benefit appears: the same packages can power a standalone Flutter app. The embedded module and a full Flutter app become two entrypoint packages over the same body of business, technical, and design-system packages - each subdomain is written once and runs in both. This is why an add-to-app project benefits from packages that can be plugged into both a standalone Flutter app and the native hosts. Sometimes you also want a way to tell at runtime which one you are in (e.g. an injected env variable), since a few implementation details differ.

This matters in two common situations:

  • Running a subdomain in isolation. A standalone app that wires up just app_transfers and its dependencies is a fast way to develop and test that subdomain, with no iOS or Android host build in the loop.
  • The migration endgame. The exit strategy for most add-to-app projects is a full switch to a Flutter-owned shell. If your subdomains already live in shared packages, that shell is just another entrypoint over the same packages rather than a rewrite. The standalone app you build for isolated development today is close to the app you will ship at the end.

What makes this work cleanly is keeping entrypoint-specific concerns out of the shared packages. A business package should not assume it is embedded (it should not reach for the native boundary directly), and it should not assume it is standalone either. Anything that differs between the two homes - how navigation is hosted, whether state crosses to a native side, app-level wiring - lives in the entrypoint package, which differs per host, while the packages it assembles stay identical.

packages/app_transfers/      # identical in both
packages/app_*/             # identical in both

apps/app_entrypoint/        # entrypoint: embeds in native hosts via the boundary
apps/app_standalone/        # entrypoint: a normal MaterialApp shell

In an add-to-app project specifically, the boundary dependency is the seam to be careful about. Packages that consume the generated native routes and stores work unchanged when embedded; in a standalone app those need a host-side equivalent or a stub. Keeping the boundary in a technical package - rather than letting it leak into every subdomain - is what lets a package drop cleanly into either entrypoint.

Rules of thumb

  • Use packages, not folders, to draw boundaries - only packages constrain the dependency graph.
  • Keep all packages in one repository as a Dart workspace; never a repository per package.
  • Prefix packages with the app name and give each one a single owning team.
  • Let business packages depend on each other when there is a real business reason - just keep the graph acyclic and go through public facades rather than internals.
  • Decouple navigation with a shared navigation package so a package can route to another's screen without depending on it.
  • Split when a real pressure appears - the structure above is the destination, not day one.
  • Keep the native boundary in one platform-owned package - it is the contract native compiles against and the unit you version; only native-entry screens belong in it.
  • Share state across engines through a platform-synced store (with inlay, an @InlayStore), not facades or an event bus - those only reach within one engine's isolate.
  • Keep entrypoint-specific code in the entrypoint package so the rest stays identical across the embedded module and a standalone app.

On this page