June 9, 2025
Published by Manu Junjanna
If you've ever opened an old Flutter project and found yourself wading through a jungle of widgets, services, and mysterious global variables, you're not alone. It starts innocently: just a screen here, a button there, but before you know it, everything's tangled together, and you are lost. Thatās where Clean Architecture comes into the picture. It's not a buzzword, but a practical approach to organize your codebase in a way that's scalable, testable, and easy to maintain over time.
In yogic philosophy, the mind is often described as "Markata", a Sanskrit word for monkey. Like a monkey, the mind is restless, constantly jumping from one thought to another. It's curious, impulsive, and always seeking something new.
As developers, we experience this same restlessness when we write code. We finish a feature, and moments later, thoughts creep in:
This is the Markata within us, the ever-wandering mind that seeks improvement. But without structure, this drive can quickly spiral into chaos. Thatās why we need an architecture that embraces change instead of resisting it.
In reality, software is never truly finished. Requirements evolve. Technologies shift. That's why our code should be decoupled and modular, allowing us to adapt with minimal disruption. For example:
Today you might be using a REST API, but tomorrow you may need to move to gRPC. With clean architecture, you only need to update the data layer, not the entire app.
You might start with Stacked State managment for its simplicity, but later realize you need the scalability and modularity of Bloc. A well-structured architecture lets you swap this out cleanly.
Maybe you're using Material Design now, but later want to adopt platform-specific UI for a more native feel. With proper separation, you only need to adjust the presentation layer.
Clean architecture gives your code the flexibility to evolve without fighting against itself.
Clean Architecture is typically divided into three main layers: Domain, Data, and Presentation.
This is the most important and independent part of your app. It doesn't depend on anything else.
Think of this layer as the brain of your app. it defines the rules, logic, and core behaviors, independent of any external systems.
This layer is responsible for getting and saving data, whether from a database, API, or local storage.
The Data layer relies on the Domain layer to define contracts and logic, but the Domain layer remains completely independent. This one-way dependency ensures that core business rules are unaffected by changes in data sources
This is the UI layer ā everything the user interacts with.
Before starting, it's important to understand how to structure your folders. Below are a few common approaches to organizing your project using clean architecture principles:
The folder structure can vary depending on the nature of your project and what makes development easier and more maintainable for your team.
For this blog, we'll follow the Feature-First Approach. We'll implement a simple login flow where the user taps the login button, enters their credentials, and receives an authentication token upon successful login.
Always start with the independant layer i,e domain layer, lets start with defining entities followed by repositories interface and use cases
lib/feature/login/domain/entities/user.dart
class Auth {
final String? accessToken;
final String? refreshToken;
Auth({this.accessToken, this.refreshToken});
}
lib/feature/login/domain/repositories/auth_repository.dart
abstract class AuthRepository {
Future<Auth> login(String email, String password);
}
lib/feature/login/domain/usecase/login_usecase.dart
class LoginUseCase {
final AuthRepository repository;
LoginUseCase(this.repository);
Future<Auth> call(String email, String password) {
return repository.login(email, password);
}
}
As you can see, this code is completely decoupled and independent of any external layers. It defines the core business rules and contracts that the rest of the application must adhere to.
With the domain layer defining the core logic and contracts, the data layer comes next, responsible for fetching and transforming data. It depends on the domain to ensure consistent business rules, regardless of how or where the data is sourced
lib/feature/login/data/model/auth_model.dart
import '../../domain/entities/auth.dart';
class AuthModel extends Auth {
// AuthModel can include serialization logic or other data-specific methods
// that are only relevant within the data layer, while still conforming to the Auth entity.
AuthModel({
super.accessToken,
super.refreshToken,
});
factory AuthModel.fromJson(Map<String, dynamic> json) {
return AuthModel(
accessToken: json['access_token'] as String?,
refreshToken: json['refresh_token'] as String?,
);
}
}
lib/feature/login/data/datasources/auth_remote_data_source.dart
abstract class AuthRemoteDataSource {
Future<AuthModel> login(String email, String password);
}
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final http.Client client;
AuthRemoteDataSourceImpl(this.client);
@override
Future<AuthModel> login(String email, String password) async {
final response = await client.post(
Uri.parse('https://yourapi.com/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email, 'password': password}),
);
if (response.statusCode == 200) {
return AuthModel.fromJson(jsonDecode(response.body));
} else {
throw Exception('Login failed');
}
}
}
lib/feature/login/repositories/auth_repository_impl.dart
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
AuthRepositoryImpl(this.remoteDataSource);
@override
Future<Auth> login(String email, String password) {
return remoteDataSource.login(email, password);
}
}
Once the Domain and Data layers are in place, we move on to the Presentation layer. To keep things simple, we'll use setState instead of any state management libraries. Regardless of the approach, the core interaction remains the same: using UseCases to drive the UI.
First, let's initialize all the dependencies using dependency injection (DI) with the help of get_it.
lib/feature/login/presentation/di/dependency_injection.dart
void initDependencies() {
Ā
Ā Get.lazyPut(() => http.Client());
Ā // Data sources
Ā Get.lazyPut(() => AuthRemoteDataSourceImpl(Get()));
Ā Get.lazyPut(() => AuthLocalDataSourceImpl());
Ā // Repository
Ā Get.lazyPut(() => AuthRepositoryImpl(
Ā Ā Ā Ā Ā Ā Ā remoteDataSource: Get(),
Ā Ā Ā Ā Ā Ā Ā localDataSource: Get(),
Ā Ā Ā Ā Ā ));
Ā // UseCase
Ā Get.lazyPut(() => LoginUseCase(Get()));
}
class LoginButton extends StatelessWidget {
const LoginButton({super.key});
void _handleLogin() async {
.....
final Auth auth = await Get.find<LoginUseCase>(
_emailController.text.trim(),
_passwordController.text.trim(),
);
...
}
@override
Widget build(BuildContext context) {
return Column(
children: [
......
ElevatedButton(
onPressed: _handleLogin,
child: const Text('Login'),
),
......
],
);
}
}
In summary, the LoginButton calls the LoginUseCase to perform the login operation. The LoginUseCase delegates the task to the AuthRepository, which then uses the AuthRemoteDataSource to make the actual API call. Once the API responds, the result is passed back through the layers and returned to the LoginButton as an Auth entity.
This clear separation of concerns ensures that each layer is responsible only for its specific tasks, making your codebase easier to maintain, test, and extend.
Just as the wandering "Markata" mind finds peace through discipline and structure, our codebases thrive when guided by clear architectural boundaries. Clean Architecture isnāt just a technical choice, itās a way to future-proof our projects and welcome change without fear. By investing in well-defined layers and feature-first organization, you create software thatās not only easier to build and test today, but also far more adaptable for whatever tomorrow brings.
So, the next time your codebase starts to feel like a jungle, remember: structure brings clarity, and clarity brings calm. Embrace Clean Architecture and let your Flutter projects evolve with grace and confidence
While Clean Architecture lays the foundation but building a truly robust Flutter app involves many other best practices. Here are some key principles and habits to elevate your code quality, performance, and developer experience:
Remember, in Flutter, the performance of your appās UI is inversely proportional to the number of times widgets are rebuilt. If you want a high-performance app, focus on minimizing unnecessary widget rebuilds.
Registered Office: Bright HR Limited, The Peninsula, Victoria Place, Manchester, M4 4FB. Registered in England and Wales No: 9283467. Tel: 0844 892 3928. I Copyright Ā© 2024 BrightHR