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

Apr 1, 2025 - 12:15
 0
Flutter Feature Pattern Tutorial: Building Scalable & Maintainable Features

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 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 a List (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 get props => [status, message, banners, submitStatus];
}

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 the Either 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 using getIt().loadBanners();.
  • BlocBuilder: Listens to state changes from BannerCubit 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 current state.
  • 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!