Inside Mono-Repo Flutter Architecture - FUT Maidaan
How I turned 3-minute Flutter builds into 15-second wins with modular packages
Most Flutter developers build their apps like a single giant house where everything lives under one roof. The kitchen connects directly to the bedroom, the bathroom shares pipes with the living room and when you need to fix the plumbing, you have to tear down half the structure. This approach worked fine when I was building small apps but when I started working on FUT Maidaan, a complex football player database app with over 18,000 players, I realized I needed something completely different. Here’s how I transformed a potentially chaotic codebase into a collection of independent, reusable building blocks that made development faster, testing simpler and maintenance effortless.
If you are figuring out what is FUT Maidaan then discover yourself downloading the app.
The Problem with Monolithic Flutter Apps
When you create a standard Flutter app, everything exists in a single package. Your API calls, UI components, business logic and feature screens all mingle together in the same lib folder. This creates several problems that become more apparent as your app grows.
Code generation becomes painfully slow. When you run build_runner on a large monolithic app, it has to process thousands of files even when you’ve only changed a small widget. This means longer coffee breaks and broken development flow.
Testing becomes a nightmare because everything depends on everything else. Want to test your player details screen? You’ll need to mock the entire app context, navigation system and API layer just to verify that a rating displays correctly.
Sharing code between projects becomes impossible. That beautiful design system you built? Or complex design-agnostic widgets that you built It’s so tightly coupled to your current app that extracting it for your next project would require major surgery.
Note: In FUT Maidaan app, initial build_runner executions were taking 2-3 minutes for simple model changes. This development friction was slowing down feature implementation significantly.
The Mono-Repo Solution
A mono-repo architecture solves these problems by breaking your app into small, focused packages that live in the same repository but operate independently. Think of it like building with Lego blocks instead of carving everything from a single piece of marble.
In FUT Maidaan app, I organized packages into four distinct categories that each serve a specific purpose. This organization isn’t arbitrary. It follows the dependency flow and ensures that lower-level packages never depend on higher-level ones.
Quality Packages: The Foundation
Quality packages form the foundation of the entire system:
quality_analysis - Holds only the linter rules and every package depends on it, ensuring all packages follow the same lint rules
quality_bdd_test - Provides Gherkin-style test cases and testing framework that feature and domain packages depend on
Utility Packages: App-Agnostic Services
Utility packages provide functionality that could work in any Flutter app:
utility_navigation - Abstracts go_router or auto_route packages for consistent navigation across features
utility_di - Configures dependency injection using get_it and injectable for almost all packages, creating a clean dependency graph
Core Packages: Shared Business Concerns
Core packages handle the fundamental business logic that multiple features need:
core_api_client - Foundation for API connection via dio with custom interceptors, consistent error handling and token management
core_design_system - Atomic design principles for modular and consistent app UI following reusable component patterns
core_domain - Common data layer, domain layer and presentation layer consumed by multiple features, containing shared models, APIs and repositories. This package follows clean architecture.
Feature Packages: Self-Contained User Flows
Feature packages implement specific user journeys using clean architecture:
feature_player - Follows clean architecture (data, domain, presentation) for onboarding, player lists and player details screens
feature_filter - Manages search and filtering functionality with complex query building UI
Additional features - Easy to spin up new features following the same patterns
And, here’s how the main pubspec.yaml looks like:
How Dependencies Flow Through the System
The beauty of this architecture lies in how packages depend on each other. Dependencies always flow downward, creating a directed acyclic graph that prevents circular dependencies and makes the system predictable.
Don’t worry by looking at these many arrows. They are for your clarity. Let me walk you through how this dependency flow works in practice:
Feature packages sit at the top and can depend on any lower-level package. When the player feature needs to make an API call, it uses repositories from the domain package. When it needs to display a component, it imports from the design_system package. When it needs navigation, it calls the navigation package.
Core packages depend only on utility and quality packages. The domain package uses dependency injection from the utility layer and follows lint rules from the quality layer but it never imports anything from feature packages.
Utility packages depend only on quality packages. They provide foundational services but don’t know anything about your business logic or UI.
Quality packages depend on nothing except external libraries. They establish the rules and testing framework that everything else follows. If you later wish to have common test files in bdd_test package, as it won’t violate the rule of features depending on quality.
This dependency structure creates several powerful benefits:
When you modify a low-level package, you immediately know which packages might be affected
When you want to add a new feature, you can be confident it won’t break existing functionality if you follow the dependency rules
Circular dependencies become impossible by design and even if you face such a challenge, you know the architecture is disturbed and you are moving in opposite direction of modular app.
Code reuse becomes natural rather than forced
The Magic of Granular Code Generation
One of the most immediate benefits I noticed was the dramatic improvement in code generation speed. When I modify a single component in the design_system or add api in domain package, I run build_runner for only that package and it processes files in that package only. This typically takes 10-15 seconds instead of the 2-3 minutes required for a monolithic app.
This speed improvement compounds throughout development:
You make changes more frequently because the feedback loop is faster
You experiment more freely because you’re not waiting for long build processes
You catch errors sooner because you can regenerate code after each small change
The improved speed comes from isolating the blast radius of changes. When you modify a model in the player feature package, only that package needs to regenerate its code. The filter package, domain package and design_system package remain untouched.
Real-world impact: What used to be a 3-minute wait for code generation after changing a single data model became a 15-second operation. Over a full development day, this saved hours of waiting time. You can always run build_runner in watch mode to never wait.
Clean Architecture Within Each Package
Each package in my mono-repo follows clean architecture principles but the implementation varies based on the package’s purpose. Feature packages implement the full data-domain-presentation layering, while utility packages focus on a single responsibility.
Feature Package Architecture
In feature packages like player and filter, the architecture follows these layers:
Presentation Layer contains BLoC state management classes that handle user interactions. These BLoCs communicate exclusively with use cases from the domain layer, never directly with repositories or data sources.
Domain Layer contains use cases, entities and repository interfaces. Use cases encapsulate business logic and coordinate between different repositories. For example, the GetPlayerDetails
use case might calls both the PlayerRepository
and PriceRepository
to build a complete player profile.
Data Layer implements repository interfaces and manages data sources. Repositories coordinate between local storage using Drift and remote APIs through Supabase. They handle caching, error handling and data transformation transparently.
Shared Domain Architecture
The domain package follows a similar structure but contains only shared concerns. Its entities represent core business concepts like Player
, Team
and League
that multiple features need. Its repositories handle data that doesn’t belong to a specific feature. For any feature specific API calls or business logic, the feature packages will have their own data-domain layers.
State Management That Scales
I chose flutter_bloc as the sole state management solution because it enforces clear boundaries between different parts of the system. Each BLoC handles a specific user flow and communicates through well-defined events and states.
The state management follows a strict communication pattern:
UI triggers events → BLoCs receive user interactions
BLoCs call use cases → Business logic coordination happens here
Use cases call repositories → Data fetching and manipulation
Repositories return data → Results flow back up the chain
To reduce the verbosity that BLoC is known for, I use the mappable package instead of freezed. Mappable generates immutable classes with less boilerplate and supports inheritance for data classes, which proves useful for complex state hierarchies. As I remember, new freezed version also does it, but I am preferring 1 generated file over 2.
The state management architecture scales beautifully as the app grows. Adding a new feature means creating new BLoCs and respective pages that follow the same patterns. These BLoCs can safely depend on existing use cases and repositories without risking conflicts.
API Architecture That Adapts
The api_client package demonstrates how mono-repo architecture handles cross-cutting concerns elegantly. All network communication flows through this single package but features remain decoupled from specific API implementations.
I use Retrofit with Dio to generate type-safe API clients. The api_client package provides a configured Dio instance with two custom interceptors that handle common concerns automatically:
ApiKeyInterceptor
- Attaches authentication tokens to every request, eliminating repetitive boilerplate in feature codeApiErrorInterceptor
- Transforms network errors into domain-specific exceptions that the presentation layer can handle appropriately
This centralized approach proved invaluable when I had to switch data sources. The original API I was using stopped updating player data for several months, which would have been catastrophic for a football app during the most important part of the season.
Because my API logic was isolated in the api_client and domain packages, switching from the external API to Supabase took only two days. I modified the repository implementations in the domain package and updated the API client configuration. No feature code needed to change because the use cases and BLoCs continued working with the same interfaces.
Crisis Management: When your primary data source fails, having isolated API logic means the difference between a two-day fix and a complete rewrite. This architectural decision saved my app during a critical period. Before making shift to Supabase, I was calling API for fetching players and almost everything but last season that API vanished. When I switched to supabase, all I had to do was make supabase calls from repository layer instead of calling API.
Dependency Injection That Just Works
Managing dependencies across multiple packages could become a nightmare without proper tooling. I use get_it with injectable to create a dependency graph that configures itself automatically.
Each package defines its own dependencies using injectable annotations:
When the app starts, all package dependency modules are registered with get_it in the correct order. This ensures that when a BLoC needs a use case and that use case needs a repository, everything is available without manual wiring.
The dependency injection setup follows the same hierarchical structure as the packages themselves:
Quality packages register first
Utility packages register second
Core packages register third
Feature packages register last
This order ensures that dependencies are always available when they’re needed. Injectable generates all the registration code, so adding a new dependency is as simple as adding an annotation.
Testing That Makes Sense
The package structure makes testing significantly more straightforward because each package has clear boundaries and minimal dependencies. You can test a use case by mocking only its direct dependencies, not the entire application context.
I use BDD-style tests with the bdd_widget_test package to create readable test scenarios:
Feature: Player Data Loading
Scenario: Player data loads successfully
Given I am on the player list page
When the page loads
Then I see text {'Lionel Messi'}
And I see text {'93'}
And I see text {'RW'}
And I don't see text {'Loading...'}
Scenario: Player data fails to load
Given I am on the player list page
And the network is unavailable
When the page loads
Then I see text {'Failed to load players'}
And I don't see text {'Lionel Messi'}
And I see widget {RetryButton}
Scenario: Loading state is displayed
Given I am on the player list page
And the API response is delayed
When the page loads
Then I see text {'Loading...'}
And I see widget {CircularProgressIndicator}
And I don't see text {'Lionel Messi'}
These tests read like natural language specifications and can be understood by non-technical stakeholders. Appropriate _test.dart files are created on generation. More on this in future posts. The isolated nature of packages means you can run tests for individual packages without setting up the entire application.
When you need to test integration between packages, the dependency injection system makes it easy to replace real services with test doubles. You can test the complete user flow while still maintaining control over external dependencies.
The Development Experience
Working with this architecture feels fundamentally different from traditional Flutter development. Adding a new feature becomes a process of composing existing building blocks rather than weaving new functionality into an existing codebase.
When I decided to add player price tracking to FUT Maidaan, the process was remarkably clean:
Created a new API client in the domain package for price data
Added price-related use cases that coordinated with existing player data
Updated the player feature to display the new price information
The filter feature, design system and navigation packages remained completely untouched
I developed this solo, so this doesn’t apply to me. But for larger teams the modular structure also makes code review more focused. When someone reviews a pull request that adds filter functionality, they only need to understand the filter package and its immediate dependencies. They don’t need to comprehend the entire application to verify that the changes are correct.
Building solo and working on multiple projects simultaneously demands that your app architecture is modular , scalable and has clear separation of concerns, otherwise most of your time goes in recollecting how the app is developed. No matter app is small or big, if your app is scalable then you don’t procrastinate adding new feature to app for months.
Development speed improvements compound throughout the day:
Code generation runs faster - Only affected packages rebuild
Tests run faster - You can test individual packages in isolation
Builds complete faster - Each package processes only what it needs
This speed improvement accumulates throughout the day, making development more enjoyable and productive.
Sharing Code Between Projects
This is an ambitious thought - One of the long-term benefits of mono-repo architecture is code reusability. The design_system package I built for FUT Maidaan contains many components that would work perfectly in other sports apps or any app that needs clean data visualization.
The utility packages are completely app-agnostic:
The navigation abstraction could work with any routing solution
Dependency injection configuration applies to any Flutter project
Analysis rules establish consistent code quality standards
Even some core packages like api_client could be adapted for other projects with minimal changes. The HTTP interceptors, error handling and caching logic solve common problems that most apps face.
This reusability means that future projects can start with a solid foundation instead of building everything from scratch. You’re not just building an app; you’re building a toolkit for all your future apps.
Building Your Own Mono-Repo
If you’re convinced that mono-repo architecture could benefit your Flutter project, start small and grow incrementally. Begin by extracting your most reusable components into separate packages within your existing project structure.
Step-by-Step Migration Strategy
Create a design_system package first because it has minimal dependencies and immediate benefits. Keep it free from domain knowledge; think how material widgets are created. Move your reusable widgets, themes and style constants into this package and update your feature code to import from it.
Next, extract utility concerns like navigation and dependency injection. These packages will be used by multiple features and help establish the dependency flow patterns that make mono-repo architecture effective.
Gradually extract feature functionality into dedicated packages, ensuring that each package follows clean architecture principles and maintains clear boundaries with other packages.
Set up proper tooling using melos for mono-repo management, which allows you to run commands across all packages with 1 command and manage dependencies effectively.
The investment in setting up this architecture pays dividends quickly. You’ll notice faster development cycles, easier testing and more maintainable code within the first few weeks of adoption.
The mono-repo approach transformed how I think about Flutter development. Instead of building apps as monolithic structures, I now build them as collections of focused, reusable components that happen to work together to create a cohesive user experience. This shift in perspective makes complex projects manageable and sets the foundation for building better apps faster.
I have used this structure in over 4-5 apps now and it has never felt that I over-engineered for no reason or I made a mistake.
When you’re ready to scale beyond simple Flutter apps, mono-repo architecture provides the structure and discipline needed to manage complexity without sacrificing development speed. The initial investment in proper architecture pays off exponentially as your project grows and evolves.
This architecture deep-dive is just the beginning. Building FUT Maidan from idea to App Store taught me dozens of lessons about what actually works in production. More insights from the trenches coming soon.
If you haven’t read about how all this started, I would recommend to understand the key insights that I shared in this post about my learnings and journey to make it to production on both mobile platforms - Android and iOS with Flutter.