DartFlutter

Scalable Flutter Project Structure: Feature-Based Foldering Guide

Flutter project structure

Introduction

As your Flutter app grows, so does the complexity of your codebase. What starts as a simple main.dart and a few screens can quickly turn into hundreds of files scattered across folders with no clear organization. Finding the right file becomes a treasure hunt, and adding new features feels like surgery on a house of cards. Apps from companies like Alibaba, Google Pay, and BMW scale to hundreds of screens because they invest in architecture from day one. In this comprehensive guide, you’ll learn how to structure your Flutter project using a feature-based approach with Clean Architecture principles—a pattern used by experienced teams to build maintainable, testable, and scalable applications. We’ll cover the complete folder structure, practical implementation examples, and how to handle cross-cutting concerns like routing, theming, and dependency injection.

Why Project Structure Matters

A clear folder structure provides benefits that compound as your app grows:

Navigation speed: Find any file in seconds instead of scrolling through endless flat lists.

Reduced merge conflicts: Team members work in isolated feature folders, minimizing overlap.

Separation of concerns: Business logic stays separate from UI, making both easier to test and maintain.

Onboarding velocity: New developers understand where things go and can contribute faster.

Feature isolation: Remove or refactor features without cascading changes throughout the app.

Comparing Structure Approaches

Flat Structure (Avoid)

lib/
  main.dart
  login_screen.dart
  home_screen.dart
  profile_screen.dart
  user_model.dart
  api_service.dart
  auth_controller.dart
  widgets.dart
  // 200 more files...

This becomes unmanageable quickly. Files have no logical grouping, and finding related code requires searching.

Type-Based Structure (Common but Limited)

lib/
  models/
    user_model.dart
    product_model.dart
  views/
    login_screen.dart
    home_screen.dart
  controllers/
    auth_controller.dart
    product_controller.dart
  services/
    api_service.dart
    storage_service.dart

Better, but files for the same feature are scattered across folders. Working on “auth” means jumping between 4+ directories.

lib/
  core/
    constants/
    theme/
    routing/
    utils/
  features/
    auth/
      data/
      domain/
      presentation/
    home/
    products/
    profile/
  shared/
    widgets/
    extensions/
  main.dart

Each feature is self-contained. All auth-related code lives in features/auth/. This is the structure we’ll build.

Complete Feature-Based Structure

Here’s the full recommended structure with explanations:

lib/
├── core/                          # App-wide infrastructure
│   ├── constants/
│   │   ├── api_constants.dart     # Base URLs, endpoints
│   │   ├── app_constants.dart     # App-wide magic numbers
│   │   └── storage_keys.dart      # SharedPreferences keys
│   ├── errors/
│   │   ├── exceptions.dart        # Custom exceptions
│   │   └── failures.dart          # Failure classes for Either
│   ├── network/
│   │   ├── api_client.dart        # Dio/http configuration
│   │   └── network_info.dart      # Connectivity checking
│   ├── routing/
│   │   ├── app_router.dart        # GoRouter/AutoRoute config
│   │   └── routes.dart            # Route names/paths
│   ├── theme/
│   │   ├── app_theme.dart         # ThemeData configuration
│   │   ├── app_colors.dart        # Color palette
│   │   └── app_text_styles.dart   # Typography
│   └── utils/
│       ├── validators.dart        # Input validation
│       └── formatters.dart        # Date, currency formatting
│
├── features/                      # Feature modules
│   ├── auth/
│   │   ├── data/
│   │   │   ├── datasources/
│   │   │   │   ├── auth_local_datasource.dart
│   │   │   │   └── auth_remote_datasource.dart
│   │   │   ├── models/
│   │   │   │   └── user_model.dart
│   │   │   └── repositories/
│   │   │       └── auth_repository_impl.dart
│   │   ├── domain/
│   │   │   ├── entities/
│   │   │   │   └── user.dart
│   │   │   ├── repositories/
│   │   │   │   └── auth_repository.dart
│   │   │   └── usecases/
│   │   │       ├── login_usecase.dart
│   │   │       └── register_usecase.dart
│   │   └── presentation/
│   │       ├── controllers/
│   │       │   └── auth_controller.dart
│   │       ├── screens/
│   │       │   ├── login_screen.dart
│   │       │   └── register_screen.dart
│   │       └── widgets/
│   │           └── auth_form.dart
│   ├── home/
│   ├── products/
│   └── profile/
│
├── shared/                        # Shared across features
│   ├── widgets/
│   │   ├── app_button.dart
│   │   ├── app_text_field.dart
│   │   └── loading_indicator.dart
│   ├── extensions/
│   │   ├── context_extensions.dart
│   │   └── string_extensions.dart
│   └── mixins/
│       └── validation_mixin.dart
│
├── injection.dart                 # Dependency injection setup
└── main.dart                      # App entry point

Implementing a Feature: Auth Example

Let’s build the auth feature layer by layer:

Domain Layer (Business Logic)

// features/auth/domain/entities/user.dart
class User {
  final String id;
  final String email;
  final String name;
  final String? avatarUrl;

  const User({
    required this.id,
    required this.email,
    required this.name,
    this.avatarUrl,
  });
}

// features/auth/domain/repositories/auth_repository.dart
import 'package:dartz/dartz.dart';

abstract class AuthRepository {
  Future> login(String email, String password);
  Future> register(String email, String password, String name);
  Future> logout();
  Future> getCurrentUser();
}

// features/auth/domain/usecases/login_usecase.dart
class LoginUseCase {
  final AuthRepository repository;

  LoginUseCase(this.repository);

  Future> call(LoginParams params) {
    return repository.login(params.email, params.password);
  }
}

class LoginParams {
  final String email;
  final String password;

  LoginParams({required this.email, required this.password});
}

Data Layer (API, Storage, Models)

// features/auth/data/models/user_model.dart
class UserModel extends User {
  const UserModel({
    required super.id,
    required super.email,
    required super.name,
    super.avatarUrl,
  });

  factory UserModel.fromJson(Map json) {
    return UserModel(
      id: json['id'] as String,
      email: json['email'] as String,
      name: json['name'] as String,
      avatarUrl: json['avatar_url'] as String?,
    );
  }

  Map toJson() {
    return {
      'id': id,
      'email': email,
      'name': name,
      'avatar_url': avatarUrl,
    };
  }
}

// features/auth/data/datasources/auth_remote_datasource.dart
abstract class AuthRemoteDataSource {
  Future login(String email, String password);
  Future register(String email, String password, String name);
}

class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
  final ApiClient apiClient;

  AuthRemoteDataSourceImpl(this.apiClient);

  @override
  Future login(String email, String password) async {
    final response = await apiClient.post(
      '/auth/login',
      data: {'email': email, 'password': password},
    );
    return UserModel.fromJson(response.data['user']);
  }

  @override
  Future register(String email, String password, String name) async {
    final response = await apiClient.post(
      '/auth/register',
      data: {'email': email, 'password': password, 'name': name},
    );
    return UserModel.fromJson(response.data['user']);
  }
}

// features/auth/data/repositories/auth_repository_impl.dart
class AuthRepositoryImpl implements AuthRepository {
  final AuthRemoteDataSource remoteDataSource;
  final AuthLocalDataSource localDataSource;
  final NetworkInfo networkInfo;

  AuthRepositoryImpl({
    required this.remoteDataSource,
    required this.localDataSource,
    required this.networkInfo,
  });

  @override
  Future> login(String email, String password) async {
    if (await networkInfo.isConnected) {
      try {
        final user = await remoteDataSource.login(email, password);
        await localDataSource.cacheUser(user);
        return Right(user);
      } on ServerException catch (e) {
        return Left(ServerFailure(e.message));
      }
    } else {
      return const Left(NetworkFailure('No internet connection'));
    }
  }
}

Presentation Layer (UI, State)

// features/auth/presentation/controllers/auth_controller.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'auth_controller.g.dart';

@riverpod
class AuthController extends _$AuthController {
  @override
  FutureOr build() async {
    final getCurrentUser = ref.read(getCurrentUserUseCaseProvider);
    final result = await getCurrentUser();
    return result.fold((failure) => null, (user) => user);
  }

  Future login(String email, String password) async {
    state = const AsyncLoading();
    
    final loginUseCase = ref.read(loginUseCaseProvider);
    final result = await loginUseCase(LoginParams(
      email: email,
      password: password,
    ));

    state = result.fold(
      (failure) => AsyncError(failure.message, StackTrace.current),
      (user) => AsyncData(user),
    );
  }

  Future logout() async {
    final logoutUseCase = ref.read(logoutUseCaseProvider);
    await logoutUseCase();
    state = const AsyncData(null);
  }
}

// features/auth/presentation/screens/login_screen.dart
class LoginScreen extends ConsumerStatefulWidget {
  const LoginScreen({super.key});

  @override
  ConsumerState createState() => _LoginScreenState();
}

class _LoginScreenState extends ConsumerState {
  final _formKey = GlobalKey();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  Future _handleLogin() async {
    if (_formKey.currentState?.validate() ?? false) {
      await ref.read(authControllerProvider.notifier).login(
        _emailController.text,
        _passwordController.text,
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    final authState = ref.watch(authControllerProvider);

    ref.listen(authControllerProvider, (previous, next) {
      next.whenOrNull(
        data: (user) {
          if (user != null) {
            context.go('/home');
          }
        },
        error: (error, _) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(error.toString())),
          );
        },
      );
    });

    return Scaffold(
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Form(
            key: _formKey,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(
                  'Welcome Back',
                  style: Theme.of(context).textTheme.headlineMedium,
                ),
                const SizedBox(height: 32),
                AppTextField(
                  controller: _emailController,
                  label: 'Email',
                  keyboardType: TextInputType.emailAddress,
                  validator: Validators.email,
                ),
                const SizedBox(height: 16),
                AppTextField(
                  controller: _passwordController,
                  label: 'Password',
                  obscureText: true,
                  validator: Validators.password,
                ),
                const SizedBox(height: 24),
                AppButton(
                  label: 'Login',
                  onPressed: _handleLogin,
                  isLoading: authState.isLoading,
                  fullWidth: true,
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

Dependency Injection Setup

// injection.dart
import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

Future initializeDependencies() async {
  // Core
  getIt.registerLazySingleton(() => ApiClient());
  getIt.registerLazySingleton(() => NetworkInfoImpl());

  // Auth Feature
  getIt.registerLazySingleton(
    () => AuthRemoteDataSourceImpl(getIt()),
  );
  getIt.registerLazySingleton(
    () => AuthLocalDataSourceImpl(),
  );
  getIt.registerLazySingleton(
    () => AuthRepositoryImpl(
      remoteDataSource: getIt(),
      localDataSource: getIt(),
      networkInfo: getIt(),
    ),
  );
  getIt.registerLazySingleton(() => LoginUseCase(getIt()));
  getIt.registerLazySingleton(() => RegisterUseCase(getIt()));
}

Common Mistakes to Avoid

Mixing layers: Never call API directly from widgets. Always go through use cases and repositories.

Over-engineering small apps: For simple apps with 2-3 screens, this structure is overkill. Use it when you have 5+ features.

Circular dependencies: Features should not import from each other. Use shared modules or events for cross-feature communication.

Inconsistent naming: Establish naming conventions early. Use consistent suffixes like _screen, _controller, _repository.

Forgetting barrel files: Create index.dart files to simplify imports within features.

Conclusion

A scalable Flutter project structure using feature-based foldering transforms how your team works. Each feature becomes a self-contained module with its own data, domain, and presentation layers. New developers find code quickly, teams work without stepping on each other, and removing or refactoring features becomes surgical rather than explosive. Start with this structure from day one if you’re building anything beyond a simple prototype. The upfront investment pays dividends as your app grows from MVP to production and beyond. For more on building maintainable Flutter apps, check out our guide on Building Reusable UI Components in Flutter. For deeper exploration of Clean Architecture in Flutter, explore the official Flutter architecture documentation.

Leave a Comment