Flutter Clean Architecture with MVVM and Dependency Injection

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 Advanced

Category: 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.

What you will learn
  • 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

  1. Why Clean Architecture matters
  2. Understand the architecture
  3. Clean Architecture vs MVVM
  4. Recommended project structure
  5. Create the domain layer
  6. Implement the data layer
  7. Dependency injection with Riverpod
  8. Create the ViewModel
  9. Build the Flutter UI
  10. Using Injectable and get_it
  11. Testing and best practices
  12. 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.

Presentation Layer
View · ViewModel · UI State
↓
Domain Layer
Entities · Repository Contracts · Use Cases
↑
Data Layer
Repository Implementations · API · Database

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.
How they work together

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

✓ Keep business rules out of widgets
Use ViewModels and domain operations for logic that deserves independent testing.
✓ Depend on abstractions
The domain defines repository contracts, while concrete data implementations live outside it.
✓ Avoid unnecessary layers
Introduce use cases when they add business meaning or coordination, not simply to increase the file count.
✓ Use dependency injection consistently
Choose Riverpod, get_it/Injectable, or a deliberate combination with clear responsibilities.
✓ Test errors as well as success
Cover network failures, invalid data, loading states, and business-rule validation.
✓ Keep dependencies pointing inward
The domain layer should not import Flutter UI, Dio, or concrete data-source classes.

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 Documentation

References

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