Have you ever found your Flutter app becoming harder to maintain as it grows? This is a common challenge for developers who start without a clear architectural plan. And that is where clean architecture comes into play.
Clean architecture is a design approach that structures your app into distinct layers, each with a specific responsibility. This approach helps to separate business logic, UI, and data concerns and makes your code more scalable, testable, and easier to work with over time. It ensures that your app’s core logic is independent of external frameworks, tools, and platforms.
In this post, I’ll guide you through the principles of clean architecture and show you how to structure your app for long-term success—something that transformed the way I approach Flutter development
The Principles of Clean Architecture
As we said in the introduction, clean architecture is about separation of concerns and ensuring that the dependencies in your application flow in a controlled, predictable manner. Let’s break down the key principles that make this approach so effective
Separation of concerns
In clean architecture every layer of your app has a specific responsibility, ensuring that different aspects of the app remain independent. This separation helps organize your code and make it easier to understand, test, and maintain.
The layers include:
- Presentation Layer: Handles the UI and user interactions. It interacts with the domain layer to retrieve data and update the UI based on business logic.
- Domain Layer: Contains the core business logic. This layer is completely independent of external frameworks, UI, or data sources.
- Data Layer: Manages data retrieval and storage. It implements interfaces defined in the domain layer, ensuring the domain logic remains framework-agnostic.
By structuring your app this way, each layer can evolve independently. For example:
- You can swap out an API implementation in the data layer without modifying the business logic in the domain layer.
- UI changes can be made in the presentation layer without affecting how data is fetched or processed.
This clear division of responsibilities reduces the risk of code becoming tightly coupled, making your app easier to maintain and scale.
The Dependency Rule
The dependency rule in clean architecture says that outer layers can depend on inner layers, but inner layers must not depend on outer layers.
For example, the domain layer should have no knowledge of presentation or data layers. Instead it interacts with abstractions like interfaces or repositories, which are implemented in the data layer.
This ensures that the core logic of the app remains isolated and unaffected by changes in UI or data sources
Testability
Since clear separation of concerns in enforce in clean architecture testing become much easier
- Unit Tests: The domain layer is isolated and doesn’t relay on external frameworks, making it perfect for unit testing.
- Integration Test: Each layer can be tested independently, reduction the complexity of the integration test
Writing tests for each layer facilitates quickly identifying and resolving bugs without affecting unrelated part of the application.
Independence from Frameworks
One of the guiding principles of clean architecture is keeping your app’s core logic free from dependencies on external libraries or frameworks.
For instance, your business logic should not directly depend on Flutter widgets or Firebase SDKs. This makes it easier to replace or upgrade frameworks without rewriting large portions of your code.
Single Responsibility
Each class or module in your application should have one reason to change. This is aligned with the Single Responsibility Principle (SRP) from SOLID design principles.
For example, a repository class should only handle data operations and not concern itself with UI updates or navigation.
Understanding the Layers
As we’ve seen above clean architecture organizes your app into three primary layers: Presentation, Domain, and Data. Each layer serves a specific purpose and follows the principles of separation of concerns and controlled dependencies. Let’s explore these layers in detail:
Presentation Layer
This is the layer closest to the user, handling everything they see and interact with.
- Responsibilities:
- Display the user interface (UI).
- Handle user input and events.
- Communicate with the domain layer to retrieve or update data.
- Examples:
- Flutter widgets like Scaffold, TextField, Button.
- State management solutions like BLoC, Provider, or Riverpod.
The Presentation layer should not contain business logic; instead, it delegates this responsibility to the Domain layer.
Domain Layer
The Domain layer is the core of your application, housing the business rules and logic that define how your app behaves.
- Responsibilities:
- Define business entities and their behavior.
- Implement use cases that execute specific application logic.
- Provide interfaces (e.g. Repository) that abstract data operations.
- Examples:
- Entities like User or Order that represent core objects in your app.
- Use cases like GetUserProfile or PlaceOrder.
This layer is independent of frameworks, APIs, or UI components, ensuring it remains unaffected by changes in external systems.
Data Layer
The Data layer is responsible for fetching, storing, and delivering data to the Domain layer.
- Responsibilities:
- Implement repositories defined in the Domain layer.
- Interact with external data sources, such as APIs or local databases.
- Map raw data (e.g., JSON) to domain entities.
- Examples:
- API clients, database services, or shared preferences.
- A
UserRepositoryImpl
that implements theUserRepository
interface.
The Data layer depends on the abstractions defined in the Domain layer, not the other way around. This ensures the Domain layer remains clean and reusable.
How They Together
How these layer work together:
- The Presentation layer requests data or actions from the Domain layer
- The Domain layer executes the business logic using its use cases.
- the Data layer fulfills request from the Domain layer, fetching or storing data as needed
This separation of layers ensures that changes in one part of the app have minimal impact on the rest, making the app more modular and maintainable
Setting Up Clean Architecture in a Flutter Project
Implementing clean architecture in a Flutter project involves organizing your code into well-defined layers and following the principles of separation of concerns. Let’s walk through the setup step by step.
A well-organized folder structure is key to implementing clean architecture.
Create a new flutter project and name it clean_arch_app. We will be following the folder structure below:
lib/
features/
feature_name/ # Example feature module
presentation/ # UI components and state management
domain/ # Entities, use cases, and repository interfaces
data/ # API, database, and repository implementations
core/ # Shared utilities, styles, etc
main.dart # App entry point
Dependencies
We will need the following packages
dependencies:
flutter_bloc: ^8.0.0 # For state management
get_it: ^7.2.0 # Dependency injection
http: ^1.2.2 # For API calls
equatable: ^2.0.5 # For value comparison
dartz: ^0.10.1 # Functional programming utilities (for Either)
dartz is a functional programming package that provides utilities like Either
. This is especially useful for handling errors and nullable values cleanly.
Make sure you run flutter pub get.
Set up the domain layer
The Domain layer encapsulates business logic and defines core application rules.
In the lib folder create the features/user_profile/domain folder, which will contain the following:
Entities
Entities Represent core objects in your app. Create the file ../entities/user.dart. user.dart should contain the following code. Use equatable for value comparison:
import 'package:equatable/equatable.dart';
class User extends Equatable {
const User({
required this.id,
required this.name,
});
final int id;
final String name;
@override
List<Object?> get props => [id, name];
}
Repository interfaces
Repository interfaces define contracts for data access.
Since we are using dartz to handle Errors we will need to create Failure class. In the lib folder create core/errors/failures.dart.
import 'package:equatable/equatable.dart';
class Failure extends Equatable {
final String message;
const Failure(this.message);
@override
List<Object?> get props => [message];
}
In the repository we can now return a Failure when the data layer returns an Error.
Let’s create the file ../repositories/user_repository.dart with the following code
import 'package:clean_arch_app/core/errors/failures.dart';
import 'package:clean_arch_app/features/user_profile/domain/entities/user.dart';
import 'package:dartz/dartz.dart';
abstract class UserRepository {
Future<Either<Failure, User>> getUserById(String id);
}
Use Cases
Use cases implement application logic in reusable class. In the domain folder create the use_cases/get_user_profile.dart file
import 'package:clean_arch_app/core/errors/failures.dart';
import 'package:clean_arch_app/features/user_profile/domain/entities/user.dart';
import 'package:clean_arch_app/features/user_profile/domain/repositories/user_repository.dart';
import 'package:dartz/dartz.dart';
class GetUserProfile {
GetUserProfile(this.repository);
final UserRepository repository;
Future<Either<Failure, User>> call(String userId) async {
return await repository.getUserById(userId);
}
}
Implement the Data Layer
The Data layer contains implementations for data retrieval and storage.
Add the ../user_profile/data folder. It will contain the following:
Models
Models are the extensions of the entities from the Domain layer. They often include JSON serialization and deserialization logic for working with APIs.
Create the ../models/user_model.dart file.
import 'dart:convert';
import 'package:clean_arch_app/features/user_profile/domain/entities/user.dart';
class UserModel extends User {
const UserModel({
required super.id,
required super.name,
});
factory UserModel.fromJson(String source) => UserModel.fromMap(
jsonDecode(source) as Map<String, dynamic>,
);
UserModel.fromMap(Map<String, dynamic> json)
: this(
id: int.tryParse(json['id'].toString()) ?? 0,
name: json['name'] == null ? '' : json['name'] as String,
);
UserModel copyWith({
int? id,
String? name,
}) {
return UserModel(
id: id ?? this.id,
name: name ?? this.name,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
};
}
}
Data sources
Data sources represent the external sources of data, such as APIs or databases.
Let’s create the ../data_sources/user_remote_data_source.dart and add the code below.
import 'package:clean_arch_app/features/user_profile/data/models/user_model.dart';
import 'package:flutter/cupertino.dart';
import 'package:http/http.dart' as http;
abstract class UserRemoteDataSource {
Future<UserModel> fetchUserById(String id);
}
class UserRemoteDataSourceImpl implements UserRemoteDataSource {
@override
Future<UserModel> fetchUserById(String id) async {
final url = Uri.parse('https://jsonplaceholder.typicode.com/users/$id');
try {
final response = await http.get(url);
final user = UserModel.fromJson(response.body);
return user;
} catch (e, s) {
debugPrintStack(stackTrace: s, label: 'Failed to fetch user: $e');
throw Exception('Network error: $e');
}
}
}
UserRemoteDataSource defines the contract, while UserRemoteDataSourceImpl implements it
Repository Implementation
The repository combines data from multiple sources (e.g., remote and local) and maps it to entities used in the Domain layer
Add the file ../data/repositories/user_repository_impl.dart with the code below
import 'package:clean_arch_app/core/errors/failures.dart';
import 'package:clean_arch_app/features/user_profile/data/data_sources/user_remote_data_source.dart';
import 'package:clean_arch_app/features/user_profile/domain/entities/user.dart';
import 'package:clean_arch_app/features/user_profile/domain/repositories/user_repository.dart';
import 'package:dartz/dartz.dart';
class UserRepositoryImpl implements UserRepository {
UserRepositoryImpl(this.remoteDataSource);
final UserRemoteDataSource remoteDataSource;
@override
Future<Either<Failure, User>> getUserById(String id) async {
try {
final userModel = await remoteDataSource.fetchUserById(id);
return Right(userModel); // UserModel is compatible with User since it extends it
} on Exception catch (e) {
return Left(Failure(e.toString()));
}
}
}
Build the Presentation Layer
The Presentation layer includes UI components and state management with flutter_bloc
.
As we’ve done with the other layers let’s create a folder for it. Add the folder presentation in the user_profile folder.
Bloc (Cubit for Simplicity)
../presentation/user_profile_cubit/user_profile_state.dart
part of 'user_profile_cubit.dart';
sealed class UserProfileState extends Equatable {
const UserProfileState();
@override
List<Object?> get props => throw UnimplementedError();
}
final class UserProfileInitial extends UserProfileState {
const UserProfileInitial();
}
final class LoadingUserProfile extends UserProfileState {
const LoadingUserProfile();
}
final class UserProfileLoaded extends UserProfileState {
const UserProfileLoaded({required this.user});
final User user;
}
final class UserProfileError extends UserProfileState {
const UserProfileError({required this.message});
final String message;
@override
List<Object?> get props => [message];
}
../presentation/user_profile_cubit/user_profile_cubit.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:clean_arch_app/features/user_profile/domain/entities/user.dart';
import 'package:clean_arch_app/features/user_profile/domain/use_cases/get_user.dart';
import 'package:equatable/equatable.dart';
part 'user_profile_state.dart';
class UserProfileCubit extends Cubit<UserProfileState> {
UserProfileCubit({
required GetUserProfile getUserProfile,
}) : _getUserProfile = getUserProfile,
super(const UserProfileInitial());
final GetUserProfile _getUserProfile;
Future<void> loadUserProfile(String userId) async {
emit(const LoadingUserProfile());
final result = await _getUserProfile(userId);
result.fold(
(failure) => emit(UserProfileError(message: failure.message)),
(user) => emit(UserProfileLoaded(user: user)),
);
}
}
UI Component
../presentation/pages/user_profile_screen.dart
import 'package:clean_arch_app/features/user_profile/presentation/user_profile_cubit/user_profile_cubit.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class UserProfileScreen extends StatelessWidget {
const UserProfileScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('User Profile')),
body: BlocBuilder<UserProfileCubit, UserProfileState>(
builder: (context, state) {
if (state is UserProfileInitial || state is LoadingUserProfile) {
return const Center(child: CircularProgressIndicator());
} else {
final user = (state as UserProfileLoaded).user;
return Center(child: Text('Hello, ${user.name}!'));
}
},
),
);
}
}
Configure dependency Injection
We will use get_it to manage dependencies. Create the file core/services/service_locator.dart
import 'package:clean_arch_app/features/user_profile/data/data_sources/user_remote_data_source.dart';
import 'package:clean_arch_app/features/user_profile/data/repositories/user_repository_impl.dart';
import 'package:clean_arch_app/features/user_profile/domain/repositories/user_repository.dart';
import 'package:clean_arch_app/features/user_profile/domain/use_cases/get_user.dart';
import 'package:clean_arch_app/features/user_profile/presentation/user_profile_cubit/user_profile_cubit.dart';
import 'package:get_it/get_it.dart';
import 'package:http/http.dart';
final serviceLocator = GetIt.instance;
Future<void> setupDependencies() async {
// App Logic
serviceLocator.registerFactory(() => UserProfileCubit(getUserProfile: serviceLocator()));
// Use cases
serviceLocator.registerLazySingleton(() => GetUserProfile(serviceLocator()));
// Repositories
serviceLocator.registerLazySingleton<UserRepository>(() => UserRepositoryImpl(serviceLocator()));
// Data sources
serviceLocator.registerLazySingleton<UserRemoteDataSource>(() => UserRemoteDataSourceImpl());
// External dependencies
serviceLocator.registerLazySingleton(() => Client.new);
}
Initialize the dependencies in main.dart
import 'package:clean_arch_app/core/services/service_locator.dart';
import 'package:clean_arch_app/features/user_profile/presentation/pages/user_profile_screen.dart';
import 'package:clean_arch_app/features/user_profile/presentation/user_profile_cubit/user_profile_cubit.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
void main() async {
await setUpDependencies();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
home: BlocProvider(
create: (context) => serviceLocator<UserProfileCubit>()..loadUserProfile('1'),
child: const UserProfileScreen(),
),
);
}
}
UserProfileScreen is wrapped with BlocProvider and using serviceLocator to access the cubit the user with the id 1 is loaded.
You can find the full code on the github repository I have create.
Benefits and Challenges
Benefits of Clean Architecture
Separation of Concerns:
By dividing the app into independent layers, you ensure that each layer focuses on a single responsibility. This leads to more organized and maintainable code.
Testability:
Each layer can be tested in isolation. For example, you can write unit tests for the Domain layer without depending on the Presentation or Data layers.
Scalability:
As your project grows, adding new features or modifying existing ones becomes easier because the architecture promotes modularity.
Platform Independence:
The Domain layer is completely decoupled from Flutter and other frameworks, making it easier to reuse business logic in other platforms or projects.
Improved Collaboration:
Developers can work on different layers simultaneously without stepping on each other’s toes, improving team efficiency.
Challenges of Clean Architecture
Steeper Learning Curve:
Implementing clean architecture can be overwhelming for beginners, especially when it involves advanced concepts like dependency injection and functional programming with dartz
.
Initial Setup Overhead:
Structuring a project with clean architecture requires more upfront planning and effort compared to simpler architectures.
Increased Boilerplate Code:
Dividing the project into multiple layers can result in additional files and boilerplate code, which may seem excessive for smaller projects.
Potential Overengineering:
For small or short-lived projects, implementing clean architecture might be unnecessary and could overcomplicate the codebase.
Conclusion
Clean architecture is a powerful tool for building robust, scalable, and maintainable Flutter applications. While it has a steeper learning curve and might seem excessive for smaller projects, its benefits far outweigh the challenges in medium to large-scale applications.
To deepen your understanding of clean architecture and related concepts, explore these resources:
- Clean Architecture: A Craftsman’s Guide to Software Structure and Design by Robert C. Martin.
- Reso Coder’s Clean Architecture Series on YouTube.
- Official Flutter Bloc Documentation.
- Dartz Package Documentation.
With clean architecture, you’re not just building an app—you’re crafting a foundation that ensures your codebase remains clean, organized, and ready for future challenges.