When starting a new Flutter project, it's easy to throw everything into a single lib/ folder. Providers next to widgets, API calls inside UI callbacks, and models sprinkled everywhere. For a weekend hackathon, this is fine. For a production app that will be maintained for years—like our surveying app BhuMitra—it’s a disaster waiting to happen.
In this article, I'll walk you through the exact Clean Architecture implementation we use at Kyvronix Technologies to scale Flutter apps without losing our minds. We will combine Uncle Bob’s Clean Architecture with a feature-driven folder structure, using Riverpod for dependency injection and state management.
1. The Problem with "Standard" MVC
In traditional MVC (Model-View-Controller) or simply placing logic inside ViewModels, you often end up with tight coupling. Your UI knows about your database, your ViewModel knows about HTTP requests, and testing requires spinning up the entire environment.
Clean Architecture solves this by strictly separating concerns into layers, with dependencies always pointing inwards.
2. The Three Layers of Clean Architecture
At its core, Clean Architecture in our Flutter apps is divided into three distinct layers:
- Domain Layer: The innermost layer. It contains the business rules (Entities) and business logic (Use Cases). It is completely independent of Flutter, Riverpod, or any HTTP library.
- Data Layer: The middle layer. It implements the interfaces defined by the Domain Layer. It handles fetching data (Repositories) from external sources (Data Sources like an API or local SQLite db) and mapping JSON to Domain Entities (Models).
- Presentation Layer: The outermost layer. This is where Flutter lives. It contains the UI (Widgets) and State Management (Riverpod Notifiers).
3. Feature-Driven Folder Structure
Instead of grouping by layer (e.g., a massive lib/domain folder for the whole app), we group by feature. This keeps related code together and makes the codebase navigable.
lib/
├── core/ # Shared utilities, networking, errors
└── features/
└── authentication/
├── domain/ # Entities, Repositories (Interfaces), UseCases
├── data/ # Models, Repositories (Impl), DataSources
└── presentation/ # UI, Controllers, Riverpod Providers
4. Implementing the Layers
The Domain Layer
Let's define a simple User entity and the repository interface.
// lib/features/authentication/domain/entities/user.dart
class User {
final String id;
final String email;
User({required this.id, required this.email});
}
// lib/features/authentication/domain/repositories/auth_repository.dart
abstract class AuthRepository {
Future<User> login(String email, String password);
}
The Data Layer
The data layer implements the repository interface and fetches data from a data source.
// lib/features/authentication/data/datasources/auth_remote_data_source.dart
class AuthRemoteDataSource {
final HttpClient client;
AuthRemoteDataSource(this.client);
Future<UserModel> login(String email, String password) async {
final response = await client.post('/login', data: {'email': email, 'password': password});
return UserModel.fromJson(response.data);
}
}
// lib/features/authentication/data/repositories/auth_repository_impl.dart
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
AuthRepositoryImpl(this.remoteDataSource);
@override
Future<User> login(String email, String password) async {
final userModel = await remoteDataSource.login(email, password);
// Convert UserModel (Data) to User (Domain)
return userModel.toEntity();
}
}
The Presentation Layer (Riverpod)
We use Riverpod to inject these dependencies and manage the state of our UI.
// lib/features/authentication/presentation/providers/auth_providers.dart
final authRemoteDataSourceProvider = Provider((ref) {
return AuthRemoteDataSource(ref.read(httpClientProvider));
});
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepositoryImpl(ref.read(authRemoteDataSourceProvider));
});
final authNotifierProvider = StateNotifierProvider<AuthNotifier, AsyncValue<User?>>((ref) {
return AuthNotifier(ref.read(authRepositoryProvider));
});
class AuthNotifier extends StateNotifier<AsyncValue<User?>> {
final AuthRepository _repository;
AuthNotifier(this._repository) : super(const AsyncValue.data(null));
Future<void> login(String email, String password) async {
state = const AsyncValue.loading();
try {
final user = await _repository.login(email, password);
state = AsyncValue.data(user);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
}
5. Why This Approach Wins
By strictly enforcing these boundaries, you gain several massive advantages:
- Testability: You can mock the
AuthRepositoryand test theAuthNotifierinstantly without mocking HTTP clients. - Flexibility: Want to swap out Dio for Http? You only touch the
AuthRemoteDataSource. The Domain and Presentation layers remain untouched. - Scalability: As your team grows, developers can work on different features without causing merge conflicts in massive centralized files.
Conclusion
Clean Architecture might feel like "over-engineering" on day one. But on day 300, when requirements change and the app grows complex, it becomes the safety net that prevents your codebase from turning into spaghetti. Paired with Riverpod's elegant dependency injection, it is the ultimate stack for production Flutter apps.
— Ankit Kumar