How to Structure a Feature-Based Flutter Project
A practical guide to structuring feature-based Flutter apps: vertical slice architecture, barrel file encapsulation, cross-feature decoupling, and monorepo scaling.

How to Structure a Feature-Based Flutter Project
When starting a Flutter application, project structure rarely feels critical. A single lib/ directory containing a dozen widget files, an HTTP client, and a few state models is easy to navigate. A small team can ship features rapidly because every file is within immediate reach.
As the product matures, however, that initial layout begins to resist change. Adding a new user capability requires modifying files scattered across five root folders. Multiple engineers opening pull requests against the same shared directories encounter constant merge conflicts. Refactoring or decommissioning an obsolete workflow becomes a multi-day forensic exercise because no single boundary delineates where a feature begins and where it ends.
These friction points are not accidental. They are the direct consequence of organizing a codebase around technical roles (Layer-First) rather than business capabilities (Feature-First).
This article provides an in-depth, production-tested guide to structuring Flutter applications using vertical feature slices. We examine why traditional layer-first layouts fail at scale, how to design an encapsulated feature slice, how to govern core and shared code without creating a "common dumpster," how to decouple cross-feature communication, and when to graduate from folder-based modularization to a multi-package monorepo.
1. Architectural Evolution: Layer-First vs. Feature-First
The way a codebase is packaged determines how engineers reason about system boundaries, manage dependencies, and collaborate in parallel. In Flutter, projects almost universally start with technical packaging before migrating toward domain-driven packaging.
The Failure Modes of Layer-First Packaging
Layer-First packaging organizes the root of lib/ strictly by architectural role:
lib/
├── controllers/
│ ├── auth_controller.dart
│ ├── cart_controller.dart
│ └── catalog_controller.dart
├── models/
│ ├── auth_token.dart
│ ├── cart_item.dart
│ └── product.dart
├── repositories/
│ ├── auth_repository.dart
│ ├── cart_repository.dart
│ └── catalog_repository.dart
├── services/
│ ├── api_service.dart
│ ├── auth_service.dart
│ └── cart_service.dart
└── views/
├── auth_view.dart
├── cart_view.dart
└── catalog_view.dart
While this structure appears clean on day one, it suffers from several acute architectural pathologies as the application scales:
- Shotgun Surgery: In production, engineering tasks are almost always oriented around product capabilities (for example, "Add a discount code field to the checkout flow"). In a layer-first layout, completing this single task requires navigating to and modifying files in
/models,/services,/repositories,/controllers, and/views. The cognitive load of switching context across disparate directories slows down delivery and makes code reviews fragmented. - Low Cohesion and High Coupling: Unrelated business domains are grouped together merely because they share a technical category.
auth_service.dartlives next tocart_service.dart, despite having zero domain connection. Conversely, classes that must change together are artificially separated by hundreds of lines in the project tree. - Merge Contention Across Engineering Squads: When multiple cross-functional teams work in parallel, their pull requests routinely collide on shared directory roots (
/viewsor/models). Git merge conflicts become frequent, increasing the risk of regression. - The Impossible Deletion Problem: When a product feature is deprecated, removing it from a layer-first project requires hunting down every scattered class, model, and helper. If even one unused file is missed, orphaned dead code lingers indefinitely.
Layer-First (Horizontal Slices):
┌──────────────────────────────────────────────────────────────┐
│ Views: [CatalogView] [CartView] [CheckoutView]
├──────────────────────────────────────────────────────────────┤
│ Controllers: [CatalogCtrl] [CartCtrl] [CheckoutCtrl]
├──────────────────────────────────────────────────────────────┤
│ Repositories: [CatalogRepo] [CartRepo] [CheckoutRepo]
├──────────────────────────────────────────────────────────────┤
│ Models: [Product] [CartItem] [Order]
└──────────────────────────────────────────────────────────────┘
▲ A single feature change cuts horizontally across every layer.
Feature-First (Vertical Slices):
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ Catalog Slice │ │ Cart Slice │ │ Checkout Slice │
│ - View │ │ - View │ │ - View │
│ - Controller │ │ - Controller │ │ - Controller │
│ - Repository │ │ - Repository │ │ - Repository │
│ - Model │ │ - Model │ │ - Model │
└────────────────┘ └────────────────┘ └────────────────┘
▲ Changes remain isolated inside the feature's bounded context.
Feature-First Packaging (Vertical Slices)
Feature-First architecture organizes code around bounded domain contexts. Everything required for a product capability—its domain models, data fetching, state management, and user interface—is colocated within a single feature directory.
lib/
└── features/
├── auth/
├── cart/
├── catalog/
└── checkout/
By packaging code by feature:
- High Cohesion: Changes to a business requirement stay localized to that feature's directory.
- Cognitive Clarity: An engineer assigned to the checkout flow works entirely inside
lib/features/checkout/. - Safe Deprecation: To remove a feature, deleting the feature directory removes all associated domain logic, UI widgets, and network calls in a single atomic commit.
- Squad Autonomy: Different squads can own different feature folders with minimal git merge interference.
Architectural Comparison: Layer-First vs. Feature-First
| Dimension | Layer-First (Technical Packaging) | Feature-First (Domain Vertical Slices) |
| :--- | :--- | :--- |
| Primary Organizing Principle | Technical role (models, views, controllers) | Business capability (auth, catalog, checkout) |
| Locality of Change | Low (Shotgun surgery across 4–6 directories) | High (Localized within the assigned feature directory) |
| Cohesion | Low (Unrelated domain classes share folders) | High (Related domain logic and UI are colocated) |
| Code Deprecation / Deletion | Risky; requires manual cleanup across entire tree | Atomic; delete the feature folder and its public exports |
| Multi-Squad Scaling | Frequent merge conflicts in shared folders | Isolated pull requests scoped to distinct feature domains |
| Onboarding Time | High; engineers must trace logic across the entire codebase | Low; engineers can master a single feature slice in isolation |
2. Anatomy of a Production Feature Slice
Organizing by feature does not mean abandoning Clean Architecture principles. Inside each feature, code is still structured into disciplined layers to maintain separation of concerns, testability, and independence from UI frameworks.
For a deeper exploration of dependency inversion in Flutter, see our companion guide on Flutter Clean Architecture for Maintainable Mobile Apps.
The Three Internal Tiers
Within a single feature slice (e.g., lib/features/checkout/), code divides into three internal tiers:
domain/(Core Business Rules):- Contains pure Dart code. Zero dependencies on Flutter widgets, UI elements, or third-party storage/network SDKs.
- Defines Entities (business objects with identity), Value Objects (immutable domain values), Domain Failures, and Repository Interfaces (abstract contracts defining what data operations the domain requires).
- May contain Use Cases or interactors that encapsulate discrete business operations.
data/(Infrastructure & Retrieval):- Implements the contracts defined in
domain/. - Contains Data Sources (remote REST/GraphQL clients, local database drivers), Data Transfer Objects (DTOs) with JSON serialization/deserialization logic, and Repository Implementations that coordinate caching and network calls.
- Implements the contracts defined in
presentation/(State & UI):- Contains Flutter widgets, page screens, and state management controllers.
- Manages UI state machines, user input validation, error presentation, and animations.
- Interacts with domain repository contracts or use cases to drive the UI. For state management patterns, see our comprehensive guide on Flutter State Management: Choosing the Right Approach for Your App.
Production Directory Layout Blueprint
Here is the standard layout for a production-grade Flutter application:
lib/
├── app/
│ ├── app.dart # Top-level MaterialApp and global theme wiring
│ ├── router.dart # App-wide declarative router (e.g., go_router)
│ └── service_locator.dart # Composition root (dependency injection / service locator)
├── core/
│ ├── error/
│ │ ├── exceptions.dart # Infrastructure-level exceptions
│ │ └── failures.dart # Sealed failure hierarchy for domain use
│ ├── network/
│ │ ├── api_client.dart # Network client wrapper (Dio / http)
│ │ └── network_info.dart # Connectivity status contract
│ └── storage/
│ └── secure_storage.dart # Encrypted key-value storage wrapper
├── shared/
│ ├── theme/
│ │ ├── app_colors.dart # Design system color tokens
│ │ └── app_typography.dart # Design system typography tokens
│ └── widgets/
│ ├── primary_button.dart # Design-system atomic button
│ └── status_badge.dart # Generic status chip
└── features/
├── catalog/
└── checkout/
├── checkout.dart # Public barrel file (exports public API only)
└── src/ # Private feature internals
├── domain/
│ ├── models/
│ │ ├── cart_item.dart
│ │ └── order_summary.dart
│ ├── repositories/
│ │ └── checkout_repository.dart
│ └── usecases/
│ └── place_order_use_case.dart
├── data/
│ ├── datasources/
│ │ └── checkout_remote_data_source.dart
│ ├── dtos/
│ │ └── order_request_dto.dart
│ └── repositories/
│ └── checkout_repository_impl.dart
└── presentation/
├── controllers/
│ ├── checkout_controller.dart
│ └── checkout_state.dart
├── screens/
│ └── checkout_screen.dart
└── widgets/
├── order_summary_card.dart
└── payment_method_selector.dart
3. Feature Encapsulation and the Public Barrel File (src/ Pattern)
A common point of failure in feature-based architectures is leakage of internal implementation details. If features/catalog/ is allowed to directly import a private state notifier or internal widget from features/checkout/src/presentation/widgets/payment_method_selector.dart, the boundary between the features collapses. Within months, the codebase regresses into a tangled web of circular dependencies.
The Dart src/ Encapsulation Model
The Dart SDK establishes a proven encapsulation convention used across the package ecosystem: the src/ directory convention.
Under this pattern:
- All concrete implementation files are placed inside a
src/subfolder (e.g.,lib/features/checkout/src/). - A single public barrel file is placed at the feature root (e.g.,
lib/features/checkout/checkout.dart). - The barrel file explicitly exports only the public entry points that external modules are permitted to consume:
- Primary screen widgets;
- Route argument objects;
- Public domain contracts or value objects needed for cross-feature composition.
- Internal helper widgets, controllers, DTOs, and data sources remain unexported inside
src/.
Implementing the Public Barrel File
// File: lib/features/checkout/checkout.dart
/// Public API entry point for the Checkout feature.
///
/// External callers and the top-level app router must import strictly this file.
/// Internal implementation files in `src/` must never be directly imported from
/// outside this feature.
library feature_checkout;
// Export public screens consumed by the router
export 'src/presentation/screens/checkout_screen.dart' show CheckoutScreen;
// Export typed route arguments for safe navigation
export 'src/presentation/navigation/checkout_route_args.dart' show CheckoutRouteArgs;
// Export domain entities or contracts if cross-feature composition requires them
export 'src/domain/models/order_summary.dart' show OrderSummary;
export 'src/domain/repositories/checkout_repository.dart' show CheckoutRepository;
Self-Contained Domain and Presentation Implementation
The following self-contained Dart 3 implementation illustrates the internal structure of the checkout feature slice.
First, the pure Dart domain contract and state models:
// File: lib/features/checkout/src/domain/models/order_summary.dart
import 'package:flutter/foundation.dart';
/// Immutable domain model representing an evaluated checkout order.
/// Pure Dart — zero dependencies on Flutter rendering or data drivers.
@immutable
class OrderSummary {
const OrderSummary({
required this.orderId,
required this.itemCount,
required this.totalAmountInCents,
});
final String orderId;
final int itemCount;
final int totalAmountInCents;
double get totalAmountInDollars => totalAmountInCents / 100.0;
}
// File: lib/features/checkout/src/domain/repositories/checkout_repository.dart
import '../models/order_summary.dart';
/// Abstract domain repository port.
/// Defines what operations the checkout domain requires from data infrastructure.
abstract interface class CheckoutRepository {
Future<OrderSummary> getSummary(String cartId);
Future<String> submitOrder(String cartId);
}
Next, the presentation state machine using modern Dart 3 sealed class hierarchies:
// File: lib/features/checkout/src/presentation/controllers/checkout_state.dart
import 'package:flutter/foundation.dart';
import '../../domain/models/order_summary.dart';
/// Sealed class hierarchy representing all exhaustive states of checkout.
sealed class CheckoutState {
const CheckoutState();
}
final class CheckoutInitial extends CheckoutState {
const CheckoutInitial();
}
final class CheckoutLoading extends CheckoutState {
const CheckoutLoading();
}
final class CheckoutLoaded extends CheckoutState {
const CheckoutLoaded(this.summary);
final OrderSummary summary;
}
final class CheckoutError extends CheckoutState {
const CheckoutError(this.message);
final String message;
}
Finally, the state controller and the exported presentation screen:
// File: lib/features/checkout/src/presentation/controllers/checkout_controller.dart
import 'package:flutter/foundation.dart';
import '../../domain/repositories/checkout_repository.dart';
import 'checkout_state.dart';
class CheckoutController extends ChangeNotifier {
CheckoutController({required CheckoutRepository repository})
: _repository = repository;
final CheckoutRepository _repository;
CheckoutState _state = const CheckoutInitial();
CheckoutState get state => _state;
Future<void> loadOrderSummary(String cartId) async {
_state = const CheckoutLoading();
notifyListeners();
try {
final summary = await _repository.getSummary(cartId);
_state = CheckoutLoaded(summary);
} catch (error) {
_state = CheckoutError(error.toString());
} finally {
notifyListeners();
}
}
Future<void> placeOrder(String cartId) async {
// Business execution logic for placing order
}
}
// File: lib/features/checkout/src/presentation/screens/checkout_screen.dart
import 'package:flutter/material.dart';
import '../controllers/checkout_controller.dart';
import '../controllers/checkout_state.dart';
/// Public screen widget. Exported through checkout.dart.
class CheckoutScreen extends StatefulWidget {
const CheckoutScreen({
super.key,
required this.cartId,
required this.controller,
});
final String cartId;
final CheckoutController controller;
@override
State<CheckoutScreen> createState() => _CheckoutScreenState();
}
class _CheckoutScreenState extends State<CheckoutScreen> {
@override
void initState() {
super.initState();
widget.controller.loadOrderSummary(widget.cartId);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Checkout Review'),
),
body: ListenableBuilder(
listenable: widget.controller,
builder: (context, _) {
return switch (widget.controller.state) {
CheckoutInitial() || CheckoutLoading() => const Center(
child: CircularProgressIndicator(),
),
CheckoutError(:final message) => Center(
child: Text('Checkout failed: $message'),
),
CheckoutLoaded(:final summary) => Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Order ID: ${summary.orderId}'),
const SizedBox(height: 8),
Text('Total Items: ${summary.itemCount}'),
const SizedBox(height: 8),
Text(
'Total: \$${summary.totalAmountInDollars.toStringAsFixed(2)}',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
),
),
const Spacer(),
ElevatedButton(
onPressed: () => widget.controller.placeOrder(widget.cartId),
child: const Text('Confirm & Pay'),
),
],
),
),
};
},
),
);
}
}
4. Core vs. Shared Governance: Dismantling the "Common Dumpster"
In almost every growing Flutter codebase, there is a folder named common/, shared/, or utils/. Over time, this folder inevitably turns into an architectural dumping ground. Whenever an engineer is unsure where a piece of logic belongs, it gets tossed into utils/.
Within a year, utils/ imports half the features in the app, the features import utils/, and the entire dependency graph degenerates into an unmaintainable knot. Circular dependencies appear, hot reload slows down, and test isolation becomes impossible.
To prevent this decay, teams must establish strict governance and separate non-feature code into two distinct categories: core/ and shared/.
Permissible Dependency Flow:
┌───────────────────────────────────────────────────────────┐
│ Features Layer │
│ features/auth features/cart features/catalog │
└─────────────────────────────┬─────────────────────────────┘
│ (imports)
▼
┌───────────────────────────────────────────────────────────┐
│ Shared Layer │
│ Design System • Reusable UI Atoms • UI Tokens │
└─────────────────────────────┬─────────────────────────────┘
│ (imports)
▼
┌───────────────────────────────────────────────────────────┐
│ Core Layer │
│ Network Client • Storage Drivers • Logging • Error Types│
│ (Domain-Agnostic Infrastructure) │
└───────────────────────────────────────────────────────────┘
Governing core/ (Infrastructure Foundation)
core/ contains foundational, application-wide infrastructure that has zero domain awareness. If an engineer were to take the core/ folder and paste it into a completely different Flutter app (e.g., moving from an e-commerce app to a banking app), the code in core/ should still make sense and compile.
- What belongs in
core/:- Network abstractions (HTTP client wrappers, interceptors, connectivity monitors).
- Storage drivers (SecureStorage wrappers, SQLite / Hive / Drift adapters).
- Telemetry and logging interfaces.
- Base error/failure types (
AppFailure,NetworkException). - Environment configurations (
app_env.dart).
- What is strictly forbidden in
core/:- Business entities (e.g.,
User,Product,Order). - Feature-specific validation logic.
- UI widgets or layout elements.
- Business entities (e.g.,
Governing shared/ (Presentation & Design System)
shared/ contains reusable presentation primitives, design system components, and domain-agnostic UI helpers.
- What belongs in
shared/:- Design tokens (Color palette, typography styles, spacing constants, elevation curves).
- Atomic UI widgets (Buttons, input fields, loading indicators, custom modal dialogs).
- Generic formatting helpers (Currency formatters, date string parsers).
- Responsive layout scaffolding (Screen-size breakpoint helpers).
- What is strictly forbidden in
shared/:- Feature-specific business logic.
- Direct network requests or API endpoints.
- Feature state controllers or business use cases.
Responsibility Matrix: Core vs. Shared vs. Feature
| Attribute | core/ | shared/ | features/<name>/ |
| :--- | :--- | :--- | :--- |
| Domain Awareness | None (pure infrastructure) | None (pure UI/design system) | High (owns a specific bounded business context) |
| Flutter Widget Imports | Forbidden (pure Dart foundation) | Permitted (widgets, themes, builders) | Permitted (screens, state, widgets) |
| Reusable Across Different Apps? | Yes | Yes (if sharing branding/tokens) | No (specific to this product domain) |
| Can Import From | External third-party packages | core/ | core/, shared/ |
| Cannot Import From | shared/, features/ | features/ | Internal src/ of peer features |
5. Cross-Feature Communication Topologies
In any non-trivial application, features cannot exist in total isolation. A user browsing products in catalog needs to add items to the cart. A user in checkout needs to inspect profile data from auth.
If features import each other's private classes directly, tight coupling and circular dependencies quickly destroy the modular architecture. To keep feature slices decoupled, production architectures rely on three established topologies:
1. Route-Based Decoupling:
[Catalog Screen] ─────(go_router URI: '/checkout?cartId=42')─────► [Checkout Screen]
2. Inverted Contract (Port & Adapter):
[Catalog Domain] ───► (defines interface CartDelegator)
▲
│ (implements)
[App Composition] ───► [Cart Service Adapter]
3. Domain Event Stream:
[Auth Feature] ──────► (emits UserSignedOutEvent) ──────► [Event Bus]
│ (subscribes)
▼
[Cart Feature Clears]
Topology 1: Route-Based Decoupling (Declarative Navigation)
The simplest and most effective way to decouple features is to route between them using declarative navigation (such as go_router). Instead of Feature A directly instantiating Feature B's screen widget and passing complex domain objects, Feature A triggers navigation via a route path or URI, passing primitive parameters (e.g., IDs or query strings).
// File: lib/features/catalog/src/presentation/widgets/product_card.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class ProductCard extends StatelessWidget {
const ProductCard({
super.key,
required this.productId,
required this.title,
});
final String productId;
final String title;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
trailing: IconButton(
icon: const Icon(Icons.arrow_forward),
onPressed: () {
// Navigating using a URI path and primitive parameter.
// Catalog has zero knowledge of Checkout's internal classes or widgets.
context.push('/checkout?cartId=$productId');
},
),
);
}
}
By passing primitive IDs (e.g., cartId=42) rather than passing mutable object instances, Feature B is forced to fetch and validate its own data through its repository. This eliminates subtle shared-memory bugs and keeps each feature's state lifecycle self-contained. For more on optimizing widget rebuilds and state lifecycles, see Flutter Performance Habits for Production Apps.
Topology 2: Contract-Based Inversion (Inverted Ports & Adapters)
Sometimes Feature A requires an immediate service or action from Feature B (for example, the catalog feature needs to add an item to the shopping cart, which is managed by cart).
Instead of catalog importing cart, we apply the Dependency Inversion Principle:
catalogdefines an abstract interface (a port) in its own domain layer:CartDelegator.- An adapter class in the application composition layer (
lib/app/adapters/) implementsCartDelegatorby delegating to thecartfeature. - At application launch, the dependency injector wires the adapter into
catalog.
// File: lib/features/catalog/src/domain/contracts/cart_delegator.dart
/// Abstract contract defined by the consumer (Catalog).
/// Pure Dart — Catalog specifies what it needs, not how it is fulfilled.
abstract interface class CartDelegator {
Future<void> addItem({required String productId, required int quantity});
}
// File: lib/app/adapters/cart_delegator_adapter.dart
// In the top-level app composition layer:
import 'package:app/features/catalog/catalog.dart';
import 'package:app/features/cart/cart.dart';
/// Adapter fulfilling Catalog's contract using Cart's public API.
class CartDelegatorAdapter implements CartDelegator {
const CartDelegatorAdapter(this._cartService);
final CartService _cartService;
@override
Future<void> addItem({required String productId, required int quantity}) {
return _cartService.addToCart(productId: productId, quantity: quantity);
}
}
Notice that catalog has zero compile-time dependencies on cart. The dependencies point inward toward the contract, preserving complete modularity.
Topology 3: Application Event Streams (Domain Events)
When a state transition in one feature must trigger reactions across multiple other features (e.g., "User signed out" or "Payment completed"), an event-driven publish-subscribe pattern prevents coupling.
Using a strongly typed event stream:
// File: lib/core/events/app_domain_event.dart
import 'package:flutter/foundation.dart';
/// Sealed hierarchy of system-wide domain events in core/
@immutable
sealed class AppDomainEvent {
const AppDomainEvent();
}
final class UserSignedOutEvent extends AppDomainEvent {
const UserSignedOutEvent({required this.timestamp});
final DateTime timestamp;
}
final class CartItemCountChangedEvent extends AppDomainEvent {
const CartItemCountChangedEvent({required this.totalItems});
final int totalItems;
}
Features publish events to an EventBus without knowing who is listening:
authemitsUserSignedOutEvent.cartlistens and purges cached items.cataloglistens and resets user-specific pricing.
Neither cart nor catalog needs to know anything about auth.
6. Scaling Beyond Folders: Multi-Package Monorepos
As engineering teams scale, folder-based conventions eventually face human limits. In a single Flutter package, Dart's compiler allows any file to import any other file via relative paths. A developer in a hurry can easily write import '../../cart/src/private_helper.dart', bypassing barrel files and violating boundaries.
When an organization reaches multiple teams or squads, graduating to a Multi-Package Monorepo provides compiler-enforced boundary safety.
Multi-Package Monorepo Layout:
my_flutter_monorepo/
├── apps/
│ └── mobile_app/ # Shell application (wiring, main.dart, themes)
│ └── pubspec.yaml
├── packages/
│ ├── core/
│ │ ├── core_network/ # Isolated network package
│ │ │ └── pubspec.yaml
│ │ └── core_storage/ # Isolated secure storage package
│ │ └── pubspec.yaml
│ ├── shared/
│ │ └── design_system/ # Reusable atomic UI components
│ │ └── pubspec.yaml
│ └── features/
│ ├── feature_auth/ # Auth feature package
│ │ └── pubspec.yaml
│ ├── feature_cart/ # Cart feature package
│ │ └── pubspec.yaml
│ └── feature_checkout/ # Checkout feature package
│ └── pubspec.yaml
├── melos.yaml # Multi-package management configuration
└── pubspec.yaml # Root workspace manifest
Compiler-Enforced Boundary Safety
In a multi-package setup, each feature has its own pubspec.yaml. If feature_checkout wants to use code from feature_cart, it cannot do so unless feature_cart is explicitly listed as a dependency in packages/features/feature_checkout/pubspec.yaml.
# File: packages/features/feature_checkout/pubspec.yaml
name: feature_checkout
description: Checkout feature vertical slice
version: 1.0.0
publish_to: none
environment:
sdk: '>=3.5.0 <4.0.0'
flutter: '>=3.24.0'
dependencies:
flutter:
sdk: flutter
# Explicitly allowed infrastructure dependencies:
core_network:
path: ../../core/core_network
design_system:
path: ../../shared/design_system
# NOTICE: feature_cart and feature_catalog are NOT listed here.
# If an engineer attempts to import them, the Dart compiler immediately halts with an error.
The Dart compiler itself becomes the architectural enforcer. Boundary violations cannot slip through code review unnoticed.
Dart Workspaces (Dart 3.5+) vs. Melos
Managing multiple packages historically required third-party tools like Melos. Modern Flutter ecosystems now leverage native Dart Workspaces (introduced in Dart 3.5) alongside Melos:
- Dart Workspaces: Built directly into the Dart SDK. Provides native resolution of local package paths, unified
pubspec.lockresolution across all sub-packages, and faster dependency fetching without symlink gymnastics. - Melos: Functions as a workflow runner on top of Dart Workspaces. It coordinates versioning, automated changelog generation, and selective script execution across packages:
# Run tests only on packages that changed in the current pull request:
melos exec --diff="main" --dir-exists="test" -- flutter test
Monolithic Folder Structure vs. Multi-Package Monorepo
| Characteristic | Folder-Based Slices (Single Package) | Multi-Package Monorepo (Dart Workspaces) |
| :--- | :--- | :--- |
| Boundary Enforcement | Convention & lint rules (developers can bypass) | Compiler-enforced (pubspec.yaml manifest constraints) |
| CI/CD Pipeline Speed | Tests run on the entire codebase every PR | Selective testing (only test packages affected by diff) |
| Setup & Tooling Complexity| Low; standard Flutter project setup | Moderate; requires workspace manifests and Melos scripts |
| Dependency Versioning | Unified single version for entire app | Independent versioning possible per package |
| Ideal Team Scale | 1 to 10 mobile engineers (Single squad) | 10 to 50+ engineers (Multiple autonomous squads) |
For growing mobile organizations, structuring for scale is not just an engineering preference—it is a strategic decision that protects release velocity. For an analysis of Flutter's organizational benefits, see Why Flutter Works for Business Mobile Products.
7. Incremental Migration Strategy: Moving from Layer-First to Feature-First
Attempting a "stop-the-world" rewrite to restructure an active production app from layer-first to feature-first is an anti-pattern. Feature delivery halts, merge conflicts overwhelm the team, and regressions are inevitable.
Instead, production teams use an incremental strangler migration across four distinct phases:
Migration Progression:
Phase 1: Extract Core & Shared Foundation
└── Create lib/core/ and lib/shared/; move network clients and design tokens.
Phase 2: Migrate the Simplest Leaf Feature
└── Select an isolated domain (e.g., Settings or FAQ); move to lib/features/settings/.
Phase 3: Incrementally Migrate Core Domains
└── Migrate Checkout, Cart, Catalog one vertical slice per sprint.
Phase 4: Enforce Boundaries with Analysis Options
└── Add custom linter rules to disallow cross-feature private imports.
Step 1: Extract Core Infrastructure and Shared UI
Before touching complex domain logic, extract application-agnostic code:
- Move network clients, interceptors, and database drivers to
lib/core/. - Move colors, typography, buttons, and input fields to
lib/shared/. - Update existing layers to import from
core/andshared/.
Step 2: Migrate a "Leaf" Feature First
Pick the least coupled feature in the system (such as settings, feedback, or profile).
- Create
lib/features/settings/. - Move its views, controllers, repositories, and models into the new vertical slice.
- Expose the entry screen through
lib/features/settings/settings.dart. - Verify that the app compiles and all tests pass before proceeding.
Step 3: Migrate High-Traffic Features Sequentially
Address primary business features (such as catalog, cart, checkout) one at a time across scheduled sprints. During this phase, use route-based parameter passing and inverted adapter contracts to sever direct ties between migrated and unmigrated features.
Step 4: Automate Boundary Enforcement
Add lint rules or analysis rules to prevent developers from accidentally introducing layer-first patterns or bypassing barrel files. Custom analysis packages (like custom_lint) can enforce that files inside lib/features/A/ never import from lib/features/B/src/.
8. Production Verification Checklist
Before considering a feature-based Flutter project structure production-ready, verify your implementation against this 10-point checklist:
- [ ] 1. Strict Feature Encapsulation: Does every feature maintain a public barrel file at its root, with internal implementation details hidden inside
src/? - [ ] 2. Pure Dart Domain Layer: Is the
domain/folder of each feature completely free of Flutter UI imports (package:flutter/material.dartorpackage:flutter/widgets.dart)? - [ ] 3. Permissible Dependency Flow: Does the codebase adhere strictly to
features -> shared -> core? Confirm thatcore/has zero imports fromfeatures/orshared/. - [ ] 4. Primitive Navigation Parameters: Does cross-feature routing pass primitive IDs or query strings rather than passing mutable domain entity instances across boundaries?
- [ ] 5. Zero "Common Dumpster" Code: Has all logic in
shared/andcore/been audited to ensure no domain-specific business rules are masquerading as "helpers"? - [ ] 6. Inverted Cross-Feature Contracts: When Feature A must invoke Feature B directly, does Feature A depend on an abstract domain interface rather than Feature B's concrete classes?
- [ ] 7. Exhaustive State Modeling: Are presentation states modeled using Dart 3 sealed class hierarchies, ensuring compile-time exhaustive checking in widget UI builders?
- [ ] 8. Atomic Feature Deprecation: Can an entire feature folder be deleted without leaving orphaned dead code scattered across root folders?
- [ ] 9. Test Isolation: Can unit tests for a feature's domain and presentation logic run in isolation without initializing unrelated feature modules or the full widget tree?
- [ ] 10. Monorepo Readiness: If team size exceeds 10 engineers, is there an established roadmap to transition from folder slices to Dart Workspaces for compiler-enforced package isolation?
Conclusion: Structure as an Enabler of Velocity
A software architecture is not an abstract academic exercise. In production, an architecture succeeds or fails based on how effectively it facilitates safe, rapid changes by human engineering teams.
Layer-first packaging works well for prototypes, but it creates structural debt that compounds with every new screen and model. By adopting a Feature-First vertical slice architecture:
- Code changes remain localized to the domain capability being updated;
- Features communicate cleanly through public barrel files, declarative routes, and inverted contracts;
- Infrastructure and design systems remain protected from business rule contamination;
- Engineering squads gain the autonomy to ship features without blocking each other.
Start by drawing clear boundaries around your product's domain capabilities. Enforce encapsulation with barrel files, decouple communication with primitive-based routing, and let your project structure scale smoothly alongside your product and team.


