Flutter Feature Pattern Tutorial: Building Scalable & Maintainable Features
Flutter Feature Pattern Tutorial: Building Scalable & Maintainable Features This tutorial guides junior Flutter developers on implementing a robust and scalable feature structure based on Clean Architecture principles. We'll use Cubit for state management, Freezed for models, and Injectable for dependency injection. Goal: To create features that are well-organized, easy to test, maintain, and scale. Core Concepts: Clean Architecture: Separating code into distinct layers (like Data, Domain, Presentation) to improve organization and testability. Our pattern uses Model, Service (Data/Domain), Cubit (Presentation Logic), and View (UI). Feature-First Structure: Organizing code by feature (e.g., banner, auth, plants) rather than by type (e.g., widgets, models, screens). Cubit: A simple state management solution from the flutter_bloc package. Freezed: A code generation package for creating immutable data models with less boilerplate. Injectable: A code generation package built on get_it to simplify Dependency Injection setup. Either: A functional programming type (from dartz) used here to handle success or failure states explicitly, often from service calls. Prerequisites: Basic understanding of Flutter widgets and state management concepts. Flutter SDK installed. A Flutter project set up. Required Packages: Add these to your pubspec.yaml: dependencies: flutter: sdk: flutter flutter_bloc: ^latest # For Cubit/Bloc freezed_annotation: ^latest # For Freezed models injectable: ^latest # For Dependency Injection get_it: ^latest # Service Locator used by Injectable dartz: ^latest # For Either type dev_dependencies: flutter_test: sdk: flutter build_runner: ^latest # Code generation tool freezed: ^latest # Code generator for Freezed injectable_generator: ^latest # Code generator for Injectable Run flutter pub get after adding the dependencies. Step-by-Step Tutorial (Example: Banner Feature) Let's build a simple feature that fetches and displays promotional banners. Step 1: Create the Feature Directory Structure Organize your feature code like this: lib/ features/ banner/ ├── cubit/ │ ├── banner_cubit.dart │ └── banner_state.dart ├── model/ │ └── banner_model.dart ├── service/ │ └── banner_service.dart └── view/ ├── banner_screen.dart └── widgets/ └── banner_widget.dart # Example specific widget ... other features and core files model/: Contains data structures (classes) representing the banner data. service/: Handles fetching banner data (e.g., from an API or database). cubit/: Manages the state of the banner feature and contains the business logic. view/: Contains the UI widgets that display the banners. Step 2: Define the Data Model (with Freezed) The model represents the data structure. Freezed helps create immutable models easily. import 'package:freezed_annotation/freezed_annotation.dart'; part 'banner_model.freezed.dart'; // Generated part file part 'banner_model.g.dart'; // Generated part file for JSON @freezed class BannerModel with _$BannerModel { const factory BannerModel({ required String id, required String title, required String imageUrl, String? targetUrl, // Optional link }) = _BannerModel; // Factory constructor for creating an empty/initial instance factory BannerModel.empty() => const BannerModel( id: '', title: '', imageUrl: '', ); // Factory constructor for JSON serialization factory BannerModel.fromJson(Map json) => _$BannerModelFromJson(json); } @freezed: Marks the class for code generation. part '...';: Links to generated files. factory BannerModel(...): Defines the main constructor. factory BannerModel.fromJson(...): Allows creating a BannerModel from a JSON map. Run Code Generation: Open your terminal in the project root and run: flutter pub run build_runner build --delete-conflicting-outputs This generates banner_model.freezed.dart and banner_model.g.dart. Why Freezed? Immutability: Ensures data models cannot be accidentally changed, making state predictable. Less Boilerplate: Generates copyWith, ==, hashCode, toString, and JSON methods automatically. Union Types (Sum Types): Useful for representing different states or variations of a model (not shown here, but a powerful feature). Step 3: Implement the Service Layer (with Either) The service interacts with external data sources (API, database). It should return data wrapped in Either to handle potential errors gracefully. import 'package:injectable/injectable.dart'; import 'package:dartz/dartz.dart'; import 'package:mobile/core/error/error.dart'; // Assuming you have a custom AppError import 'package:mobile/features/banner/model/banner_model.dart'; // import 'package:supabase_flutter/supabase_flutter.dart'; // Example dependency @lazySingleton // Marked for Dependency Injection class BannerService { // final

Flutter Feature Pattern Tutorial: Building Scalable & Maintainable Features
This tutorial guides junior Flutter developers on implementing a robust and scalable feature structure based on Clean Architecture principles. We'll use Cubit for state management, Freezed for models, and Injectable for dependency injection.
Goal: To create features that are well-organized, easy to test, maintain, and scale.
Core Concepts:
- Clean Architecture: Separating code into distinct layers (like Data, Domain, Presentation) to improve organization and testability. Our pattern uses Model, Service (Data/Domain), Cubit (Presentation Logic), and View (UI).
-
Feature-First Structure: Organizing code by feature (e.g.,
banner
,auth
,plants
) rather than by type (e.g.,widgets
,models
,screens
). -
Cubit: A simple state management solution from the
flutter_bloc
package. - Freezed: A code generation package for creating immutable data models with less boilerplate.
-
Injectable: A code generation package built on
get_it
to simplify Dependency Injection setup. -
Either: A functional programming type (from
dartz
) used here to handle success or failure states explicitly, often from service calls.
Prerequisites:
- Basic understanding of Flutter widgets and state management concepts.
- Flutter SDK installed.
- A Flutter project set up.
Required Packages:
Add these to your pubspec.yaml
:
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^latest # For Cubit/Bloc
freezed_annotation: ^latest # For Freezed models
injectable: ^latest # For Dependency Injection
get_it: ^latest # Service Locator used by Injectable
dartz: ^latest # For Either type
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^latest # Code generation tool
freezed: ^latest # Code generator for Freezed
injectable_generator: ^latest # Code generator for Injectable
Run flutter pub get
after adding the dependencies.
Step-by-Step Tutorial (Example: Banner Feature)
Let's build a simple feature that fetches and displays promotional banners.
Step 1: Create the Feature Directory Structure
Organize your feature code like this:
lib/
features/
banner/
├── cubit/
│ ├── banner_cubit.dart
│ └── banner_state.dart
├── model/
│ └── banner_model.dart
├── service/
│ └── banner_service.dart
└── view/
├── banner_screen.dart
└── widgets/
└── banner_widget.dart # Example specific widget
... other features and core files
-
model/
: Contains data structures (classes) representing the banner data. -
service/
: Handles fetching banner data (e.g., from an API or database). -
cubit/
: Manages the state of the banner feature and contains the business logic. -
view/
: Contains the UI widgets that display the banners.
Step 2: Define the Data Model (with Freezed)
The model represents the data structure. Freezed helps create immutable models easily.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'banner_model.freezed.dart'; // Generated part file
part 'banner_model.g.dart'; // Generated part file for JSON
@freezed
class BannerModel with _$BannerModel {
const factory BannerModel({
required String id,
required String title,
required String imageUrl,
String? targetUrl, // Optional link
}) = _BannerModel;
// Factory constructor for creating an empty/initial instance
factory BannerModel.empty() => const BannerModel(
id: '',
title: '',
imageUrl: '',
);
// Factory constructor for JSON serialization
factory BannerModel.fromJson(Map json) =>
_$BannerModelFromJson(json);
}
-
@freezed
: Marks the class for code generation. -
part '...';
: Links to generated files. -
factory BannerModel(...)
: Defines the main constructor. -
factory BannerModel.fromJson(...)
: Allows creating aBannerModel
from a JSON map.
Run Code Generation:
Open your terminal in the project root and run:
flutter pub run build_runner build --delete-conflicting-outputs
This generates banner_model.freezed.dart
and banner_model.g.dart
.
Why Freezed?
- Immutability: Ensures data models cannot be accidentally changed, making state predictable.
-
Less Boilerplate: Generates
copyWith
,==
,hashCode
,toString
, and JSON methods automatically. - Union Types (Sum Types): Useful for representing different states or variations of a model (not shown here, but a powerful feature).
Step 3: Implement the Service Layer (with Either)
The service interacts with external data sources (API, database). It should return data wrapped in Either
to handle potential errors gracefully.
import 'package:injectable/injectable.dart';
import 'package:dartz/dartz.dart';
import 'package:mobile/core/error/error.dart'; // Assuming you have a custom AppError
import 'package:mobile/features/banner/model/banner_model.dart';
// import 'package:supabase_flutter/supabase_flutter.dart'; // Example dependency
@lazySingleton // Marked for Dependency Injection
class BannerService {
// final SupabaseClient _client; // Example: Inject Supabase client
// Use constructor injection for dependencies
// const BannerService(this._client);
const BannerService(); // Simple example without external dependencies yet
Future>> fetchBanners() async {
try {
// TODO: Replace with actual API/database call
// Example: Simulating a successful API call after a delay
await Future.delayed(const Duration(seconds: 1));
final banners = [
const BannerModel(id: '1', title: 'Summer Sale', imageUrl: 'url1', targetUrl: '/sale'),
const BannerModel(id: '2', title: 'New Arrivals', imageUrl: 'url2'),
];
// Return success state with data
return Right(banners);
} catch (e, stackTrace) {
// Log the error (using your preferred logger)
print('Error fetching banners: $e');
// Return failure state with an error object
return Left(AppError.fromException(e, stackTrace)); // Use your custom error handling
}
}
}
-
@lazySingleton
: Marks this class for registration with Injectable (we'll cover DI in Step 6). -
Future
: The return type clearly indicates it can result in either an>> AppError
(Left) or aList
(Right). -
try...catch
: Standard error handling. -
Right(data)
: Represents a successful result. -
Left(error)
: Represents a failure.
Step 4: Create the Cubit and State (with Base Classes)
The Cubit manages the feature's state, interacts with the service, and provides the state to the UI. We use BaseCubit
and BaseState
(as per your project's structure) to handle common state properties like loading status.
State (banner_state.dart
):
part of 'banner_cubit.dart'; // Link to the Cubit file
// Assuming BaseState has properties like `status`, `message`, `submitStatus`
class BannerState extends BaseState {
final List banners;
// Add other specific state properties if needed
const BannerState({
required RequestStatus status, // From BaseState
required String message, // From BaseState
required this.banners,
RequestStatus submitStatus = RequestStatus.initial, // From BaseState
}) : super(status: status, message: message, submitStatus: submitStatus);
// Factory for the initial state
factory BannerState.initial() => const BannerState(
status: RequestStatus.initial,
message: '',
banners: [],
);
// Implement copyWith for easy state updates
BannerState copyWith({
RequestStatus? status,
String? message,
List? banners,
RequestStatus? submitStatus,
}) {
return BannerState(
status: status ?? this.status,
message: message ?? this.message,
banners: banners ?? this.banners,
submitStatus: submitStatus ?? this.submitStatus,
);
}
// Implement props for Equatable (if BaseState uses it)
@override
List
Cubit (banner_cubit.dart
):
import 'package:bloc/bloc.dart';
import 'package:injectable/injectable.dart';
import 'package:mobile/core/cubit/base_cubit.dart'; // Import your base cubit/state
import 'package:mobile/features/banner/model/banner_model.dart';
import 'package:mobile/features/banner/service/banner_service.dart';
part 'banner_state.dart'; // Link to the State file
@injectable // Marked for Dependency Injection
class BannerCubit extends BaseCubit {
final BannerService _bannerService;
// Inject the service via constructor
BannerCubit(this._bannerService) : super(BannerState.initial());
// Method to load banners
Future loadBanners() async {
// Emit loading state (using copyWith from BaseState/BannerState)
emit(state.copyWith(status: RequestStatus.loading));
final result = await _bannerService.fetchBanners();
// Handle the Either result
result.fold(
(error) {
// Emit failure state
emit(state.copyWith(
status: RequestStatus.failure,
message: error.message, // Get message from your AppError
));
},
(banners) {
// Emit success state with data
emit(state.copyWith(
status: RequestStatus.success,
banners: banners,
));
},
);
}
// Add other methods for banner interactions (e.g., onBannerTap) if needed
}
-
@injectable
: Marks the Cubit for DI. -
extends BaseCubit
: Inherits common logic from your base class. -
super(BannerState.initial())
: Sets the initial state. - Constructor Injection:
BannerService
is passed in the constructor. Injectable will handle providing it. -
emit()
: Used to push new states to the UI. -
result.fold()
: A clean way to handle theEither
type from the service.
Step 5: Build the View Layer
The View listens to the Cubit's state and renders the UI.
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:mobile/core/cubit/base_cubit.dart'; // For RequestStatus enum
import 'package:mobile/core/injection/injection.dart'; // For getIt
import 'package:mobile/features/banner/cubit/banner_cubit.dart';
// import 'package:mobile/features/banner/view/widgets/banner_widget.dart'; // Import specific widget
class BannerScreen extends StatefulWidget {
const BannerScreen({super.key});
@override
State createState() => _BannerScreenState();
}
class _BannerScreenState extends State {
@override
void initState() {
super.initState();
// Load banners when the screen initializes
// Use getIt() as per project guidelines
getIt().loadBanners();
}
@override
Widget build(BuildContext context) {
// Use BlocBuilder to listen to state changes from BannerCubit
return BlocBuilder(
bloc: getIt(), // Provide the cubit instance via getIt
builder: (context, state) {
// Handle different states
if (state.status == RequestStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == RequestStatus.failure) {
return Center(
child: Text('Error loading banners: ${state.message}'),
);
}
if (state.status == RequestStatus.success && state.banners.isEmpty) {
return const Center(child: Text('No banners available.'));
}
// Display banners on success
if (state.status == RequestStatus.success) {
return ListView.builder(
itemCount: state.banners.length,
itemBuilder: (context, index) {
final banner = state.banners[index];
// return BannerWidget(banner: banner); // Use a dedicated widget
return ListTile( // Simple example
leading: Image.network(banner.imageUrl, width: 50, height: 50, fit: BoxFit.cover),
title: Text(banner.title),
onTap: () {
if (banner.targetUrl != null) {
// Handle navigation or action
print('Tapped banner: ${banner.title}, Target: ${banner.targetUrl}');
}
},
);
},
);
}
// Initial or other states
return const Center(child: Text('Initializing...'));
},
);
}
}
-
initState()
: Often used to trigger the initial data load usinggetIt
.().loadBanners(); -
BlocBuilder
: Listens to state changes fromBannerCubit
and rebuilds the UI whenever the state changes. -
bloc: getIt
: Provides the specific Cubit instance using() getIt
(as per guideline #8). -
builder: (context, state)
: The function that builds the UI based on the currentstate
. - State Handling: Check
state.status
to show loading indicators, error messages, or the actual content.
Step 6: Setting up Dependency Injection (with Injectable)
Dependency Injection (DI) is a design pattern where dependencies (like BannerService
) are "injected" into classes (like BannerCubit
) rather than being created internally. This makes code more modular, testable, and flexible.
Injectable
is a code generator that simplifies setting up DI using the get_it
service locator.
1. Annotate Classes:
We already did this in Step 3 and Step 4 by adding @lazySingleton
or @injectable
to BannerService
and BannerCubit
.
-
@lazySingleton
: Registers the class as a singleton (only one instance created) and creates it only when first requested. -
@injectable
: A general-purpose annotation. Often used for classes that might have multiple instances or different lifetimes. - Other annotations exist (
@singleton
,@factoryMethod
, etc.) for different registration needs.
2. Configure Dependencies:
Create a file (e.g., lib/core/injection/injection.dart
) to initialize Injectable.
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
// Import the generated file
import 'injection.config.dart';
final getIt = GetIt.instance;
@InjectableInit(
initializerName: 'init', // default
preferRelativeImports: true, // default
asExtension: true, // default
)
Future configureDependencies() async {
// This will call the generated `init` extension method
await getIt.init();
}
-
getIt = GetIt.instance
: Creates the service locator instance. -
@InjectableInit
: Tells Injectable how to generate the configuration. -
configureDependencies()
: The function you'll call to set up all dependencies.
3. Initialize in main.dart
:
Call configureDependencies()
before running your app.
import 'package:flutter/material.dart';
import 'package:mobile/core/injection/injection.dart'; // Import configuration
// ... other imports
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
// Configure dependencies before running the app
await configureDependencies();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'My Flutter App',
home: Scaffold( // Replace with your actual home screen/router
appBar: AppBar(title: const Text('Banner Feature Example')),
// Example: body: BannerScreen(), // Assuming BannerScreen is your initial screen
),
);
}
}
4. Run Code Generation for Injectable:
Run the build runner again. This generates injection.config.dart
, which contains the actual registration code based on your annotations.
flutter pub run build_runner build --delete-conflicting-outputs
5. Access Dependencies:
Now, whenever you need an instance of an annotated class (like BannerCubit
or BannerService
), you can get it from the service locator:
// In your view (as shown in Step 5)
final bannerCubit = getIt<BannerCubit>();
bannerCubit.loadBanners();
// Or if needed inside another service/cubit (though constructor injection is preferred)
final someService = getIt<SomeOtherService>();
Pros of Using Injectable:
-
Reduced Boilerplate: Automatically generates the registration code for
get_it
, saving you from writing repetitive manual registrations. - Type Safety: Helps catch dependency resolution errors at compile time (during code generation) rather than runtime.
- Clear Dependencies: Annotations make it explicit which classes are managed by the DI container.
- Environment Support: Allows registering different dependencies for different environments (e.g., mock services for testing, real services for production).
-
Improved Testability: Makes it much easier to provide mock or fake implementations of services during unit/widget testing. You can register mocks in your test setup using
getIt.registerSingleton
.(MockMyService()) - Consistency: Provides a standard way to handle dependencies across the project.
Step 7: Run Code Generation (Reminder)
Remember, whenever you add new models with Freezed or new services/cubits with Injectable annotations, you need to run the code generator:
flutter pub run build_runner build --delete-conflicting-outputs
You can also use watch
to automatically regenerate on file changes: flutter pub run build_runner watch --delete-conflicting-outputs
.
Conclusion
This feature pattern, combining a clear directory structure, Freezed for models, Cubit (with Base classes) for state management, and Injectable for DI, provides a solid foundation for building scalable and maintainable Flutter applications. It promotes separation of concerns, enhances testability, and ensures consistency across your features. While there's an initial setup cost per feature, the long-term benefits in larger projects are significant. Happy coding!