Implementations of a Clean Architecture in Flutter Projects

Lennard Deurman
15 min readFeb 24, 2024

--

The architectural structure of your mobile application plays an important role in its potential to be maintained, prevent bugs from arising, and be properly testable. While multiple architectures exist for Flutter applications my go-to approach has always been clean architecture because of its ability to produce simple, testable, maintainable code and have a clear separation of concerns.

The idea is simple and revolves around a union-based approach. Separate responsibilities are depicted in their own layers, and by doing so and applying a high level of abstraction clear, maintainable code can be produced. Dependencies only go inwards meaning code may only rely on code from its inner circle.

The original image of Clean Architecture as presented by Robert C. Martin

Nowadays there’s a lot of information online about how you should implement this architecture. I will not completely go into all the details about the Clean Architecture itself but rather focus on the different variations and implementation options you will have within your Flutter application. If you’d like to read more about this I would suggest checking out some of the following resources:

In terms of architectural structure based on clean architecture in my opinion, there’s not 1 correct way that leads to the optimal result. There are several approaches and one might work better than another according to your project needs.

How would Clean Architecture look in a Flutter application?

This is something that already started to be more opinionated. Different developers will have different views on this, taking into account the project specifications as well. For me, when starting with clean architecture in my applications it was difficult to understand which layers to use, especially because I found myself looking into different resources online that described different approaches.

The common approach (3-layers: Presentation, Domain, Data)

The most common approach for setting up the architecture is having a presentation layer, a domain layer, and a data layer.

The 3 layer approach

Presentation layer

This layer should be responsible for everything related to the UI. This is the appropriate place for widgets and your state management logic (I use BloC for this). Recall the dependency inversion rule, which for this layer implies that it should only rely on the domain layer.

This part is a bit dependent on your choice of state management but generally I would make sure you adhere to the following rules:

  • Separate your state management logic from your presentation logic: A bit obvious, but sometimes still forgotten. Make sure that the classes that handle your state don’t include any UI code (so also no dialogs, or navigation). The UI should respond to state changes but the state management tool should not initiate them themselves.
  • Only your state management classes should communicate with the domain layer: The UI in your app should only use the state management classes and not directly use your repositories.
  • Keep your state management classes small, simple, and comprehensive: The state management classes should be used to communicate the state based on the business logic of the application. If you feel your state management classes handle actions that could be merged together, consider the use of an UseCase (further below).

Domain layer

The domain layer is the “template” for everything. This part of the app has little to no dependencies and ensures that our architecture is decoupled from external frameworks and libraries, promoting flexibility and reuse.

In specific this layer will provide:

  • UseCases

Next to the repositories, the use cases will present the specific tasks that the application can perform. These use cases contain the necessary business logic. For some applications, this might feel like an unnecessary connection block to the repository. For example in the first code-example below the use cases seem to have little to no value.

// Use case to get a user by their unique ID
class GetUserByIdUseCase {
final UserRepository _userRepository;

GetUserByIdUseCase(this._userRepository);

// Executes the use case with the given user ID
Future<User> execute(String userId) async {
// Call the UserRepository to fetch the user by ID
return _userRepository.getUserById(userId);
}
}

In other cases there might be more meaning to a use case, and will allow us to encapsulate business logic while not being tightly coupled to other elements of the application, thus enforcing an easy way for testing and maintainability. Consider the second case below, where error handling and custom logic are added. In this scenario, it would make more sense to split out the business logic to a separate class, rather than including our logic in the repository or the state management.

class GetUserByIdUseCase {
final UserRepository _userRepository;

GetUserByIdUseCase(this._userRepository);

// Executes the use case with the given user ID
Future<UserResult> execute(String userId) async {
try {
// Call the UserRepository to fetch the user by ID
final user = await _userRepository.getUserById(userId);

// Check user eligibility based on business rules
if (!isUserEligible(user)) {
return UserResult.error('User is not eligible for retrieval');
}

return UserResult.success(user);
} catch (e) {
return UserResult.error('Failed to retrieve user: $e');
}
}

// Business logic to determine user eligibility
bool isUserEligible(User user) {
// Example business rules: user must be at least 18 years old and have an active account
return user.age >= 18 && user.accountStatus == AccountStatus.active;
}
}

Dependency injection

Note that we inject our dependencies into the classes, having the responsibility for creating the class outside of the use case. This strategy will be used throughout the layers for the classes we use, it provides us more flexibility and better testing.

Using services instead of UseCases

In some applications instead of a UseCase, a Service is used to form the communication block between the domain and presentation layer. Unlike a UseCase, which is one specific action, they would combine multiple actions and encapsulate their business logic into that service. There’s not really one go-to reason when to go for a UseCase or a Service. If multiple actions relate and share business logic, it might be worth joining them together in a service.

Is it always required to have a use-case / service?

As shown in the first example, the use case might be quite “boring” — there might be not much work to do besides calling the repository. The first thing that should be considered, is if there is indeed no business logic or if it’s maybe incorrectly placed. Are there specific parts that are maybe handled in the state management or in the repository itself? If this is not the case, it could be considered to leave the use cases out, and instead of calling the use-case/service in the domain layer, call the repository from the state management tool.

  • Repository contracts: For the repositories we create, we will make abstract interfaces. This might sound like boilerplate code to some, but will allow you to have a more flexible architecture, decoupling your classes from the actual implementation, and thereby making it easier to test and maintain.

// Abstract interface for UserRepository
abstract class UserRepository {

// Fetches a user by their unique ID
Future<User> getUserById(String userId);

// Saves a user to the repository
Future<void> saveUser(User user);

// Deletes a user from the repository
Future<void> deleteUser(String userId);

// Retrieves a list of all users from the repository
Future<List<User>> getAllUsers();
}

Consider the above scenario that represents an abstract interface for a repository class. By design, it will not be possible to tightly couple our abstract class with external dependencies. If our repository class could have been a direct implementation, it would’ve invoked several external dependencies (as shown in the example below) which would’ve made flexibility of implementation more difficult and would be harder to maintain.

import 'package:http/http.dart' as http; // Example external dependency for HTTP requests
import 'package:firebase/firebase.dart' as firebase; // Example external dependency for Firebase database
import 'package:logging/logging.dart'; // Example external dependency for logging

class UserRepository {
final http.Client httpClient;
final firebase.Database database;
final Logger logger;

...

UserRepository(this.httpClient, this.database, this.logger){
_initFirebase();
_otherLogic();
}

Future<User> getUserById(String userId) async {
// Example implementation using an external API
final response = await httpClient.get(Uri.parse('https://api.example.com/users/$userId'));
if (response.statusCode == 200) {
return User.fromJson(jsonDecode(response.body));
} else {
throw Exception('Failed to fetch user');
}
}

Future<void> saveUser(User user) async {
// Example implementation using Firebase database
await database.ref('users/${user.id}').set(user.toJson());
logger.info('User ${user.id} saved');
}

Future<void> deleteUser(String userId) async {
// Example implementation using Firebase database
await database.ref('users/$userId').remove();
logger.info('User $userId deleted');
}

Future<List<User>> getAllUsers() async {
// Example implementation using Firebase database
final snapshot = await database.ref('users').once('value');
final users = <User>[];
snapshot.forEach((userId, userData) {
users.add(User.fromJson(userData));
});
return users;
}
}

Testability

An argument that is often named for using the first approach is having more flexibility in testability. While it’s true an abstract interface would be easier to test directly, it would not mean the second example is not properly testable (in Dart). Nowadays Flutter offers tools such as Mockito which make it pretty easy to have stubs for the implementation classes as well.

Are there also reasons not to use an abstract layer here? In the third section (2-layer) approach, we dive more in-depth into when it would make sense to create a simplification here.

  • Domain models

Finally, in this layer of the application, we find the models/entities used. For example for the example above we would define a User class as an entity.

class User {
final int userId;
final String username;
final String email;
final String password;
final bool isActive;
final DateTime? createdAt;
final DateTime? updatedAt;

const User({
required this.userId,
required this.username,
required this.email,
required this.password,
this.isActive = true,
this.createdAt,
this.updatedAt,
});
}

Difference with DTOs

Note that we differentiate between these models in the application layer, and the DTOs (Data Transfer Objects). These DTOs are simple models without logic that are used to transfer data from one part of the app to another — in this context done within the data layer. Usually, these models are used to serialize and deserialize a JSON structure from an application backend. Below an example of a typical DTO structure in one my Flutter applications.

import 'package:equatable/equatable.dart';
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:json_annotation/json_annotation.dart';

part 'user_dto.g.dart';

@JsonSerializable()
@CopyWith()
class UserDto extends Equatable {
@JsonKey(name: 'user_id')
final String userId;
@JsonKey(name: 'username')
final String username;
@JsonKey(name: 'email')
final String email;
@JsonKey(name: 'is_active')
final bool isActive;
@JsonKey(name: 'first_name')
final String? firstName;
@JsonKey(name: 'last_name')
final String? lastName;
@JsonKey(name: 'age')
final int? age;
@JsonKey(name: 'gender')
final String? gender;

const UserDto({
required this.userId,
required this.username,
required this.email,
required this.isActive,
this.firstName,
this.lastName,
this.age,
this.gender,
});

@override
List<Object?> get props => [
userId,
username,
email,
isActive,
firstName,
lastName,
age,
gender,
];

factory UserDto.fromJson(Map<String, dynamic> json) =>
_$UserDtoFromJson(json);

Map<String, dynamic> toJson() => _$UserDtoToJson(this);
}

When I first started working with DTOs I felt like they were unnecessary and did not fully understand the need for them. They seemed like an unnecessary extra abstraction that resulted in a lot of extra code. Although this might be true in some cases, DTOs in the context of clean architecture also deliver a bunch of benefits:

  • Better separation of concerns: The DTOs (as their name Data Transfer Objects suggests) are only responsible for the serialization and deserialization of the objects, and domain models can handle other logic
  • Reduced coupling: The DTOs allow us to differentiate our domain models from the DTO structure, and hereby use a structure that’s more suitable for use in the app.

While I personally always tend to follow the design recommendation from clean architecture done by Martin Fowler because of its flexibility to filter and distinguish my backend response and request models with my actual entities some people question it’s use-case. Given the extra classes it introduces I think on one hand that’s a fair concern, but the flexibility it gives us almost always (except for in very small applications maybe) outweighs this issue and creates a cleaner way to write our code.

That being said, it’s important to consider this topic from both sides. For example, the article “Stop using Data Transfer Objects (DTOs)” claims that the DTOs make code harder to understand and maintain because of introducing extra mapping functions while the DTO and the Entity are likely similar, leading to two duplicated objects. If you’re 100% sure that your fields always match with the exact nullability of the parameters this could be an argument, but that’s likely not the case. Other sources online like “The vices of DTOs” and “DTO or not to DTO?” confirm the issue with code duplication but acknowledge the good practice of a DTO when data is coming from a web layer. More on the DTOs later in the Data layer section.

Data layer

By now, we’ve created the whole “template” of the application by having its domain layer. We don’t have any concrete implementation yet. This is the part we should touch upon now. I like to split this into the implementation of the repositories and the data sources.

This layer within our architecture holds a dependency on the domain layer. The implementation classes in this layer will not be explicitly be used in the other layers.

Conceptual structure of the data layer

The repositories are pretty straight forward, these should be the concrete implementations of the repositories. For example, the UserRepository example:

class UserRepositoryImpl extends UserRepository {
final String apiUrl = 'https://example.com/api/users';

@override
Future<User> getUserById(String userId) async {
...
}

@override
Future<void> saveUser(User user) async {
...
}

@override
Future<void> deleteUser(String userId) async {
...
}

@override
Future<List<User>> getAllUsers() async {
...
}
}

This repository class might use data sources such as an API (I like to go for Retrofit) or local storage (In case you might use any local database like Drift) that can be injected into our repository as dependencies. I’d personally like to separate this from the actual repository to keep the code as SOLID as possible.

Data Transfer Objects: As discussed in the previous section, we use DTOs to parse data from our data sources (Most likely API). These DTO objects should be part of our data layer.

Mapping DTO to Entity: Using DTOs and Entities requires us to write functions to map these objects. While some might integrate this in their DTOs, this in my opinion harms the Single Reponsibility Principle. We would not only let our DTOs serialize and deserialize from JSON but also let it communicate with our entities. Apart from that the API part of the app should tend to have as few as possible dependencies, which we would increase by usage entities from our domain layer in there.

@JsonSerializable()
@CopyWith()
class UserDto extends Equatable {
const UserDto({...});

@override
List<Object?> get props => [...];

factory UserDto.fromJson(Map<String, dynamic> json) =>
_$UserDtoFromJson(json);

/// Creates a UserDto from an entity class.
factory UserDto.fromEntity(User user);

/// Converts the UserDto to a User entity.
User toUser();

Map<String, dynamic> toJson() => _$UserDtoToJson(this);
}

Instead of this, in the context of Flutter I like to make use of Dart extensions on our objects and define it separately from our dto and models to do the mapping.

extension UserDtoExtension on User {
Map<String, dynamic> toDto() {
return {
'userId': userId,
'username': username,
'email': email,
'password': password,
'isActive': isActive,
'createdAt': createdAt?.toIso8601String(),
'updatedAt': updatedAt?.toIso8601String(),
};
}
}

extension UserDtoExtension on UserDto {
User toEntity() {
return User(
userId: this.userId,
username: this.username,
email: this.email,
password: this.password,
isActive: this.isActive,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
);
}
}

Since this can be quite a repetitive process, I initially created the package mapify to use the build runner to automise this process. Soon later I found out there’s actually a better tool available, that offers the functionality I was looking for called auto_mappr.

Dependency injection

What is left, is the “glue” that holds all the layers together. We came up with the following dependency rules for our architecture:

  • The presentation layer makes use of the domain layer leveraging their use cases, and abstract repositories.
  • The domain layer is independent.
  • The data layer uses the domain layer for their abstract repository contracts.

But how do we then make sure that the presentation layer uses the correct implementation of our UserRepository class? The presentation layer was not supposed to use the concrete implementation of the data layer, and should thus not have access to the class UserRepositoryImpl. There should be a way for the data layer to make the concrete implementations of the repository usable in the domain layer. This part is often forgotten in the articles that completely emerge about app architecture. To support this we are using service location (I use the get_it package for this). The explanation from their pubdev site is pretty straightforward:

If you are not familiar with the concept of Service Locators, it’s a way to decouple the interface (abstract base class) from a concrete implementation, and at the same time allows to access the concrete implementation from everywhere in your App over the interface. I can only highly recommend reading this classic article by Martin Fowler Inversion of Control Containers and the Dependency Injection pattern.

My go-to approach for having service location in the app, is creating a separate “service-location” layer, depending on both the domain and data layer.

The app calls the service location to initialise dependencies in domain and data layer

Adding an application layer (4-layers: Presentation, Application, Domain, Data)

Maybe you have seen, just like me, articles passing around where not 3-layers were discussed but instead 4 layers. Instead of the presentation layer communicating with the domain layer, the presentation layer would communicate with an application layer.

This article “What is Flutter Application Architecture” by Emmanuel Uchenna states that 4 layers should exist in the architecture of a Flutter app with the application layer being:

Establishes connections between the presentation layer and other layers through services.

Houses services capable of modifying models.

Likewise, this article on CodeWithAndrea.com describes a similar concept of using an application layer to reuse logic between multiple parts of the application. It states that logic that depends on multiple data sources or repositories should belong in the application layer.

In other words, what we would do with the use cases and services would still be the “connection-block” for the presentation layer, but would be moved to a separate application layer in this approach.

Considering the application layer as “connection-block”

As you can see, the difference between having 3-layers of 4-layers is not that big. One is not simply better than the other, but distinguishes the location within the architecture.

Simplified approach, leaving out the domain layer (2-layers: Presentation, Data)

And what about a very simple application? Is that level of abstraction always necessary? In general, I would say for most production apps this improves scalability, maintainability, and overall code-quality but it also requires us to write more code. In smaller applications, it might be good to consider if all these principles are worth the extra overhead. The Android App Architecture guide event suggests that the domain layer is an optional layer, that can be left out when working with non-complex apps.

This layer is optional because not all apps will have these requirements. You should only use it when needed-for example, to handle complexity or favor reusability.

Not sure if I’m 100% in favor of doing it like this, because complexity is likely to be introduced later, but there are definitely options to further simplify your architecture.

A simplified architectural approach

If we want to simplify our architecture, it’s good to understand where we actually introduce extra boilerplate code. Some of this was discussed already (like whether to use a DTO or an entity) but for convenience, we’ll review this again.

The need for UseCases and Services: Can we leave them out?

As we have discussed above, some apps barely contain business logic.

But.. you might think that you don’t have any business logic, and later find yourself writing business logic in the other layers. Check carefully if you are indeed not having business logic and if that’s the case for your application you can consider leaving the UseCases or Services out, and let your app directly communicate with the repositories.

Omitting DTO models

In the sections above we discussed the difference between DTOs and entities and how it would be easier to have a DTO for parsing and sending data to a backend. There is an option to leave this out but this is personally (in almost all cases) not something I would do. If you are 100% sure your entity always has the same structure as your DTO for sending and updating data you could consider leaving the DTOs out, but this will make your models more tightly coupled.

Abstract repository contracts in the Domain layer

Another thing that requires us to write some extra code is defining interfaces for the repositories. If you feel like you don’t need the benefits of having an abstract class, and you’re fine with not having the decoupling of dependencies you could consider this option. In my opinion, you would be violating the principle “Program to an interface, not an implementation” but if you’re making a small application that would likely need little to no maintenance this could be an option, and even remove an unnecessary complexity that you would otherwise have.

Conclusion

With the clean architecture approach, we strive to make our code compact and reduce dependence on our components or elements. This improves the testability and the scalability but also brings some risk of overengineering, and writing too much code where not necessary. The article aims to visualize different implementation options for clean architecture in Flutter applications and illustrates considerations to keep in mind pointing out its advantages and disadvantages.

--

--

Lennard Deurman

Freelance Senior Flutter developer | Sharing my insights and stories on tech and Flutter 🚀