If you've ever split a Flutter app into a handful of local packages, you know the friction: every package gets its own .dart_tool/, its own pubspec.lock, and its own dependency resolution. You wire them together with path: dependencies, run pub get in each folder (or lean on a tool to do it for you), and hope nobody's transitive versions drift apart.

Since Dart 3.6*, there's a first-class answer baked into the SDK: *pub workspaces. One resolution, one lockfile, one .dart_tool/, shared across every package in the repo. No external tooling required.
This post walks through what workspaces are, how to set one up, and how I use them in my Flutter starter template — a monorepo with ten local packages, zero path: dependencies, and no Melos config file in sight.
Reference implementation: github.com/kido-luci/flutter-starter-template
---
The problem workspaces solve
A typical Flutter monorepo looks like this:
my_app/
├── pubspec.yaml # the app
└── packages/
├── network/
│ └── pubspec.yaml
├── theme/
│ └── pubspec.yaml
└── ...
Before workspaces, each of those packages resolved its dependencies independently. That means:
.dart_tool/package_config.json and its own pubspec.lock. A clean pub get across the repo resolves the same shared dependencies over and over.theme use architecture, you write architecture: { path: ../architecture }. It works, but it's noisy and easy to get wrong.network and storage to agree on, say, the same dio or injectable version. Independent resolution can leave each package on a slightly different transitive set, and the only place that gets reconciled is the app at the top — sometimes painfully.Melos papered over a lot of this with melos bootstrap, but bootstrapping was still orchestrating many independent resolutions. Workspaces remove the problem at the root instead of managing it.
---
What a Dart workspace actually is
A workspace is a set of packages that share a single dependency resolution*. Instead of each package resolving alone, pub treats the whole repo as one unit: it builds one dependency graph, picks one version of every shared package, and writes a *single pubspec.lock and a single .dart_tool/ at the workspace root.
It's two small pieces of configuration:
1. The root package declares which packages belong to the workspace.
2. Each member package opts in with one line.
That's the entire mechanism. Everything else — the speed, the consistency, the single pub get — falls out of those two declarations.
Requirements: Dart SDK >= 3.6.0 in every package (workspaces and the member opt-in key are only understood by 3.6+). All members must have compatible SDK constraints, since they resolve together.
---
Setting it up
1. Declare the workspace at the root
In the root pubspec.yaml, add a workspace: field listing every member package by its path. Here's the actual root config from my starter template:
name: flutter_starter_template
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.12.0
workspace:
- packages/app_ui
- packages/analytics
- packages/config
- packages/architecture
- packages/network
- packages/app_platform
- packages/storage
- packages/sync
- packages/theme
- packages/test_utils
dependencies:
app_ui: ^0.1.0
analytics: ^0.1.0
config: ^0.1.0
architecture: ^0.1.0
network: ^0.1.0
app_platform: ^0.1.0
storage: ^0.1.0
sync: ^0.1.0
theme: ^0.1.0
flutter:
sdk: flutter
# ...the rest of the app's dependencies
Two things worth noticing:
config: ^0.1.0), not by path:. Inside a workspace, pub resolves those names to the local members automatically.2. Opt each member into the workspace
Every member package adds a single key: resolution: workspace. Here's packages/network:
name: network
description: Dio HTTP client building blocks for the Flutter starter template.
version: 0.1.0
publish_to: none
environment:
sdk: ^3.12.0
resolution: workspace
dependencies:
config: ^0.1.0 # ← another local package, by name. No path:.
dio: ^5.9.2
firebase_performance: ^0.11.4+1
injectable: ^3.0.0
retrofit: ^4.9.2
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.15.0
injectable_generator: ^3.0.2
test_utils: ^0.1.0 # ← yet another local package
resolution: workspace is the opt-in. It tells pub: don't resolve me on my own — resolve me as part of the workspace this package belongs to. Pub finds the workspace by walking up the directory tree until it hits the root pubspec.yaml that lists this package.
3. Run pub get once
flutter pub get # or: dart pub get
Run it from the root. Pub resolves the entire repo in one pass and writes:
my_app/
├── .dart_tool/ # one, at the root
├── pubspec.lock # one, at the root
├── pubspec.yaml # workspace: [...]
└── packages/
├── network/
│ └── pubspec.yaml # resolution: workspace
└── ...
No per-package .dart_tool/. No per-package lockfile. One resolution for everything.
---
What you get
One consistent version of every shared dependency
This is the headline. Because the whole repo resolves together, network, storage, sync, and the app cannot end up on different versions of injectable or dio. Pub picks one version that satisfies every constraint across every member, or it fails loudly and tells you which constraints conflict. Version drift between packages is gone by construction.
Local packages by name, not by path
Inter-package wiring becomes trivial. In my template, theme depends on analytics and architecture; test_utils depends on analytics, app_platform, and storage; network depends on config. Every one of those is just:
dependencies:
analytics: ^0.1.0
architecture: ^0.1.0
No path: segments to maintain when you move a folder. Pub maps the name to the workspace member.
A single, fast pub get
One resolution instead of N. On a repo with ten packages that share most of their dependency graph, that's a real difference — both on your machine and in CI, where a single pub get at the root warms the cache for the whole workspace.
Run tests and analysis across the repo
A shared analysis_options.yaml at the root applies to every package (mine includes very_good_analysis), and you can analyze or test the whole workspace from one place:
dart analyze
dart test # runs across workspace members
---
Requirements and gotchas
environment.sdk constraint must allow 3.6 or newer, and the constraints must be mutually compatible — they're resolved as one unit. (My template pins ^3.12.0 across the board.)package A on dio 4.x while package B insists on dio 5.x — that's now a hard resolution error, not a silently-different lockfile. This is the point, but it can surface latent conflicts the first time you migrate.resolution: workspace is mandatory on members. Forget it on one package and pub will try to resolve that package standalone and complain that it isn't part of any workspace (or resolve it separately, which defeats the purpose).dependencies. If you want a "pure" container with no app code, you can make the root a minimal package whose only job is to host workspace: — but having the app be the root (as I do) is perfectly fine.pubspec.lock and .dart_tool/ files; the canonical ones now live at the root. Update .gitignore accordingly.---
Workspaces vs. Melos
Workspaces don't make Melos obsolete — they replace the part Melos was worst at and leave the part it's good at.
| Concern | Pub workspaces | Melos |
|---|---|---|
| Shared dependency resolution | ✅ Built in | ⚠️ Bootstrap orchestration |
Single lockfile / .dart_tool | ✅ Yes | ❌ Per-package |
path:-free local deps | ✅ Yes | ❌ Still needed |
| Running scripts across packages | ❌ Not its job | ✅ Strong |
| Coordinated versioning / changelogs / publishing | ❌ Not its job | ✅ Strong |
The clean split: let the SDK own resolution, let Melos (if you need it) own task running and release automation. Modern Melos actually integrates with pub workspaces for exactly this reason. My starter template doesn't ship a melos.yaml at all — native workspaces cover everything I needed for resolution, and I run scripts with plain dart/flutter commands.
---
Wrapping up
Pub workspaces turn a Flutter monorepo from "N packages I have to keep in sync" into "one repo that resolves as a unit." The setup is two lines of config — workspace: at the root, resolution: workspace in each member — and the payoff is consistent versions, no path: dependencies, and a single fast pub get.
If you want to see it on a real, multi-package project, the full setup lives here:
👉 github.com/kido-luci/flutter-starter-template
Look at the root pubspec.yaml for the workspace: list, then open any package under packages/ to see resolution: workspace and the path-free local dependencies in action.
Happy coding : )



Loading…