DEV Community

Cover image for How to Structure a Flutter App Using Clean Architecture
Nabeel Krissane
Nabeel Krissane

Posted on

How to Structure a Flutter App Using Clean Architecture

Stop building apps that collapse under their own weight. Here's the structure that actually scales.

Introduction

You ship your first Flutter app. It works. Users come in. Features pile up. Six months later, you're afraid to touch anything because one change breaks three other things.

Sound familiar?

This is what happens when Flutter apps grow without a clear architecture. Business logic bleeds into widgets. API calls live inside initState. State management becomes a mess of setState calls scattered everywhere.

The fix is Flutter Clean Architecture — a battle-tested pattern that separates your app into distinct layers, each with one responsibility. In this guide, you'll learn how to set up a production-ready Flutter Clean Architecture boilerplate from scratch, with real code examples, a Laravel backend integration, and authentication built in.

By the end, you'll have a foundation you can drop into any real-world project.


The Problem: Why Flutter Apps Become Unmaintainable

Most Flutter tutorials show you how to build a feature. Almost none show you where to put it.

So developers do what feels natural:

  • Call the API directly from the widget
  • Store state in a top-level variable
  • Put business rules inside the UI layer
  • Duplicate logic across screens because there's no shared structure

This works fine at 3 screens. At 30 screens, it's a nightmare.

The result: slow onboarding for new teammates, fear of refactoring, and bugs that reappear in unexpected places. The app becomes fragile — and the codebase becomes a liability.


The Solution: Flutter Clean Architecture

Clean Architecture, popularized by Robert C. Martin, organizes code into three independent layers:

  • Presentation Layer — UI widgets, state management (BLoC/Cubit)
  • Domain Layer — Business logic, use cases, abstract repository interfaces
  • Data Layer — API calls, local storage, concrete repository implementations

Each layer only talks to the layer directly below it. Your widgets never know what database you use. Your business logic never imports a Flutter widget. This separation makes testing easy, onboarding fast, and refactoring safe.

Let's build it.


Step 1: Project Setup and Folder Structure

Start by creating your Flutter project:

flutter create my_app
cd my_app
Enter fullscreen mode Exit fullscreen mode

Now structure your lib/ folder like this:

lib/
├── core/
│   ├── error/
│   │   ├── exceptions.dart
│   │   └── failures.dart
│   ├── network/
│   │   └── api_client.dart
│   └── utils/
│       └── constants.dart
├── features/
│   └── auth/
│       ├── data/
│       │   ├── datasources/
│       │   │   └── 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
│       └── presentation/
│           ├── bloc/
│           │   ├── auth_bloc.dart
│           │   ├── auth_event.dart
│           │   └── auth_state.dart
│           └── pages/
│               └── login_page.dart
└── injection_container.dart
Enter fullscreen mode Exit fullscreen mode

Visual: Here you should show the full folder tree in your IDE (VS Code or Android Studio). Highlight the features/auth/ directory and show how data, domain, and presentation sit side by side inside each feature.


Step 2: Install Dependencies

Add these to your pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^8.1.3
  dio: ^5.3.2
  get_it: ^7.6.4
  dartz: ^0.10.1
  equatable: ^2.0.5
  shared_preferences: ^2.2.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^5.4.2
  build_runner: ^2.4.6
Enter fullscreen mode Exit fullscreen mode

Then run:

flutter pub get
Enter fullscreen mode Exit fullscreen mode

Why these packages?

  • flutter_bloc — State management
  • dio — HTTP client for API calls (works great with Laravel)
  • get_it — Dependency injection
  • dartz — Functional error handling with Either<Failure, Success>
  • equatable — Value equality for entities and states

Step 3: Core Implementation

3.1 Define Failures and Exceptions

In Clean Architecture, errors flow upward through layers. Define them clearly.

// lib/core/error/exceptions.dart
class ServerException implements Exception {
  final String message;
  const ServerException({required this.message});
}

class CacheException implements Exception {
  final String message;
  const CacheException({required this.message});
}
Enter fullscreen mode Exit fullscreen mode
// lib/core/error/failures.dart
import 'package:equatable/equatable.dart';

abstract class Failure extends Equatable {
  final String message;
  const Failure({required this.message});

  @override
  List<Object> get props => [message];
}

class ServerFailure extends Failure {
  const ServerFailure({required super.message});
}

class CacheFailure extends Failure {
  const CacheFailure({required super.message});
}
Enter fullscreen mode Exit fullscreen mode

3.2 Set Up the API Client (Dio + Laravel)

// lib/core/network/api_client.dart
import 'package:dio/dio.dart';

class ApiClient {
  late final Dio _dio;

  ApiClient() {
    _dio = Dio(
      BaseOptions(
        baseUrl: 'https://your-laravel-app.com/api',
        connectTimeout: const Duration(seconds: 10),
        receiveTimeout: const Duration(seconds: 10),
        headers: {
          'Accept': 'application/json',
          'Content-Type': 'application/json',
        },
      ),
    );

    _dio.interceptors.add(
      InterceptorsWrapper(
        onRequest: (options, handler) {
          // Attach Bearer token for Laravel Sanctum / Passport
          final token = ''; // retrieve from storage
          if (token.isNotEmpty) {
            options.headers['Authorization'] = 'Bearer $token';
          }
          return handler.next(options);
        },
        onError: (DioException e, handler) {
          return handler.next(e);
        },
      ),
    );
  }

  Dio get dio => _dio;
}
Enter fullscreen mode Exit fullscreen mode

Visual: Here you should show an API response flow diagram. Arrow from Flutter app → Dio Client → Laravel /api/login → JSON response → model parsing → entity → BLoC state.


Step 4: The Authentication Feature — End to End

This is the most common real-world feature. Let's build it across all three layers.

4.1 Domain Layer — The Pure Core

The domain layer has zero Flutter or Dart-specific framework dependencies. It's plain Dart.

// lib/features/auth/domain/entities/user.dart
import 'package:equatable/equatable.dart';

class User extends Equatable {
  final int id;
  final String name;
  final String email;
  final String token;

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

  @override
  List<Object?> get props => [id, email, token];
}
Enter fullscreen mode Exit fullscreen mode
// lib/features/auth/domain/repositories/auth_repository.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/user.dart';

abstract class AuthRepository {
  Future<Either<Failure, User>> login({
    required String email,
    required String password,
  });
  Future<Either<Failure, void>> logout();
}
Enter fullscreen mode Exit fullscreen mode
// lib/features/auth/domain/usecases/login_usecase.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/user.dart';
import '../repositories/auth_repository.dart';

class LoginUseCase {
  final AuthRepository repository;

  LoginUseCase(this.repository);

  Future<Either<Failure, User>> call({
    required String email,
    required String password,
  }) async {
    return await repository.login(email: email, password: password);
  }
}
Enter fullscreen mode Exit fullscreen mode

4.2 Data Layer — API Integration with Laravel

// lib/features/auth/data/models/user_model.dart
import '../../domain/entities/user.dart';

class UserModel extends User {
  const UserModel({
    required super.id,
    required super.name,
    required super.email,
    required super.token,
  });

  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['user']['id'],
      name: json['user']['name'],
      email: json['user']['email'],
      token: json['token'],
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'email': email,
      'token': token,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode
// lib/features/auth/data/datasources/auth_remote_datasource.dart
import '../../../../core/error/exceptions.dart';
import '../../../../core/network/api_client.dart';
import '../models/user_model.dart';

abstract class AuthRemoteDataSource {
  Future<UserModel> login({required String email, required String password});
}

class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
  final ApiClient apiClient;

  AuthRemoteDataSourceImpl(this.apiClient);

  @override
  Future<UserModel> login({
    required String email,
    required String password,
  }) async {
    try {
      final response = await apiClient.dio.post(
        '/login',
        data: {'email': email, 'password': password},
      );

      if (response.statusCode == 200) {
        return UserModel.fromJson(response.data);
      } else {
        throw ServerException(message: 'Login failed');
      }
    } catch (e) {
      throw ServerException(message: e.toString());
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
// lib/features/auth/data/repositories/auth_repository_impl.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../domain/entities/user.dart';
import '../../domain/repositories/auth_repository.dart';
import '../datasources/auth_remote_datasource.dart';

class AuthRepositoryImpl implements AuthRepository {
  final AuthRemoteDataSource remoteDataSource;

  AuthRepositoryImpl(this.remoteDataSource);

  @override
  Future<Either<Failure, User>> login({
    required String email,
    required String password,
  }) async {
    try {
      final user = await remoteDataSource.login(
        email: email,
        password: password,
      );
      return Right(user);
    } on ServerException catch (e) {
      return Left(ServerFailure(message: e.message));
    }
  }

  @override
  Future<Either<Failure, void>> logout() async {
    // Clear token from storage
    return const Right(null);
  }
}
Enter fullscreen mode Exit fullscreen mode

4.3 Presentation Layer — BLoC + UI

// lib/features/auth/presentation/bloc/auth_event.dart
import 'package:equatable/equatable.dart';

abstract class AuthEvent extends Equatable {
  @override
  List<Object?> get props => [];
}

class LoginRequested extends AuthEvent {
  final String email;
  final String password;

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

  @override
  List<Object?> get props => [email, password];
}
Enter fullscreen mode Exit fullscreen mode
// lib/features/auth/presentation/bloc/auth_state.dart
import 'package:equatable/equatable.dart';
import '../../domain/entities/user.dart';

abstract class AuthState extends Equatable {
  @override
  List<Object?> get props => [];
}

class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}

class AuthSuccess extends AuthState {
  final User user;
  AuthSuccess(this.user);

  @override
  List<Object?> get props => [user];
}

class AuthFailure extends AuthState {
  final String message;
  AuthFailure(this.message);

  @override
  List<Object?> get props => [message];
}
Enter fullscreen mode Exit fullscreen mode
// lib/features/auth/presentation/bloc/auth_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/usecases/login_usecase.dart';
import 'auth_event.dart';
import 'auth_state.dart';

class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final LoginUseCase loginUseCase;

  AuthBloc({required this.loginUseCase}) : super(AuthInitial()) {
    on<LoginRequested>(_onLoginRequested);
  }

  Future<void> _onLoginRequested(
    LoginRequested event,
    Emitter<AuthState> emit,
  ) async {
    emit(AuthLoading());

    final result = await loginUseCase(
      email: event.email,
      password: event.password,
    );

    result.fold(
      (failure) => emit(AuthFailure(failure.message)),
      (user) => emit(AuthSuccess(user)),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode
// lib/features/auth/presentation/pages/login_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/auth_bloc.dart';
import '../bloc/auth_event.dart';
import '../bloc/auth_state.dart';

class LoginPage extends StatefulWidget {
  const LoginPage({super.key});

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Login')),
      body: BlocConsumer<AuthBloc, AuthState>(
        listener: (context, state) {
          if (state is AuthSuccess) {
            Navigator.pushReplacementNamed(context, '/home');
          } else if (state is AuthFailure) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text(state.message)),
            );
          }
        },
        builder: (context, state) {
          return Padding(
            padding: const EdgeInsets.all(24.0),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                TextField(
                  controller: _emailController,
                  decoration: const InputDecoration(labelText: 'Email'),
                  keyboardType: TextInputType.emailAddress,
                ),
                const SizedBox(height: 16),
                TextField(
                  controller: _passwordController,
                  decoration: const InputDecoration(labelText: 'Password'),
                  obscureText: true,
                ),
                const SizedBox(height: 24),
                if (state is AuthLoading)
                  const CircularProgressIndicator()
                else
                  ElevatedButton(
                    onPressed: () {
                      context.read<AuthBloc>().add(
                        LoginRequested(
                          email: _emailController.text,
                          password: _passwordController.text,
                        ),
                      );
                    },
                    child: const Text('Login'),
                  ),
              ],
            ),
          );
        },
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Visual: Here you should show the Flutter login screen UI — a clean form with email/password fields, a loading indicator state, and a success navigation transition. Screenshot from the emulator or device.

4.4 Dependency Injection with GetIt

Wire everything together in one place:

// lib/injection_container.dart
import 'package:get_it/get_it.dart';
import 'core/network/api_client.dart';
import 'features/auth/data/datasources/auth_remote_datasource.dart';
import 'features/auth/data/repositories/auth_repository_impl.dart';
import 'features/auth/domain/repositories/auth_repository.dart';
import 'features/auth/domain/usecases/login_usecase.dart';
import 'features/auth/presentation/bloc/auth_bloc.dart';

final sl = GetIt.instance;

Future<void> init() async {
  // BLoC
  sl.registerFactory(() => AuthBloc(loginUseCase: sl()));

  // Use Cases
  sl.registerLazySingleton(() => LoginUseCase(sl()));

  // Repositories
  sl.registerLazySingleton<AuthRepository>(
    () => AuthRepositoryImpl(sl()),
  );

  // Data Sources
  sl.registerLazySingleton<AuthRemoteDataSource>(
    () => AuthRemoteDataSourceImpl(sl()),
  );

  // Core
  sl.registerLazySingleton(() => ApiClient());
}
Enter fullscreen mode Exit fullscreen mode

Call init() in your main.dart:

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'features/auth/presentation/bloc/auth_bloc.dart';
import 'features/auth/presentation/pages/login_page.dart';
import 'injection_container.dart' as di;

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await di.init();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Clean App',
      home: BlocProvider(
        create: (_) => di.sl<AuthBloc>(),
        child: const LoginPage(),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Laravel Backend — What the API Should Return

Your Laravel /api/login endpoint should return:

{
  "token": "1|abcdefg12345",
  "user": {
    "id": 1,
    "name": "Ahmed Ben Ali",
    "email": "ahmed@example.com"
  }
}
Enter fullscreen mode Exit fullscreen mode

A simple Laravel controller using Sanctum:

// app/Http/Controllers/Api/AuthController.php
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;

class AuthController extends Controller
{
    public function login(Request $request)
    {
        $request->validate([
            'email'    => 'required|email',
            'password' => 'required',
        ]);

        $user = User::where('email', $request->email)->first();

        if (! $user || ! Hash::check($request->password, $user->password)) {
            return response()->json([
                'message' => 'Invalid credentials',
            ], 401);
        }

        return response()->json([
            'token' => $user->createToken('flutter-app')->plainTextToken,
            'user'  => $user->only(['id', 'name', 'email']),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Visual: Here you should show the API response flow — diagram with Laravel Sanctum token generation on the server side, and the Flutter model parsing the JSON into a UserModel, then converting to a User entity.


Common Mistakes Developers Make

1. Importing Flutter packages in the domain layer

The domain layer must stay pure Dart. Never import package:flutter there. If you need BuildContext or widgets in a use case, you've gone wrong.

2. Putting business logic in BLoC

BLoC handles state transitions, not business rules. The logic belongs in use cases. BLoC just calls the use case and emits a new state.

3. Skipping the repository interface

Some developers implement the repository directly without an abstract class. This makes testing impossible — you can't mock a concrete class cleanly.

4. One giant feature folder

Dumping all features into a single lib/screens/ folder defeats the purpose. One folder per feature, with its own data/domain/presentation split.

5. Not handling Left (failures)

Using dartz's Either is pointless if you only handle Right. Always .fold() and handle both cases.


Best Practices

  • Test at the use case level. Use cases are pure Dart — easy to unit test without Flutter Test infrastructure.
  • One use case per action. LoginUseCase, LogoutUseCase, RegisterUseCase — not a AuthUseCase that does everything.
  • Use Equatable on entities and states. BLoC won't rebuild on state changes unless equality works correctly.
  • Register BLoCs as Factory, singletons as LazySingleton. BLoCs hold UI state — they should be created fresh per screen.
  • Keep your ApiClient in core. Don't duplicate Dio setup per feature.
  • Store the token securely. Use flutter_secure_storage for the Bearer token, not SharedPreferences.

Real-World Use Cases

This architecture isn't academic. It's what powers serious production apps:

SaaS mobile apps — Multi-tenant platforms where auth, billing, and user roles need clear separation between layers.

E-commerce apps — Product listings, cart, and checkout each live as independent features. One team can work on cart without touching auth.

Admin dashboards — Flutter Web admin panels backed by Laravel APIs use this exact structure for scalable role-based access control.

Healthcare apps — Where testability and audit trails are non-negotiable, Clean Architecture is the standard.

Any app that needs to grow, be tested, or be maintained by more than one developer benefits from this structure.


Conclusion

Flutter Clean Architecture isn't about following a textbook pattern for its own sake. It's about building apps that don't become unmanageable.

With this setup, you get:

  • A codebase you can test at every layer
  • Features that can be built and deployed independently
  • A clear contract between your Flutter app and any backend (like Laravel)
  • A structure any developer can onboard into quickly

The boilerplate feels like overhead at first. Within weeks on a real project, it saves you hours.

Start with auth. Add your next feature using the same pattern. The architecture will carry you.


Found this useful? Follow for more practical Flutter and Laravel content. Clap if it saved you time — it helps others find this guide.

Top comments (0)