Skip to content
Umar ManzoorBook a call
← Writing

When to Modularise a Flutter App (and When Not To)

July 21, 2026

Every growing Flutter project eventually reaches the meeting where someone says it: "We should break this into modules."

Sometimes that's the right call. More often it's someone reaching for structure to fix a problem that structure won't solve. The app feels hard to change, packages feel like the professional answer, and so the team spends a few sprints splitting a codebase into a dozen packages — and ends up with the same tangle, now spread across a dozen pubspec.yaml files and a slower build.

So before any folder structure or melos.yaml, the question worth being honest about: what are you actually buying?

Modularity is a cost, not a goal

A modular Flutter app is not automatically a better one. Modularity is a trade. You pay real, recurring costs — more boilerplate, more indirection, slower first builds, dependency wiring, versioning, a monorepo tool to learn — in exchange for specific benefits. If you're not collecting those benefits, you're just paying the bill.

That framing matters because "modular" has become a proxy for "well-architected," and the two aren't the same. You can have a beautifully bounded single-package app and a horrifying modular one. The packages are not the architecture. The boundaries are.

When you don't need modules

If you're a small team — one or two developers, one product, shipping fast — you very likely do not need packages yet. A single well-organised codebase will carry you much further than people admit.

What that looks like in practice is feature-first folders and clean layering inside one package:

lib/
  core/
    design_system/
    network/
    models/
  features/
    auth/
      data/
      domain/
      presentation/
    orders/
    tracking/
  app/
    app.dart        // routing + wiring

This gives you almost everything a beginner thinks modules give them: a clear place for each feature, a shared core, and a separation between UI, domain logic, and data. It builds fast, it's easy to navigate, and — crucially — it's easy to delete and reshape while the product is still finding itself.

Splitting this into packages too early buys you slower incremental builds, more ceremony around every shared type, and a wall of indirection you walk through every day. In exchange for nothing, because the benefits of modules only show up under conditions you don't have yet.

The real triggers: people and boundaries, not lines of code

The signal to modularise is almost never "the app got big." Size alone is a weak predictor. The real triggers are about people and boundaries:

  • Multiple teams keep stepping on each other. When several teams edit the same codebase, a module becomes an ownership line. Team A owns feature_orders; Team B can't accidentally reach into its internals. The module is a social boundary as much as a technical one.
  • Features reach into each other's internals. If orders is importing private widgets and models from tracking, you have coupling that folders aren't stopping. A package boundary makes "what's public" enforceable — only the exported API is reachable.
  • Build and CI times hurt. Package boundaries enable module-level incremental builds and targeted testing. Changing one feature shouldn't rebuild and retest the world.
  • You're shipping an SDK or white-labelling. If the separation is the product — a reusable package other apps consume, or one codebase producing several branded apps — modules stop being optional.

Put simply: you modularise when the cost of everyone being able to touch everything finally exceeds the cost of the module overhead. Before that point, you're solving a problem you don't have.

Boundaries first: get them right in one codebase

Here's the part teams skip, and it's the most important: if you can't keep boundaries clean inside a single package, splitting into packages will not save you. It will just hide the mess behind a pubspec.

So earn the boundaries first, in the monolith:

  • Feature-first, not layer-first at the top level. Group by features/orders, not by screens/, models/, services/ sprawled across the whole app. A feature should be a vertical slice you could, in principle, lift out.
  • One-directional dependencies. Features depend on core. Features do not depend on each other. The app layer depends on features. If you find orders importing from tracking, that's the coupling to fix — before it's a package problem.
  • A public surface per feature. Even within a single package, treat each feature as if it has an API: a barrel file that exports what's meant to be used, and everything else kept to itself.

If you enforce those three rules in one codebase and they hold, extracting packages later is almost mechanical. If they don't hold, packages will make things worse, not better — this is the same lesson as the one about state management: the library is not the architecture — the tool doesn't create the boundary; you do.

The shape of an efficient modular Flutter app

When you do have a real reason to split, the structure that scales is boring on purpose. Three kinds of modules:

  • A core (or shared) package — the design system, networking, common models, and cross-cutting utilities. Everything can depend on it. It depends on nothing above it.
  • Feature packagesfeature_auth, feature_orders, feature_tracking. Each is a vertical slice. They depend on core. They never depend on each other.
  • A thin app shell — the runnable app that depends on the feature packages and wires them together: routing, dependency injection, startup.

As a monorepo:

packages/
  core/
  feature_auth/
  feature_orders/
  feature_tracking/
app/
melos.yaml

And the dependency direction — the single rule that keeps the whole thing sane:

              app shell
             /    |    \
            ▼     ▼     ▼
         auth  orders  tracking     ← features never import each other
            \     |     /
             ▼    ▼    ▼
                core                 ← design system · networking · models

Dependencies point one way: down. The app knows about features; features know about core; core knows about nobody. Wiring is just path dependencies:

# app/pubspec.yaml
dependencies:
  core:
    path: ../packages/core
  feature_auth:
    path: ../packages/feature_auth
  feature_orders:
    path: ../packages/feature_orders

And a monorepo tool like melos manages the whole set — bootstrapping, running analyze/test across packages, versioning:

# melos.yaml
name: my_app
packages:
  - app
  - packages/**

scripts:
  analyze: melos exec -- flutter analyze
  test: melos exec --dir-exists=test -- flutter test

Reach for melos after you actually have multiple packages worth managing — not before. A single-package app does not need a monorepo tool.

How features talk without depending on each other

The rule "features never import each other" always raises the same objection: but orders needs the current user, which auth owns. Good — that tension is exactly where architecture happens.

You resolve it without a direct dependency in one of two ways:

1. An interface in core, implemented by the feature. core defines an abstraction — say, AuthGateway with currentUser(). feature_auth implements it. feature_orders depends only on the AuthGateway abstraction from core, never on feature_auth. The app shell wires the concrete implementation in via dependency injection. Now orders depends on a contract, not on another feature's internals.

2. Events. For "something happened" communication — UserLoggedOut, PaymentCompleted — features publish and subscribe through a shared event mechanism instead of calling each other. This is the whole argument in Your App Shouldn't Need to Know Everything That Happens: the producer announces a fact and doesn't hold a directory of consumers.

Both keep the dependency graph acyclic. The moment two features import each other directly, you've traded a monolith for a distributed monolith — the worst of both.

Extracting a module without a big-bang

You don't modularise by stopping the world and re-slicing everything. You do it one seam at a time, the same way you'd do any large migration safely:

  1. Pick the cleanest boundary first. Usually core — the design system and networking have the fewest inbound dependencies and the clearest edges.
  2. Extract it to a package, update imports, keep the app green. Nothing else changes yet.
  3. Extract one feature that already respects its boundaries. If it doesn't, fix the boundary in place before extracting — don't drag the coupling into a package.
  4. Repeat, feature by feature, with the app compiling and shipping the entire time.

If a feature fights extraction — it can't come out without dragging three others with it — that's not a packaging problem. That's the boundary telling you it was never clean. Fix it in the monolith; the package will follow.

The anti-patterns that quietly ruin it

Most "modular" Flutter apps that feel worse than the monolith they replaced fell into one of these:

  • The common dump. A shared module that starts reasonable and slowly absorbs everything, until every package depends on one giant grab-bag. Now you have all of the module overhead and none of the isolation — change common, rebuild everything. Keep core deliberate and small; resist the urge to park "misc" there.
  • Circular dependencies. feature_a needs something in feature_b, which needs something in feature_a. Dart won't even let two packages depend on each other cyclically — and that constraint is a gift. Treat any temptation toward it as a design smell, and push the shared thing down into core or behind an interface.
  • Over-splitting. A package per tiny concern means dozens of pubspec.yaml files, endless version bumps, and a dependency graph nobody can hold in their head. Modules should map to real ownership and real boundaries, not to every folder.
  • Modularising to fix bad boundaries. The big one. If the boundaries are wrong, packages make them harder to fix, because now the coupling is spread across published surfaces. Get the boundaries right first; package second.

The payoff, stated plainly

When it's done for the right reasons and structured well, modularity buys you concrete things: teams that ship in parallel without collisions, import statements the compiler can police, incremental builds and targeted tests that don't rebuild the world, and features you can reason about — and delete — in isolation.

Those are real benefits. They're also benefits you only need past a certain point of team size and boundary pressure. Below that point, a clean single package delivers most of the same clarity with none of the overhead.

The actual point

Modularity is just boundaries with a build system attached.

If the boundaries are wrong, modules make the app worse — you've added indirection and build complexity on top of the original coupling. If the boundaries are right, packages are a natural, almost mechanical next step — not the thing that made the app clean, but a way to enforce a cleanliness you already had.

So the honest question before you split isn't "should this be modular?" It's: are you buying isolation you actually need — and have you earned the boundaries that would make the split trivial? Answer that first. The pubspec files can wait.