Project structure
Monorepo, polyrepo, or git submodules - where the Flutter module, the native hosts, and shared code live.
An add-to-app project has at least three moving parts that need a home: the Flutter module (your Dart code and its generated boundary), and the native host apps - typically one iOS app and one Android app - that embed it. How you lay these out across repositories is one of the first structural decisions you make, and it is worth making deliberately, because it sets the cost of every cross-boundary change that follows.
Two questions drive the decision more than any other:
- Who already owns the native apps? In most enterprise add-to-app projects the native apps predate Flutter - they live in established repositories, with their own teams, CI, and release process. That existing reality often constrains the layout more than any preference would.
- How often does the boundary change? Every native-Flutter contract (see Native-Flutter communication) is code that spans both sides. If the boundary is still churning, you want changes to both sides to land together; if it is stable, independence costs less.
There are three common layouts. They are not mutually exclusive across a large organization, but for a single product you generally pick one.
Monorepo
The Flutter module and both native hosts live in one repository. A single checkout contains everything that ships in the app.
app-repo/
android/ # native Android host
ios/ # native iOS host
flutter_module/ # the Flutter module (Dart + generated boundary)
packages/ # shared Dart packages (optional)This is the layout this framework's own example uses: the inlay packages, the example_module, and the example_android / example_ios hosts all sit in one tree.
This layout fits when one organization owns both the native and Flutter sides, or when you are building the host apps fresh alongside the migration. It is the strongest choice while the boundary is still moving.
What you gain is that a cross-boundary change - adding a route, renaming a field in a shared contract, regenerating types for all three platforms - is one atomic commit, reviewable as a unit and impossible to half-merge. There is a single source of truth and a single version of the boundary, which is what you want when a single team owns the boundary code.
The cost is that the repository holds three toolchains (Gradle, Xcode, Flutter), so CI has to be set up to build only what changed rather than everything on every push. The repo is large, and native and Flutter teams share it whether they want to or not.
Polyrepo
The Flutter module lives in its own repository; each native host stays in its own. The hosts consume the module as a dependency - either as a prebuilt artifact or pinned to a source revision (covered in Build integration).
flutter-module-repo/ # the Flutter module, released independently
android-app-repo/ # consumes the module as a dependency
ios-app-repo/ # consumes the module as a dependencyThis layout fits when the native apps already exist in separate repositories with separate teams, which is the common enterprise starting point. Polyrepo lets you add Flutter without reorganizing repositories the native teams already depend on.
What you gain are clean ownership boundaries and independent release cadences. The Flutter team's repo has one toolchain and fast, focused CI. Native teams keep their repo essentially untouched and consume Flutter through a single dependency.
The cost is that a boundary change is no longer atomic. Changing a contract means a coordinated set of pull requests - update the module, publish a new version, then bump and adapt each host - with a window where the repos disagree. Version pinning becomes something you have to actively manage (see Versioning), and large cross-cutting refactors are harder because no single checkout sees all the code.
Git submodules
The middle ground: the native host repos stay separate, but each pulls in the Flutter module repo as a git submodule, so the host builds the module from source without the two repos being merged.
This fits when you want source-level integration and near-atomic checkouts, but cannot (or do not want to) collapse pre-existing native repos into a monorepo.
What you gain is that the host always builds against real module source at a known commit, so you can step through and debug across the boundary, and a host checkout at a given SHA is reproducible. It keeps repositories separate while avoiding a publish round trip on every module change.
The cost is the day-to-day ergonomics of submodules. The host pins the module to a commit SHA, not a semantic version, so updating means committing a new pointer; contributors who forget --recursive get an empty directory; and detached-HEAD states trip up anyone not fluent with submodules. None of this is dramatic on its own, but the overhead falls on everyone who clones the repo.
Anti-pattern: a repository per Dart package
There is a more extreme form of polyrepo that we advise strongly against: splitting the Dart side itself across many repositories - a repo per feature package, a repo per shared library - wired together with git or path dependencies. This is different from polyrepo above, which keeps the module whole and only separates it from the native hosts. Here even the Flutter codebase itself loses atomic changes.
Avoid one repository per Dart package. We have worked on a project built this way, and the overhead compounds badly as the codebase grows.
The problems we hit, in order of how much they hurt:
- No atomic change, anywhere. A change touching two packages - which is most non-trivial changes - becomes a chain of pull requests across repos, each needing a publish or a pinned-ref bump before the next can proceed. A simple refactor can take half a day of coordinating pull requests.
- Dependency management dominates. Pinning dozens of inter-package versions (or git SHAs) by hand is constant, error-prone work, and it is easy to end up with incompatible versions resolved into one app. The lockstep you actually want is exactly what this layout makes hardest.
- Tooling fights you. Code generation (
build_runner), the analyzer, and "find usages / rename across the codebase" all assume they can see the code. Spread across repos, none of them can, so cross-package refactors lose their safety net and regenerating the boundary becomes a multi-repo exercise. - Onboarding and CI multiply. Every new contributor clones and wires up many repos; every package carries its own CI, release process, and review overhead. The fixed cost per repo is paid over and over.
The goal that motivates this layout - a modular Dart codebase with clear package boundaries - is the right goal. The mistake is treating a package boundary as a repository boundary. You get all the modularity benefits, and none of this pain, by keeping the packages in one repository as a Dart workspace (or a Melos monorepo): clean package separation, but atomic changes, one resolve, and tooling that sees everything. How to draw those package boundaries is covered in Scaling & code sharing.
Choosing
| Layout | Best when | Cross-boundary change | Main cost |
|---|---|---|---|
| Monorepo | One org owns both sides; boundary still moving | Atomic, single commit | Multi-toolchain repo + CI complexity |
| Polyrepo | Native apps pre-exist with separate teams | Coordinated PRs across repos | No atomic boundary change; version pinning |
| Submodules | Want source integration without merging repos | Pointer-commit per update | Submodule ergonomics |
Aim for a monorepo as the destination. An add-to-app project is, by nature, two codebases that have to agree at the boundary, and the more it grows, the more that agreement costs to maintain across repository lines: every new contract, every shared package, every refactor that touches both sides turns into cross-repo coordination, version bumps, and a window where the repos disagree. A monorepo collapses all of that into atomic, reviewable commits, and the benefit only grows as the project scales - while the polyrepo overhead grows right along with it.
A monorepo also prepares you for the eventual exit. The endgame of most add-to-app projects is the full switch to a Flutter-owned shell, and that step, by definition, consolidates both platforms into a single Flutter app. A monorepo gets you there incrementally: the codebases already live together, the boundary is already one source of truth, and "make Flutter the host" becomes a refactor inside one repo rather than a repository merger on top of an already hard migration.
That said, polyrepo is a perfectly good place to start, and often the only realistic one: the native apps already live in their own repositories, and you should not block the migration on a repository reorganization. Begin there, lean on versioning and feature flags to manage the boundary, and treat consolidation into a monorepo as a deliberate milestone for when the polyrepo overhead starts to outweigh the disruption of moving - typically once the boundary is broad and changing often. Submodules are the bridge in between: reach for them when you want to build the module from source inside an otherwise separate native repo, either as a stepping stone toward a monorepo or because merging the repos genuinely is not on the table.
Repository structure is closely tied to, but distinct from, how the native app consumes the module - building from source vs a prebuilt AAR / XCFramework. Source integration is natural in a monorepo or submodule setup; artifact integration is what makes polyrepo practical. That choice, and the CI/CD it implies, is covered in Build integration.