"Just delete the features you don't need" is the easiest thing in the world to write in a README, and the hardest to make true.
I hit this building a Flutter app with six verticals in one codebase — marketplace, ride-hailing, car rentals, social feed, chat, wallet. Deleting one should have been simple. It wasn't, because every feature had tendrils:
- a route in the central route table
- a tab hardcoded in the app shell
- a button on the home screen
- a service registered in
main()
Remove the feature folder and you get a wall of compile errors from files that have nothing to do with it. Here's what actually worked.
Make three things data instead of code
1. Routes
Each feature exposes its own routes from its own folder:
class WalletModule extends AppModule {
const WalletModule();
@override
String get id => 'wallet';
@override
List<GetPage> get pages => [
GetPage(name: AppRoutes.wallet, page: () => const WalletScreen()),
];
}
The app's route table becomes a composition:
static final routes = <GetPage>[
..._centralRoutes,
...ModuleRegistry.pages,
];
Adding or removing a feature stops being an edit to a shared file. It's one line in a registry.
2. Bottom-navigation tabs
This one surprised me. My app shell imported the feed widget directly:
// before — the always-present shell depends on an optional feature
import '../feed/feed_tab.dart';
The shell ships in every build. That import meant the social feature could never be removed.
So a tab became a small data class that a feature contributes:
class ShellTab {
final String id;
final int order; // core tabs use 10/30/40
final IconData icon;
final String labelKey;
final Widget Function() builder;
}
Social contributes its tab at order 20, slotting between home and alerts without the shell knowing it exists. The shell merges its own tabs with ModuleRegistry.shellTabs and sorts.
3. Entry points
Home screens linked to feature screens with Get.toNamed(...). Named routes are already decoupled — no import needed — but navigating to a route that isn't registered just fails silently.
So the route table answers questions about itself:
static final Set<String> _names = {for (final p in routes) p.name};
static bool hasRoute(String route) => _names.contains(route);
And the UI asks before offering:
if (AppPages.hasRoute(AppRoutes.cart))
ServiceTile(label: 'Cart', onTap: () => Get.toNamed(AppRoutes.cart)),
A build without the module simply doesn't show the button, instead of showing one that goes nowhere.
Two things I got wrong
Both were embarrassing, and both took one line to fix once I actually looked.
A home layout imported an entire feature to format a date. There was a feedRelativeTime() helper living inside feed_tab.dart, and the home screen used it. That single import made the social feature non-removable. Moving the helper to shared/ removed the dependency completely — it was never real coupling, just misplaced code.
Another layout imported a map controller for two numbers. A static const default latitude and longitude. Same fix.
If you try this on your own codebase, look for these first. A surprising amount of "architectural coupling" is just something sitting in the wrong file.
Navigation is not the same as capability
The subtler bug came later. My checkout screen offered "pay with wallet" unconditionally.
In a build without the wallet module, that option still worked — the service was still registered in main(), the balance was real. There was just no wallet screen to open. A payment method with nowhere to top up.
hasRoute was the wrong question. It answers "can I navigate there?" What I needed was "is this feature in the build?":
ModuleRegistry.isEnabled('wallet') // is this FEATURE here?
AppPages.hasRoute(AppRoutes.wallet) // can I navigate there?
Conflating those two is exactly how you ship a payment option you can't support.
This is also why I stopped trying to move shared services into modules. WalletService and CartService are used by several features and stay registered centrally — they're cheap and always resolvable. What must not leak is the UI for a feature that isn't installed.
How I know it works
The part that made this trustworthy isn't the architecture, it's the test.
I remove modules from the registry and rebuild, then assert:
- the app still analyzes clean — no dangling references
- the route count drops by exactly the number that module owned
- the tab bar loses exactly the tabs it contributed
-
isEnabledreports it absent
Dropping the marketplace module takes the app from 38 routes to 30, removes the Shopping home layout from Settings entirely, and falls back to the all-in-one layout if that's what the user had saved. A storefront home screen with no store is worse than not offering it.
Claims about modularity are cheap. Deleting things and watching the build stay green is not.
What I haven't solved
Repositories live in a shared data/ layer, which is what lets home screens preview any feature. That's a deliberate trade for legibility, but it means deleting a feature folder outright still needs its repository registration removed from main().
Someone on r/FlutterDev suggested an event bus — modules fire events instead of calling each other, and a base module routes them. It's a genuinely better answer for cross-module communication. The trade-off is compile-time safety: you can't see who handles an event, or whether anyone does, and tracing a flow becomes grepping for string keys. For a codebase other people have to read quickly, I'm not sure that's the right trade. Still thinking about it.
This came out of building Paragon, a Flutter template. The demo is open if you want to poke at it — no signup.
Top comments (0)