Flutter Clean Architecture with MVVM and Dependency Injection
Build scalable, testable, and maintainable Flutter applications with practical examples using Riverpod, repositories, and Injectable.
Beginner to AdvancedCategory: Flutter Development · Architecture · Dart
As a Flutter application grows, managing API calls, business logic, navigation, and UI state inside widgets becomes difficult. Large widgets are harder to test, code becomes tightly coupled, and even small changes can affect unrelated features.
Clean Architecture helps organize application code into layers with clearly defined responsibilities. When combined with MVVM (Model-View-ViewModel) and Dependency Injection (DI), it provides a practical foundation for building production-ready Flutter applications.
- Clean Architecture principles and dependency rules.
- How MVVM fits into Flutter's presentation layer.
- How to organize features, repositories, and data sources.
- How to manage state using Riverpod.
- How dependency injection works with Riverpod and Injectable.
- How to test business logic independently of the UI.
Table of Contents
- Why Clean Architecture matters
- Understand the architecture
- Clean Architecture vs MVVM
- Recommended project structure
- Create the domain layer
- Implement the data layer
- Dependency injection with Riverpod
- Create the ViewModel
- Build the Flutter UI
- Using Injectable and get_it
- Testing and best practices
- Frequently asked questions
1. Why Do Flutter Apps Need Clean Architecture?
In a small application, putting an API request directly inside a widget may seem convenient. But as requirements grow, that widget can end up handling networking, parsing, validation, state management, and presentation simultaneously.
Without structure
- Large widgets
- Tightly coupled code
- Difficult unit testing
- Repeated business logic
With clear layers
- Smaller components
- Independent business logic
- Easier testing
- Better maintainability
Clean Architecture is especially useful for applications with multiple features, complex business rules, several developers, or a long maintenance lifecycle. A small app may not need every layer, so apply the pattern proportionally to the project's complexity.
2. Understand Flutter Clean Architecture
A common Clean Architecture implementation uses three main layers: presentation, domain, and data. The key principle is that inner business rules should not depend on external implementation details.
The domain defines contracts. The data layer implements them. Dependency injection connects the pieces.
Important: The arrows above illustrate typical runtime interaction. In the dependency rule, the domain layer must not import concrete data-layer implementations. The data layer implements domain contracts, and the composition root wires implementations to their consumers.
Responsibilities of each layer
| Layer | Responsibility | Examples |
|---|---|---|
| Presentation | Display data and manage UI state | Widgets, ViewModels |
| Domain | Define business rules and contracts | Entities, use cases, repository interfaces |
| Data | Retrieve, parse, and persist data | Dio, DTOs, repository implementations |
3. Clean Architecture vs MVVM
These patterns solve related but different problems.
- Clean Architecture organizes the application into layers and establishes dependency boundaries.
- MVVM separates the user interface from presentation logic through a ViewModel.
- Dependency Injection supplies an object with the dependencies it needs instead of making it create them directly.
The View displays state from a ViewModel. The ViewModel calls a domain use case or repository contract. The data layer implements that contract. Dependency injection supplies the implementation at runtime.
4. Recommended Flutter Project Structure
For this example, we will build a simple feature that retrieves a user's profile. A feature-first structure keeps related code together.
lib/
├── core/
│ └── network/
│ └── api_client.dart
├── features/
│ └── profile/
│ ├── domain/
│ │ ├── entities/user.dart
│ │ ├── repositories/user_repository.dart
│ │ └── usecases/get_user.dart
│ ├── data/
│ │ ├── models/user_model.dart
│ │ ├── datasources/user_remote_data_source.dart
│ │ └── repositories/user_repository_impl.dart
│ └── presentation/
│ ├── viewmodels/profile_view_model.dart
│ └── views/profile_screen.dart
└── main.dart
This structure is an example rather than a mandatory standard. For smaller projects, you can combine simple files or omit use cases when they provide no meaningful value.
5. Create the Domain Layer
The domain layer contains business concepts and contracts. It should be independent of Flutter widgets, Dio, JSON serialization, and database implementations.
Step 1: Create the User entity
Create lib/features/profile/domain/entities/user.dart:
class User {
final int id;
final String name;
final String email;
const User({
required this.id,
required this.name,
required this.email,
});
}
Step 2: Define the repository contract
Create lib/features/profile/domain/repositories/user_repository.dart:
import '../entities/user.dart';
abstract interface class UserRepository {
Future<User> getUser(int id);
}
The domain layer defines what the application needs without specifying how the data is fetched.
Step 3: Add a use case
Create lib/features/profile/domain/usecases/get_user.dart:
import '../entities/user.dart';
import '../repositories/user_repository.dart';
class GetUser {
final UserRepository repository;
const GetUser(this.repository);
Future<User> call(int id) {
if (id <= 0) {
throw ArgumentError('User ID must be positive.');
}
return repository.getUser(id);
}
}
A use case represents a business operation. It is useful when an operation has validation, coordination, or business rules. For a trivial application, calling the repository directly from the ViewModel can also be reasonable.
6. Implement the Data Layer with Dio
Step 1: Create a data model
Create lib/features/profile/data/models/user_model.dart:
import '../../domain/entities/user.dart';
class UserModel {
final int id;
final String name;
final String email;
const UserModel({
required this.id,
required this.name,
required this.email,
});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'] as int,
name: json['name'] as String,
email: json['email'] as String,
);
}
User toEntity() {
return User(
id: id,
name: name,
email: email,
);
}
}
Step 2: Create a remote data source
Install the Dio package:
flutter pub add dio
Create lib/core/network/api_client.dart:
import 'package:dio/dio.dart';
class ApiClient {
ApiClient()
: dio = Dio(
BaseOptions(
baseUrl: 'https://jsonplaceholder.typicode.com',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: {'Accept': 'application/json'},
),
);
final Dio dio;
}
Create lib/features/profile/data/datasources/user_remote_data_source.dart:
import 'package:dio/dio.dart';
import '../models/user_model.dart';
class UserRemoteDataSource {
final Dio dio;
UserRemoteDataSource(this.dio);
Future<UserModel> getUser(int id) async {
final response = await dio.get<dynamic>('/users/$id');
final json = response.data as Map<String, dynamic>;
return UserModel.fromJson(json);
}
}
Step 3: Implement the repository
Create lib/features/profile/data/repositories/user_repository_impl.dart:
import '../../domain/entities/user.dart';
import '../../domain/repositories/user_repository.dart';
import '../datasources/user_remote_data_source.dart';
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource remoteDataSource;
UserRepositoryImpl(this.remoteDataSource);
@override
Future<User> getUser(int id) async {
final model = await remoteDataSource.getUser(id);
return model.toEntity();
}
}
The repository implementation translates the data-layer model into a domain entity. The domain layer never needs to know that Dio or JSONPlaceholder exists.
7. Dependency Injection with Riverpod
Dependency Injection means an object receives its dependencies from outside rather than constructing every dependency internally. Riverpod can provide a lightweight composition mechanism for a Flutter application.
Create lib/features/profile/profile_providers.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/api_client.dart';
import 'data/datasources/user_remote_data_source.dart';
import 'data/repositories/user_repository_impl.dart';
import 'domain/repositories/user_repository.dart';
import 'domain/usecases/get_user.dart';
import 'presentation/viewmodels/profile_view_model.dart';
final apiClientProvider = Provider<ApiClient>((ref) {
return ApiClient();
});
final userRemoteDataSourceProvider =
Provider<UserRemoteDataSource>((ref) {
return UserRemoteDataSource(
ref.watch(apiClientProvider).dio,
);
});
final userRepositoryProvider = Provider<UserRepository>((ref) {
return UserRepositoryImpl(
ref.watch(userRemoteDataSourceProvider),
);
});
final getUserProvider = Provider<GetUser>((ref) {
return GetUser(ref.watch(userRepositoryProvider));
});
final profileViewModelProvider =
AsyncNotifierProvider(
ProfileViewModel.new,
);
We will define UserProfileState and ProfileViewModel in the next section. The provider declarations make each dependency replaceable in tests.
8. Create the MVVM ViewModel
The ViewModel coordinates the UI with the domain operation. It should not know about Flutter widgets or HTTP response details.
Install Riverpod:
flutter pub add flutter_riverpod
Create lib/features/profile/presentation/viewmodels/profile_view_model.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/entities/user.dart';
import '../../profile_providers.dart';
class UserProfileState {
final User? user;
const UserProfileState({this.user});
}
class ProfileViewModel extends AsyncNotifier<UserProfileState> {
@override
Future<UserProfileState> build() async {
return const UserProfileState();
}
Future<void> loadUser(int id) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final getUser = ref.read(getUserProvider);
final user = await getUser(id);
return UserProfileState(user: user);
});
}
}
The ViewModel starts with an empty profile. When the user requests a profile, it calls the use case and exposes the result through Riverpod's asynchronous state.
Architecture note: The ViewModel imports a provider from the composition layer in this compact example. For stricter dependency boundaries, inject the use case into the ViewModel through a constructor or a dedicated provider factory, rather than importing the provider registry from inside the feature's domain logic.
9. Build the Profile Screen
Create lib/features/profile/presentation/views/profile_screen.dart:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../profile_providers.dart';
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profileAsync = ref.watch(profileViewModelProvider);
return Scaffold(
appBar: AppBar(
title: const Text('User Profile'),
),
body: Padding(
padding: const EdgeInsets.all(24),
child: profileAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stackTrace) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Unable to load profile.'),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () => ref
.read(profileViewModelProvider.notifier)
.loadUser(1),
child: const Text('Try Again'),
),
],
),
),
data: (profileState) {
final user = profileState.user;
if (user == null) {
return Center(
child: ElevatedButton(
onPressed: () => ref
.read(profileViewModelProvider.notifier)
.loadUser(1),
child: const Text('Load User Profile'),
),
);
}
return Center(
child: Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircleAvatar(
radius: 34,
child: Icon(Icons.person, size: 36),
),
const SizedBox(height: 16),
Text(
user.name,
style: Theme.of(context)
.textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(user.email),
],
),
),
),
);
},
),
),
);
}
}
Finally, wrap the application in ProviderScope in main.dart and use ProfileScreen as the home screen:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'features/profile/presentation/views/profile_screen.dart';
void main() {
runApp(
const ProviderScope(
child: MaterialApp(
debugShowCheckedModeBanner: false,
home: ProfileScreen(),
),
),
);
}
10. Using Injectable and get_it Instead
Riverpod providers already support dependency injection. If your application uses get_it and injectable, you can use those tools to register and resolve dependencies instead. Avoid maintaining two competing dependency registries without a clear architectural reason.
Install the packages:
flutter pub add get_it injectable
flutter pub add --dev injectable_generator build_runner
For example, a class can be registered with Injectable:
import 'package:injectable/injectable.dart';
@injectable
class ExampleService {
String getMessage() {
return 'Dependency injection is configured.';
}
}
Generate the registration code using the project's configured Injectable setup and build runner:
dart run build_runner build --delete-conflicting-outputs
Injectable requires a configured get_it instance and generated registration setup. The annotation alone does not register a class automatically. Consult the current package documentation for the complete configuration, especially when using asynchronous dependencies or environment-specific registrations.
11. Unit Testing and Best Practices
One of the biggest advantages of Clean Architecture is that business logic can be tested without starting the Flutter UI or making real HTTP requests.
For example, you can create a fake repository for testing the use case:
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/features/profile/domain/entities/user.dart';
import 'package:your_app/features/profile/domain/repositories/user_repository.dart';
import 'package:your_app/features/profile/domain/usecases/get_user.dart';
class FakeUserRepository implements UserRepository {
@override
Future<User> getUser(int id) async {
return User(
id: id,
name: 'Alex Morgan',
email: 'alex@example.com',
);
}
}
void main() {
test('GetUser returns the requested user', () async {
final useCase = GetUser(FakeUserRepository());
final user = await useCase(1);
expect(user.id, 1);
expect(user.name, 'Alex Morgan');
expect(user.email, 'alex@example.com');
});
test('GetUser rejects an invalid ID', () {
final useCase = GetUser(FakeUserRepository());
expect(
() => useCase(0),
throwsArgumentError,
);
});
}
Replace your_app in the import paths with your project's actual package name.
Recommended best practices
12. Frequently Asked Questions
Is Clean Architecture necessary for every Flutter app?
No. Small applications may work well with a simpler structure. Clean Architecture becomes more valuable when the application has complex business logic, multiple data sources, extensive testing needs, or a long maintenance lifecycle.
What is the difference between MVVM and Clean Architecture?
MVVM organizes presentation logic into a View, ViewModel, and model-related concepts. Clean Architecture establishes broader application layers and dependency rules. You can use MVVM inside the presentation layer of a Clean Architecture application.
Should I use Riverpod or Injectable?
Riverpod combines reactive state management with provider-based dependency injection. Injectable generates registrations for the get_it service locator. Choose based on your existing architecture and requirements; neither is mandatory for Clean Architecture.
Do I need a use case for every API call?
No. Use cases are helpful when they represent meaningful business operations, validation, or coordination. For straightforward operations, a ViewModel can call a repository directly if that keeps the design simpler.
Can Clean Architecture improve app performance?
Not automatically. Its primary benefits are separation of concerns, maintainability, and testability. Performance still depends on efficient rendering, state updates, networking, caching, and resource management.
Conclusion
Flutter Clean Architecture, MVVM, and dependency injection can provide a strong foundation for applications that need to grow over time. By separating presentation, domain rules, and data access, you make the codebase easier to understand, test, and change.
Start with clear boundaries, introduce abstractions where they provide value, and avoid unnecessary complexity. For larger applications, combine these principles with structured error handling, automated tests, and consistent dependency management.
Keep Learning Flutter
Explore official Flutter documentation and build your architecture knowledge through practical projects.
Explore Flutter DocumentationReferences
- Flutter App Architecture Guide
- Flutter Riverpod Documentation
- Dio Package Documentation
- Injectable Package Documentation
- get_it Package Documentation
This tutorial is for educational purposes. Verify current package versions and test all code against your Flutter SDK before using it in a production application.
Comments
Post a Comment