FlutterProject StructureMay 2, 202610 min read241

Scaling Flutter: A Practical Guide to Feature-Based Architecture Across Multiple Repositories

As a Flutter application grows from a simple prototype into a complex, enterprise-level product, the way you organize your code becomes just as critical as the code itself. If you’ve ever stared at a massive, monolithic lib folder and felt a sense of impending dread when trying to untangle dependencies, you aren’t alone.

While feature-based architecture (packaging by feature rather than by layer) within a single codebase is a massive step forward, sometimes even that isn’t enough. When multiple teams are working simultaneously, or when you have distinct features that need strict isolation, splitting your feature-based architecture across multiple repositories (polyrepo) can be a game-changer.

Here is a deep dive into why you should consider a multi-repo feature-based architecture in Flutter, and exactly how to pull it off.

1_fxk_yyrbxchUKKz6WKKb8g.webp

Why Multi-Repo?

Before tearing your codebase apart, it is important to understand the specific problems a multi-repo setup solves:

  • Strict Isolation: It becomes physically impossible to accidentally import a UI component from the “Settings” feature into the “Checkout” feature. If the dependency isn’t explicitly declared, the code simply won’t compile.
  • Clear Ownership: Different teams can own entirely separate repositories. Team A manages the authentication repo, while Team B manages the user profile repo.
  • Granular Versioning: Features can be versioned independently. If a bug is fixed in the “Chat” feature, you need to bump the version of that specific package rather than the entire app.
  • Faster CI/CD: Continuous Integration pipelines only need to run tests and static analysis for the specific repository that was changed, drastically reducing build times.
  • The Architecture Layout

    To make this work seamlessly, you need a clear hierarchy. A typical multi-repo Flutter architecture consists of three main tiers:

    1. The Core & Shared Repositories (The Foundation)

    These are the lowest-level repositories. They do not know your specific features and are heavily reused.
  • Core / Infrastructure: Contains network clients, local database setups, and third-party wrappers (like your Firebase initialization and crashlytics setup).
  • Design System / UI Kit: A repository dedicated purely to your app’s typography, colors, and custom reusable widgets (buttons, text fields, cards).
  • Domain Models: Shared entities and interfaces that multiple features might need to agree upon.
  • 1.The Core & Shared Repositories (The Foundation)
    /organization_root
    │
    ├── core_domain/                # Business Logic & Interfaces (The "Brains")
    │   ├── lib/
    │   │   ├── src/
    │   │   │   ├── transponder/
    │   │   │   │   ├── app_transponder.dart  # Abstract interface definition
    │   │   │   │   └── app_event.dart        # Base event classes
    │   │   │   ├── models/                   # Global entities (e.g., User, Order)
    │   │   │   └── failures/                 # Shared error/exception handling
    │   │   └── core_domain.dart              # Main barrel file
    │   └── pubspec.yaml
    │
    ├── core_ui/                    # The Design System (The "Look")
    │   ├── assets/                 # Shared fonts, icons, and logos
    │   ├── lib/
    │   │   ├── src/
    │   │   │   ├── theme/
    │   │   │   │   ├── app_colors.dart       # Brand color palette
    │   │   │   │   └── app_typography.dart   # Text styles
    │   │   │   └── widgets/                  # Atomic UI components
    │   │   │       ├── custom_button.dart
    │   │   │       └── loading_indicator.dart
    │   │   └── core_ui.dart                  # Main barrel file
    │   └── pubspec.yaml
    │
    └── core_infrastructure/        # Third-party & System Wrappers (The "Tools")
        ├── lib/
        │   ├── src/
        │   │   ├── network/
        │   │   │   ├── api_client.dart       # Dio or Http wrapper
        │   │   │   └── endpoints.dart        # Shared API constants
        │   │   ├── storage/
        │   │   │   └── local_storage.dart    # Shared preferences or Hive setup
        │   │   └── analytics/
        │   │       └── firebase_logger.dart  # Analytics wrapper
        │   └── core_infrastructure.dart
        └── pubspec.yaml
    

    2. The Features Repositories (The Building Blocks)

    These repositories contain the actual business logic and UI for a distinct domain of your app.
  • They depend on the Core and Design System repos.
  • Crucially, they rarely depend on each other. (Example: feature_auth, feature_dashboard, feature_settings).
  • 2.The Features Repositories (The Building Blocks)
    /organization_root
    │
    ├── feature_checkout/           # Business domain for payments & orders
    │   ├── lib/
    │   │   ├── src/
    │   │   │   ├── data/           # Repositories & Data Sources (API calls)
    │   │   │   ├── domain/         # Use Cases & Local Entities
    │   │   │   └── presentation/   # UI, State Management & Controllers
    │   │   │       ├── checkout_screen.dart
    │   │   │       └── checkout_controller.dart  <-- Broadcasts OrderPlacedEvent
    │   │   └── feature_checkout.dart
    │   ├── test/                   # Isolated unit & widget tests
    │   └── pubspec.yaml            # Depends on core_domain, core_ui, core_infra
    │
    ├── feature_dashboard/          # Business domain for overview & stats
    │   ├── lib/
    │   │   ├── src/
    │   │   │   ├── domain/
    │   │   │   └── presentation/
    │   │   │       ├── dashboard_screen.dart
    │   │   │       └── activity_badge.dart      <-- Listens for OrderPlacedEvent
    │   │   └── feature_dashboard.dart
    │   ├── test/
    │   └── pubspec.yaml            # Depends on core_domain, core_ui
    │
    └── feature_auth/               # Business domain for login & registration
        ├── lib/
        │   ├── src/
        │   │   ├── domain/
        │   │   └── presentation/
        │   │       └── login_screen.dart
        │   └── feature_auth.dart
        ├── test/
        └── pubspec.yaml
    

    3. The Host Application (The Glue)

    This is your actual Flutter application. It is surprisingly lightweight. Its only job to.
  • Import all the individual feature repositories.
  • Initialize the core service.
  • Wire up the global routing (stitching the feature screens together).
  • Handle Dependency Injection by providing the required use cases to the features.
  • 3. The Host Application (The Glue)
    /organization_root
    │
    └── host_app/                   # The main executable Flutter project
        ├── lib/
        │   ├── src/
        │   │   ├── di/             # The Wiring Room
        │   │   │   ├── injection.dart        # GetIt setup; Implements AppTransponder
        │   │   │   └── feature_modules.dart  # Inits individual feature dependencies
        │   │   │
        │   │   ├── routing/        # The Map
        │   │   │   ├── app_router.dart       # GoRouter/AutoRoute; Imports feature screens
        │   │   │   └── routes.dart           # Route name constants
        │   │   │
        │   │   ├── app.dart        # The Root Widget (MaterialApp, Theme setup)
        │   │   └── config/         # Environment flavors (dev, staging, prod)
        │   │
        │   └── main.dart           # Entry point: runs app and triggers DI
        │
        ├── assets/                 # App icons and splash screens
        ├── test_driver/            # Integration tests (testing the whole flow)
        └── pubspec.yaml            # The Master List (Depends on ALL Core & Features)
    

    Implementation: Tying it Together

    The most common hurdle in a multi-repo setup is managing the dependencies. Since these aren’t public packages on pub.dev, you have two main options:

    1. Using Git Dependencies:

    This is the most straightforward approach. In your Host App’s pubspec.yaml, you point directly to the Git repository of your feature:

    dependencies:
      flutter:
        sdk: flutter
      core_ui:
        git:
          url: git@github.com:your-org/core_ui.git
          ref: main 
      feature_auth:
        git:
          url: git@github.com:your-org/feature_auth.git
          ref: v1.2.0 # Always pin to a specific tag or commit for stability!
    

    2. A Private Pub Server

    For larger organizations, setting up a private package repository (using tools like Unpub or a cloud provider that supports Dart repositories) offers a more professional workflow, allowing you to use standard dart pub publish commands internally.

    The “DI Transponder”: How Isolated Features Communicate

    If feature_checkout cannot import feature_dashboard, what happens when a user successfully completes an order, and the dashboard needs to update a notification badge or refresh the user’s recent activity?

    If you aren’t careful, you might be tempted to create a backdoor dependency, completely defeating the purpose of your multi-repo setup. The solution is to use a DI Transponder — a central communication hub injected into your features via Dependency injection.

    Think of it as an air traffic control tower. The planes (features) don’t talk directly to each other; they broadcast their status to the tower, and the tower relays that information to anyone who needs to know.

    1. The Contract (In the Core Repo)

    In your shared core_infrastructure or core_domain repository, you define the interface for your transponder. This could be a stream-based event bus or a set of delegate callbacks.

    // Inside core_domain repo
    abstract class AppTransponder {
      Stream<AppEvent> get events;
      void broadcast(AppEvent event);
    }
    abstract class AppEvent {}
    class UserLoggedOutEvent extends AppEvent {}
    class OrderPlacedEvent extends AppEvent {
      final String orderId;
      OrderPlacedEvent(this.orderId);
    }
    

    2. The Broadcaster (In Feature Checkout)

    Your features depend on the core_domain, so they have access to this interface. When a user finalizes their purchase, feature_checkout requests the AppTransponder from your DI container (like get_it) and broadcasts the event.

    // Inside feature_checkout repo
    class CheckoutController {
      final AppTransponder transponder;
      CheckoutController(this.transponder);
      Future<void> submitOrder(OrderData data) async {
        // ... logic to process payment and submit order
        final newOrderId = "ORD-12345";
        
        // Broadcast the success to the rest of the app
        transponder.broadcast(OrderPlacedEvent(newOrderId));
      }
    }
    

    Notice that feature_checkout has no idea who is listening. It just does its job and fires the flare.

    3. The Listener (In Feature Dashboard)

    Meanwhile, feature_dashboard needs to show a little red notification dot indicating a new activity update. It also requests the AppTransonder via DI and listens to the stream.

    // Inside feature_dashboard repo
    class DashboardActivityBadge extends StatelessWidget {
      final AppTransponder transponder = locator<AppTransponder>();
      @override
      Widget build(BuildContext context) {
        return StreamBuilder<AppEvent>(
          stream: transponder.events.where((e) => e is OrderPlacedEvent),
          builder: (context, snapshot) {
            if (snapshot.hasData) {
              // A new order was placed, show the notification dot!
              return const Icon(Icons.notifications_active, color: Colors.red);
            }
            return const Icon(Icons.notifications_none);
          }
        );
      }
    }
    

    4. The Implementation (In the Host App)

    Finally, the actual implementation of the transponder lives in your lightweight Host App. During app initialization, the Host App registers the singleton and injects it into the features.

    // Inside host_app repo
    class DefaultAppTransponder implements AppTransponder {
      final _controller = StreamController<AppEvent>.broadcast();
      @override
      Stream<AppEvent> get events => _controller.stream;
      @override
      void broadcast(AppEvent event) => _controller.add(event);
    }
    // Initialization in main.dart or your DI setup file
    locator.registerSingleton<AppTransponder>(DefaultAppTransponder());
    

    By routing communication through a DI Transponder defined in the Core, your features remain completely agnostic of one another. You can compile, test, and develop feature_checkout in total isolation, mocking the transponder to simulate external events.

    Developer Experience: Staying Sane Locally

    Working across 5+ repositories can be a headache if you have to open separate IDE windows for each one.

    To maintain a smooth workflow, leverage Multi-root Workspaces. In VS Code, you can create a .code-workspace file that gathers all your cloned repositories into a single integrated window. This allows you to search across the entire project ecosystem, run the host app, and edit the feature package simultaneously without context switching.

    {
      "folders": [
        { "path": "../host_app" },
        { "path": "../core_ui" },
        { "path": "../core_infrastructure" },
        { "path": "../feature_auth" }
      ]
    }
    

    The Trade-offs

    It’s not all sunshine and perfect code isolation. You need to be prepared for the downsides:

  • Refactoring Overhead: Changing a shared core component means updating the core repo, pushing it, tagging a new version, and then going into every feature repo to update its pubspec.yaml to point to the new version.
  • Initial Setup: Establishing the CI/CD pipelines for multiple repositories takes significantly more upfront DevOps work than a single repository.
  • Final Thoughts

    A multi-repo feature-based architecture is a heavy-duty tool. It enforces discipline, prevents spaghetti code, and empowers multiple teams to move fast without stepping on each other’s toes. By combining strict isolation with smart communication patterns like the DI Transponder, you can keep your dependency graphs clean. If your app is scaling rapidly and your monolithic codebase is starting to feel brittle, it might be exactly what you need.

    1_iNCv2uIkvMN5OdwMeblZKA.webp

    Enjoyed this read?

    1likes
    Comments

    Loading…