DEV Community

Cover image for How I Made Features in a Large Flutter App Actually Removable
Paragon Framework
Paragon Framework

Posted on

How I Made Features in a Large Flutter App Actually Removable

"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()),
      ];
}
Enter fullscreen mode Exit fullscreen mode

The app's route table becomes a composition:

static final routes = <GetPage>[
  ..._centralRoutes,
  ...ModuleRegistry.pages,
];
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

And the UI asks before offering:

if (AppPages.hasRoute(AppRoutes.cart))
  ServiceTile(label: 'Cart', onTap: () => Get.toNamed(AppRoutes.cart)),
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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
  • isEnabled reports 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)