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
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
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
Then run:
flutter pub get
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 withEither<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});
}
// 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});
}
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;
}
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];
}
// 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();
}
// 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);
}
}
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,
};
}
}
// 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());
}
}
}
// 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);
}
}
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];
}
// 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];
}
// 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)),
);
}
}
// 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'),
),
],
),
);
},
),
);
}
}
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());
}
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(),
),
);
}
}
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"
}
}
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']),
]);
}
}
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 aUserentity.
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 aAuthUseCasethat does everything. -
Use
Equatableon entities and states. BLoC won't rebuild on state changes unless equality works correctly. -
Register BLoCs as
Factory, singletons asLazySingleton. BLoCs hold UI state — they should be created fresh per screen. -
Keep your
ApiClientin core. Don't duplicate Dio setup per feature. -
Store the token securely. Use
flutter_secure_storagefor the Bearer token, notSharedPreferences.
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)