Any of us who have worked longer on Flutter projects know how important it is that the project has the proper structure and architecture – that it’s scalable and testable. How can you achieve these goals? Why should you use clean architecture in your projects? How does it affect developers’ work, as well as the performance and efficiency of the application? You’ll learn all this in this post. Let’s start with the beginning!

Flutter x Clean Architecture - power up your app's efficiency

Why do you need architecture in a project?

Every Flutter project should contain our views and widgets. It should also have a place where our business logic is called and calls to APIs or backends are called. The answer to these needs is a well-chosen architecture.

I will base this blog post on the implementation within the app, which I called TimeFix. TimeFix is an application that supports small businesses. It allows you to track the time of orders, add such charges, and add costs – you have an overview of the small industry, like electricians or other specialists.

What does a well-chosen Flutter architecture give?

  • Scalability.
  • Testability.
  • Stability.
  • Openness to expansion.

Breaking the code into individual modules affects testability because we can test each module and catch bugs. Moreover, the architecture improves project stability, and by choosing good tools, these projects run more efficiently and crash less. And, of course, openness to expansion allows us to add modules more easily and modify existing modules. If we have a good architecture in the project, it’s much easier to make changes.

Clean architecture in modern Flutter design

Coming now to the clean architecture itself, it’s broken into three layers: presentation, domain, and data. I will briefly discuss each of them.

In the presentation layer, we have views – what the user sees. We have our widgets and state management (it can be bloc as here, but it can just as well be provider, mobx, or riverpod).

Moving to the next layer, the domain layer, we mainly have our repository abstractions and data source abstractions.

Meanwhile, we have calls to local data sources or APIs in the data layer

The folder structure in the Flutter project and key concepts

Having already entered the project itself as if we opened it in the IDE – here is the Flutter file structure. As you can see, here we have a system that precisely reflects the clean architecture discussed above – we have a presentation, domain and presentation folder. We also see others, but it’s not essential at this point. Those who are familiar with the project structure in Flutter will know very well what it’s all about.

The presentation layer

In the presentation folder, we can distinguish three folders:

  • common_widgets – the most usable widgets,
  • pages – our Flutter UI screens,
  • utils – utilities for this layer.

In each screen folder, we have the interface file itself, the assigned widgets, and state management (in my case, cubit). 

What role does state management play in the presentation layer?

  • Effectively separates our logic from the application views.
  • Improves the testability of our code.
  • Replaces the controllers we know from clean architecture.

If you need to write this logic in your view, I recommend the flutter_hooks package. It is very effective and efficient for this job.

The domain layer

In the domain folder, we can find the following:

  • Abstract classes of our repositories and data source,
  • Entities – the models used in the views,
  • Use cases – more on this below,
  • Utils – e.g. extension for a given type.

UseCase as a bridge between layers

At one time, I didn’t know exactly what use cases were and why they have such a good effect on a project. However, it’s nothing more than a bridge between layers. It’s a single call to business logic

@injectable 
class AddProjectUseCase {
	const AddProjectUseCase(this._repo);
	final ParseRepository _repo;

	Future<Either<Failure, AddProjectResponse>> call(AddOrEditProjectRequest request) => _repo.addProject(request);
}

Here, we have AddProjectUseCase, which indicates that it is adding an order to the database. It accepts a request to itself and then triggers our method in the repo.

How does using UseCases improve the quality of our code?

  • Use cases improve the organisation of business logic into individual calls.
  • We can quickly test them.
  • We create a connection between the domain layer and the data layer and simultaneously separate these two layers according to the clean architecture.
  • We can reuse each created UseCase in different cubits depending on where we need the specific logic that the UseCase performs.
  • By dividing the logic into UseCases, we can easily modify them or add new ones later.
Future<void> loginUser({
	required String username,
	required String password,
}) async {
	emit(const LoginPageState.loading());
	final response = await_loginUserUseCase(
		LoginUserRequest(
			username: username,
			password: password,
		),
	);
	response.fold(
	(l) => emit(LoginPageState.error(l)),
	(authData) async {
		final tokenResponse = await _saveTokenUseCase(SaveTokenRequest(token: authData.sessionToken)); 
		tokenResponse.fold(
			(l) => emit(LoginPageState.error(l)),
			(authData) async {
				final tokenResponse = await_saveTokenUseCase(SaveTokenRequest(token: authData.sessionToken));
				tokenResponse.fold(
					(l) => emit(LoginPageState.error(l)),
					(r) async {
						final saveUserIdResponse = await _saveUserIdUseCase(SaveUserIdRequest(userId: authData.objectId));
						saveUserIdResponse.fold(
							(l) => emit(LoginPageState.error(l));
							(r) = emit(const LoginPageState.success()),
						);
					};
				);
			},
		);
	}

Use cases also help us keep things organized. This example shows the loginUser method – we have as many as three use case calls, but as you can see, each use case depends on the data from each previous use case. By using use cases, we have cleanliness, we can see where the use case is called and where the response is received.

A practical example of a flow call

We have discussed the first two layers, so let’s move on to the practical call of this flow, going through them. 

@injectable
class AddProjectPageCubit extends Cubit<AddProjectPageState> {
	AddProjectPageCubit(this._addProjectUseCase) : super(const AddProjectPageState.initial());
	
	final AddProjectUseCase _addProjectUseCase;
	
	Future<void> addProject({required AddOrEditProjectRequest request}) async {
		emit(const AddProjectPageState.loading());
		final result = await _addProjectUseCase(request);
		request.fold(
			(l) => emit(addProjectPageState.error(l)),
			(r) => emit(const AddProjectPageState.success()),
		);
	}
}

In my application, a user can add an order by filling out a form. After that, he clicks a button and is left by a cubit assigned to this screen, a use case. The cubit also starts loading. Well, and is launched asynchronously through our AddProjectUseCase.

@injectable
class AddProjectUseCase {
	const AddProjectUseCase(this._repo);
	final ParseRepository _repo;
	
	Future<Either<Failure, AddProjectResponse>> call(AddOrEditProjectRequest request) => _repo.addProject(request);
}

UseCase then triggers a method from the repo and sends our request to it.

@overrideFuture<Either<Failure, AddProjectResponse>> addProject(AddOrEditProjectRequest request) async {
	try {
		final response = await _parseDataSource.addProject(request.toDto);
		return Right(response.toEntitiy);
	} catch (e) {
		final failure = await _handleFailure(e);
		return Left(failure);
	}
}

And then, the method from the repo communicates with our data layer and is wrapped with a try-catch to catch a possible error in our call. It then sends our request to the data layer, mapping to a DTO object.

The data layer

This layer is mainly used for calling APIs. We use retrofit for Dart here, but you can just as well use regular HTTP dependency. In this layer, we have:

  • folder on the data source (classes with calls to local data sources or APIs),
  • DTOs (about which more below),
  • interceptors (helpers for retrofits in this case),
  • environment files.
@override
@POST(NetworkingEndpoints.addProject)
Future<addProjectResponseDto> addProject(@Body() AddOrEditProjectRequestDto request);

In this case, retrofit calls the addProject method. These methods are named the same to keep things clean and tidy. Our retrofit calls the method, the project is added to the base, and we get the corresponding status.

Entity partitioning and DTOs – what does it prevent?

When fetching data from the web, we will most often get a response in the form of a JSON object, and directly operating on JSON files can lead to errors.

The most common problem arises when you want to extract data from a JSON-type object using the keys of its fields. This is where the DTO data model comes to the rescue.

We have another list from the app – this is a list of tracked times. What do we get from our backend?

@freezed
class TimeEntryDto with _$TimeEntryDto {
	const factory TimeEntryDto({
		required String title,
		required String date,
		required String startTime,
		required String endTime,
		required String totalTime,
	}) = _TimeEntryDto;
	
	factory TimeEntryDto.fromJson(Map<String, dynamic> json) => _$TimeEntryDtoFromJson(json);
}

extension TimeEntryDtoExtension on TimeEntryDto {
	TimeEntry get toEntity => TimeEntry(
		title: title,
		date: date,
		startTime: startTime,
		endTime: endTime,
		totalTime: totalTime,
	);
}

An approach without using the DTO concept

@freezed
class TimeEntry with _$TimeEntry {
	const factory TimeEntry({
		required String title,
		required String date,
		required String startTime,
		required String endTime,
		required String totalTime,
	}) = _TimeEntry;
}

extension TimeEntryExtension on TimeEntry {
	TimeEntryDto get toDto -> TimeEntryDto(
		title: title,
		date: date,
		startTime: startTime,
		endTime: endTime,
		totalTime: totalTime,
	);
}

We could operate on fields, but it is elementary to make a typo, which would sprinkle the code. The answer is to create a DTO even before mapping to an entity to perform data serialisation dynamically, then I add an extension so the DTO converts to an entity.

How does Flutter Clean Architecture affect developers’ work?

  • UseCase partitioning eases debugging and catching problems with individual business logic calls.
  • We keep the code organised by dividing the project into layers.
  • It improves the testability and scalability of our Dart code.
  • By breaking logic into individual Cases, we can easily reuse it, for example, on views where we need the same logic as on another screen.
  • We reduce the occurrence of errors.

When shouldn’t you use Clean Architecture in Flutter?

  • First, working on a small project that does not support various API calls and lacks advanced business logic.
  • For each type of project, try to use the appropriate tools tailored to its needs.

Contact

Do you want to discuss how to implement the right architecture in your Flutter application?

Talk to us!

Improve your Flutter applications with Clean Architecture

In conclusion, using clean architecture in Flutter apps can significantly improve the application’s scalability, testability, and stability. The clean architecture is broken into three layers: presentation, domain, and data, each of which serves a specific purpose. By dividing the code into individual modules and using tools such as state management and use cases, Flutter developers can create more efficient, stable, and expandable applications. If you’re looking for more best practices on building successful Flutter apps, feel free to download our handbook.