Most Flutter apps start small and work fine. Then the team grows, features pile up, and one change breaks three screens. Releases slow down because nobody is sure what a small edit will touch.
Modular architecture fixes this by splitting the app into small, separate parts. Each part does one job and has clear limits.
This guide shows how to structure a Flutter app so it stays easy to change, test and grow. It is written for developers and for the people who hire them.
What is modular architecture in Flutter?
A modular Flutter app is split into separate Dart packages. Each package owns one feature (like login) or one shared job (like networking). Packages talk to each other only through small, clear interfaces.
Without modules:
- All code lives in one
lib/folder. - Features import each other freely.
- A small change can break unrelated screens.
- Builds and tests get slower as the app grows.
- New developers need weeks to understand the code.
With modules:
- Each feature has its own folder, its own tests and its own owner.
- Teams work in parallel with fewer merge conflicts.
- You can change or replace one feature without touching the rest.
- A new developer can start in one module on day one.
The key idea is simple: limit what each part is allowed to know about the others.
Why it matters for custom software projects
Custom projects are different from product apps. The client’s needs change during the build. New integrations appear. Team members come and go. The code has to survive all of this.
- Faster delivery: several developers build different features at the same time without blocking each other.
- Lower maintenance cost: a bug stays inside one module, so the fix is small and safe.
- Easier handover: when the project moves to another team, they can learn one module at a time.
- Reuse across projects: login, payments, push notifications and analytics can move to your next client project with little change.
- Clear scope and pricing: features map to modules, so estimates are easier to explain to clients.
The cost is real too. Modular setup takes more time at the start. It pays back only if the app will grow. See “When NOT to go modular” below.
A simple folder structure
my_app/
├── app/ # main app, wires everything together
├── packages/
│ ├── core/ # shared tools: networking, errors, logging
│ ├── design_system/ # shared colors, fonts, widgets
│ ├── feature_auth/ # login and sign-up
│ ├── feature_orders/ # orders screen and logic
│ └── feature_profile/ # profile screen and logic
└── melos.yaml # runs commands across all packages
This is a “feature-first” layout. You group code by what it does (auth, orders), not by type (all screens in one folder, all models in another).
Inside each feature package, keep the same small structure:
feature_orders/
├── lib/
│ ├── feature_orders.dart # the only public file
│ └── src/
│ ├── data/ # API calls, local storage
│ ├── domain/ # models and business rules
│ └── presentation/ # screens, widgets, state
└── test/
- Other packages can import only
feature_orders.dart. - Everything inside
src/stays private by convention. Dart does not block imports ofsrc/, so add a lint rule or code review check to enforce it.
The 3 rules that keep modules clean
Rule 1: Features never import other features.
If feature_orders needs the current user, it asks core for it through an interface. It never imports feature_auth directly.
dart
// In core: a small contract
abstract class SessionService {
String? get userId;
Stream<bool> get isLoggedIn;
}
feature_auth provides the real version. feature_orders only knows the contract.
Rule 2: Dependencies point one way.
appdepends on features.- Features depend on
coreanddesign_system. coredepends on nothing in your project.- Never the reverse.
Rule 3: Expose as little as possible.
Each package shows a small public API. If something does not need to be public, keep it private. A small public surface is what makes later changes safe.
If you break Rule 1, you get a tangled app again, just with more folders. This is the most common way modular projects fail.
Connecting packages
Each feature is a Dart package. The app adds it in pubspec.yaml:
yaml
dependencies:
feature_auth:
path: packages/feature_auth
feature_orders:
path: packages/feature_orders
core:
path: packages/core
Each feature exposes its routes through a shared contract defined in core:
dart
abstract class FeatureModule {
List<RouteBase> get routes;
void registerDependencies(GetIt locator);
}
Each feature implements it:
dart
class OrdersModule implements FeatureModule {
@override
List<RouteBase> get routes => [
GoRoute(path: '/orders', builder: (_, __) => const OrdersScreen()),
];
@override
void registerDependencies(GetIt locator) {
locator.registerLazySingleton<OrdersRepository>(
() => OrdersRepositoryImpl(locator()),
);
}
}
The app then collects all modules in one place:
dart
final modules = <FeatureModule>[AuthModule(), OrdersModule(), ProfileModule()];
final router = GoRouter(
routes: [for (final m in modules) ...m.routes],
);
- To add a feature, you add one line to the list.
- To remove a feature, you remove one line.
- Features never know about each other.
State management
Pick one approach and use it everywhere. Common choices are Riverpod, Bloc and Provider.
- Keep state inside the feature. Only share what truly must be shared, and share it through
core. - Do not mix tools across modules. It confuses the team and slows code reviews.
- Choose what your team already knows. A familiar tool used well beats a “better” tool used badly.
- Do not switch mid-project unless there is a strong reason. The cost is high and the benefit is often small.
A good test: if you delete one feature folder, the rest of the app should still compile after you remove the module line. If it does not, state is leaking between features.
Testing
Modules make testing simpler and faster.
- Unit tests: run inside each package, so they are quick and focused.
- Widget tests: test one feature’s screens alone, with fake versions of
coreservices. - Integration tests: keep a few in the
appto check complete user flows, like login then place an order. - Run only what changed. In CI, run tests only for the packages that changed and the ones that depend on them. This keeps your pipeline fast as the project grows.
Because features depend on contracts, not on each other, you can replace real services with fakes easily:
dart
class FakeSessionService implements SessionService {
@override
String? get userId => 'test-user';
@override
Stream<bool> get isLoggedIn => Stream.value(true);
}
Tools that help
- Melos: runs commands (test, analyze, build) across all packages at once.
- Dart workspaces: newer Dart versions can manage multi-package repos natively and share one dependency resolution. Check which version fits your Flutter version.
- get_it or Riverpod: connect contracts to real implementations at app start.
- go_router: handles navigation across modules.
- Very Good CLI or your own templates: create new packages with the same structure every time.
- Lint rules: block imports between features automatically, so the rules do not depend on people remembering them.
Scaling: connect the app to the backend
A modular app works best when the backend follows the same idea. Many teams pair it with a cloud-native architecture built on small, independent services.
- Each Flutter feature can map to one backend service.
- Each side can be built, tested and released on its own.
- A problem in one service does not take down the whole app.
- You can scale busy parts (like orders) without scaling everything.
If the backend is one big block while the app is modular, you only get half the benefit.
Common mistakes
- Too many modules too early. A small app does not need 15 packages. Start with
coreplus 2-3 features and split later. - A shared “utils” dumping ground. If
coregrows without control, it becomes the new monolith. - Features importing each other “just this once.” This is how the tangle starts again.
- No owner for the design system. Every team then builds its own buttons and colors.
- Splitting by type, not by feature. A package called
all_modelsorall_screensdefeats the purpose. - No automatic checks. Rules that live only in a document get broken within weeks.
When NOT to go modular
- Prototypes or apps you plan to throw away.
- A solo developer building a small app with no growth planned.
- Very short projects where delivery speed matters more than long-term upkeep.
Modular setup costs time at the start. It pays back only when the app and the team will grow.
FAQ
Q. Is modular architecture the same as clean architecture?
A. No. Clean architecture is about layers (UI, logic, data). Modular architecture is about splitting by feature. Most teams use both together: modules on the outside, layers inside each module.
Q. How many modules should I start with?
A. Start small: one core, one design_system, and your first features. Split more only when a feature gets big or needs its own team.
Q. Does modular architecture slow down the app?
A. No. It is a code-organization tool. Users see no difference in speed.
Q. Can I move an existing app to modular architecture?
A. Yes, but do it step by step. Pull out core and design_system first. Then move one feature at a time, starting with the one that has the fewest links to others.
Q. Which state management tool is best for modular apps?
A. There is no single best one. Pick one, such as Riverpod or Bloc, and use it across all modules.
Next step
Planning a Flutter app that must grow for years? Talk to our team about structure, backend and delivery plan.
