DEV Community

Cover image for Clean architecture of Flutter application. Part 2 - Practice
gandronchik
gandronchik

Posted on • Updated on

Clean architecture of Flutter application. Part 2 - Practice

Hello everyone. In the previous article, we spoke about clean architecture concepts. Now it's time to implement them.

For a good understanding, we are going to develop a simple app. The app receives info about solar flares and storms from the NASA API and presents it on the view.

Create Project

First of all, you have to install flutter. You may check here the installation process.

After that let’s create a project. There are few ways to do it. The basic way is to create a project by terminal command:

flutter create sunFlare
Enter fullscreen mode Exit fullscreen mode

Now we have an example of a project. Let's modify it a bit by removing unwanted code and setting up directories.

Alt Text

So, we’ve created directories for each layer (data, domain, and presentation) and another one for the application layer which will contain application initialization and dependency injections. Also, we’ve created files app.dart (app initialization) and home.dart (main view of application). Code of these files you can see below:

main:

import 'package:flutter/material.dart';
import 'package:sunFlare/application/app.dart';

void main() {
 runApp(Application());
}
Enter fullscreen mode Exit fullscreen mode

app:

import 'package:flutter/material.dart';
import 'package:sunFlare/presentation/home.dart';

class Application extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return MaterialApp(
     title: 'Flutter Demo',
     theme: ThemeData(
       primarySwatch: Colors.blue,
       visualDensity: VisualDensity.adaptivePlatformDensity,
     ),
     home: Home(),
   );
 }
}
Enter fullscreen mode Exit fullscreen mode

home:

import 'package:flutter/material.dart';

class Home extends StatefulWidget {
 @override
 _HomeState createState() => _HomeState();
}

class _HomeState extends State<Home> {
 @override
 Widget build(BuildContext context) {
   return Scaffold();
 }
}
Enter fullscreen mode Exit fullscreen mode

The first step behind and now it’s time to develop a domain.

Domain

As you can understand from the previous article, the domain layer is the most important part of the application and it’s the first layer you should design. By the way, if you design a backend system, you just think about what entities (aggregators) should exist in the system and design it. So far as we are designing the client application, we already have some initial data (which we fetch from the backend), so we should take it into consideration when designing our domain entities. However, it doesn’t mean we have to use the same data format as we receive from the backend. Our application has its own business logic, so we have to define entities that participate in this process.

Now then, here is our domain-level models:

import 'package:meta/meta.dart';

class GeoStorm {
 final String gstId;
 final DateTime startTime;

 GeoStorm({
   @required this.gstId,
   @required this.startTime,
 });
}
Enter fullscreen mode Exit fullscreen mode
import 'package:meta/meta.dart';

class SolarFlare {
 final String flrID;
 final DateTime startTime;
 final DateTime endTime;
 final String classType;
 final String sourceLocation;

 SolarFlare({
   @required this.flrID,
   @required this.startTime,
   this.endTime,
   @required this.classType,
   @required this.sourceLocation,
 });
}
Enter fullscreen mode Exit fullscreen mode

We are going to implement a use case for collecting last solar activities (geo storm and solar flare), so let’s define the model first.

import 'package:meta/meta.dart';
import 'solar_flare.dart';
import 'geo_storm.dart';

class SolarActivities {
 final SolarFlare lastFlare;
 final GeoStorm lastStorm;

 SolarActivities({
   @required this.lastFlare,
   @required this.lastStorm,
 });
}
Enter fullscreen mode Exit fullscreen mode

Fine. Now we have business-level models. Let’s define protocols for repositories returning these models.

import 'package:meta/meta.dart';
import 'package:sunFlare/domain/entities/geo_storm.dart';

abstract class GeoStormRepo {
 Future<List<GeoStorm>> getStorms({
   @required DateTime from,
   @required DateTime to,
 });
Enter fullscreen mode Exit fullscreen mode
import 'package:meta/meta.dart';
import 'package:sunFlare/domain/entities/solar_flare.dart';

abstract class SolarFlareRepo {
 Future<List<SolarFlare>> getFlares({
   @required DateTime from,
   @required DateTime to,
 });
}
Enter fullscreen mode Exit fullscreen mode

And as I’ve promised here is a use case.

import 'package:sunFlare/domain/entities/solar_activities.dart';
import 'package:sunFlare/domain/repos/geo_storm_repo.dart';
import 'package:sunFlare/domain/repos/solar_flare_repo.dart';

class SolarActivitiesUseCase {
 final GeoStormRepo _geoStormRepo;
 final SolarFlareRepo _solarFlareRepo;

 SolarActivitiesUseCase(this._geoStormRepo, this._solarFlareRepo);

 Future<SolarActivities> getLastSolarActivities() async {
   final fromDate = DateTime.now().subtract(Duration(days: 365));
   final toDate = DateTime.now();

   final storms = await _geoStormRepo.getStorms(from: fromDate, to: toDate);
   final flares = await _solarFlareRepo.getFlares(from: fromDate, to: toDate);

   return SolarActivities(lastFlare: flares.last, lastStorm: storms.last);
 }
}
Enter fullscreen mode Exit fullscreen mode

Good. Now let me clarify what we’ve done just now. First of all, we designed the data models we needed for our use case. After that, we found out where to get those models from and defined repository protocols. Finally, we implemented a direct use case, which function is to return the last solar activities. It calls functions of repositories, extracts and collects last solar activities and returns it.

The tree of domain layer directory should looks like this:
Domain layer directory

We’ve just implemented the core of our application — the business logic. Now it's time to take care of the data layer.

Data

The first step is quite similar to the first step of the previous section - we are going to design the data models which will be fetched from the network.

import 'package:sunFlare/domain/entities/geo_storm.dart';

class GeoStormDTO {
 final String gstId;
 final DateTime startTime;
 final String link;

 GeoStormDTO.fromApi(Map<String, dynamic> map)
     : gstId = map['gstID'],
       startTime = DateTime.parse(map['startTime']),
       link = map['link'];
}
Enter fullscreen mode Exit fullscreen mode
import 'package:sunFlare/domain/entities/solar_flare.dart';

class SolarFlareDTO {
 final String flrID;
 final DateTime startTime;
 final DateTime endTime;
 final String classType;
 final String sourceLocation;
 final String link;

 SolarFlareDTO.fromApi(Map<String, dynamic> map)
     : flrID = map['flrID'],
       startTime = DateTime.parse(map['beginTime']),
       endTime = map['endTime'] != null ? DateTime.parse(map['endTime']) : null,
       classType = map['classType'],
       sourceLocation = map['sourceLocation'],
       link = map['link'];
}
Enter fullscreen mode Exit fullscreen mode

DTO means Data Transfer Object. It’s an usual name for transport layer models. The models implement constructors parsing JSON.

The next code snippet contains the implementation of NasaService, which is responsible for NASA API requests.

import 'package:dio/dio.dart';
import 'package:sunFlare/data/entities/geo_storm_dto.dart';
import 'package:sunFlare/data/entities/solar_flare_dto.dart';
import 'package:intl/intl.dart';

class NasaService {
 static const _BASE_URL = 'https://kauai.ccmc.gsfc.nasa.gov';

 final Dio _dio = Dio(
   BaseOptions(baseUrl: _BASE_URL),
 );

 Future<List<GeoStormDTO>> getGeoStorms(DateTime from, DateTime to) async {
   final response = await _dio.get(
     '/DONKI/WS/get/GST',
     queryParameters: {
       'startDate': DateFormat('yyyy-MM-dd').format(from),
       'endDate': DateFormat('yyyy-MM-dd').format(to)
     },
   );

   return (response.data as List).map((i) => GeoStormDTO.fromApi(i)).toList();
 }

 Future<List<SolarFlareDTO>> getFlares(DateTime from, DateTime to) async {
   final response = await _dio.get(
     '/DONKI/WS/get/FLR',
     queryParameters: {
       'startDate': DateFormat('yyyy-MM-dd').format(from),
       'endDate': DateFormat('yyyy-MM-dd').format(to)
     },
   );

   return (response.data as List)
       .map((i) => SolarFlareDTO.fromApi(i))
       .toList();
 }
}
Enter fullscreen mode Exit fullscreen mode

The service contains methods calling API and returning DTO objects.

Now we have to extend our DTO models. We are going to implement mappers from data layer models to domain layer models.

import 'package:sunFlare/domain/entities/geo_storm.dart';

class GeoStormDTO {
 final String gstId;
 final DateTime startTime;
 final String link;

 GeoStormDTO.fromApi(Map<String, dynamic> map)
     : gstId = map['gstID'],
       startTime = DateTime.parse(map['startTime']),
       link = map['link'];
}

extension GeoStormMapper on GeoStormDTO {
 GeoStorm toModel() {
   return GeoStorm(gstId: gstId, startTime: startTime);
 }
}
Enter fullscreen mode Exit fullscreen mode
import 'package:sunFlare/domain/entities/solar_flare.dart';

class SolarFlareDTO {
 final String flrID;
 final DateTime startTime;
 final DateTime endTime;
 final String classType;
 final String sourceLocation;
 final String link;

 SolarFlareDTO.fromApi(Map<String, dynamic> map)
     : flrID = map['flrID'],
       startTime = DateTime.parse(map['beginTime']),
       endTime =
           map['endTime'] != null ? DateTime.parse(map['endTime']) : null,
       classType = map['classType'],
       sourceLocation = map['sourceLocation'],
       link = map['link'];
}

extension SolarFlareMapper on SolarFlareDTO {
 SolarFlare toModel() {
   return SolarFlare(
       flrID: flrID,
       startTime: startTime,
       classType: classType,
       sourceLocation: sourceLocation);
 }
}
Enter fullscreen mode Exit fullscreen mode

And finally it’s time to implement repositories which protocols are in the domain layer.

import 'package:sunFlare/data/services/nasa_service.dart';
import 'package:sunFlare/domain/entities/geo_storm.dart';
import 'package:sunFlare/domain/repos/geo_storm_repo.dart';
import 'package:sunFlare/data/entities/geo_storm_dto.dart';

class GeoStormRepoImpl extends GeoStormRepo {
 final NasaService _nasaService;

 GeoStormRepoImpl(this._nasaService);

 @override
 Future<List<GeoStorm>> getStorms({DateTime from, DateTime to}) async {
   final res = await _nasaService.getGeoStorms(from, to);
   return res.map((e) => e.toModel()).toList();
 }
}
Enter fullscreen mode Exit fullscreen mode
import 'package:sunFlare/data/services/nasa_service.dart';
import 'package:sunFlare/domain/entities/solar_flare.dart';
import 'package:sunFlare/domain/repos/solar_flare_repo.dart';
import 'package:sunFlare/data/entities/solar_flare_dto.dart';

class SolarFlareRepoImpl extends SolarFlareRepo {
 final NasaService _nasaService;

 SolarFlareRepoImpl(this._nasaService);

 @override
 Future<List<SolarFlare>> getFlares({DateTime from, DateTime to}) async {
   final res = await _nasaService.getFlares(from, to);
   return res.map((e) => e.toModel()).toList();
 }
}
Enter fullscreen mode Exit fullscreen mode

The constructors of repositories accept NasaService. Methods call requests, map DTO models to domain models by mappers we have just realized, and return domain models to the domain layer.

This is how the Data directory should look like now.
Data layer directory

User Interface

Almost there. Domain and Data are behind, now it’s time for Presentation Layer.

I am not going to write a lot about presentation layer architectures in this article. There is a range of variants and if you are interested in this topic, please let me know in the comments.

As long as we decided to use the MVVM pattern for presentation layer architecture, let’s add dependencies for RX.

flutter:
 ...
 mobx: any
 flutter_mobx: any
Enter fullscreen mode Exit fullscreen mode

Also, we need to add packets to dev dependencies to generate files that allow us to use annotations @observable, @computed, @action. Just a bit of syntax sugar.

dev_dependencies:
...
 mobx_codegen: any
 build_runner: any
Enter fullscreen mode Exit fullscreen mode

We already have the view — Home. Just add the file called home_state.dart nearby. This file will contain viewModel (which in flutter is usually called state for some reason). And add the code to the file:

import 'package:mobx/mobx.dart';
import 'package:sunFlare/domain/use_cases/solar_activities_use_case.dart';
import 'package:sunFlare/domain/entities/solar_activities.dart';

part 'home_state.g.dart';

class HomeState = HomeStateBase with _$HomeState;

abstract class HomeStateBase with Store {
 HomeStateBase(this._useCase) {
   getSolarActivities();
 }

 final SolarActivitiesUseCase _useCase;

 @observable
 SolarActivities solarActivities;

 @observable
 bool isLoading = false;

 @action
 Future<void> getSolarActivities() async {
   isLoading = true;
   solarActivities = await _useCase.getLastSolarActivities();
   isLoading = false;
 }
}
Enter fullscreen mode Exit fullscreen mode

Nothing special here. We call our use case in the constructor. Also, we have two observable properties — solarActivities and isLoading. solarActivities is just the model returned by the use case. isLoading shows us if the request is in progress. The view will subscribe to these variables soon.

To generate class home_state.g.dart (to use @obsevable annotations), just call the command in terminal:

flutter packages pub run build_runner build
Enter fullscreen mode Exit fullscreen mode

Let’s come back to our view — home.dart and update it.

import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:sunFlare/presentation/home_state.dart';

class Home extends StatefulWidget {
 HomeState homeState;

 Home({Key key, @required this.homeState}) : super(key: key);

 @override
 _HomeState createState() => _HomeState();
}

class _HomeState extends State<Home> {
 Widget _body() {
   return Observer(
     builder: (_) {
       if (widget.homeState.isLoading)
         return Center(
           child: CircularProgressIndicator(),
         );
       return Column(
         crossAxisAlignment: CrossAxisAlignment.stretch,
         children: [
           Text(
               'Last Solar Flare Date: ${widget.homeState.solarActivities.lastFlare.startTime}'),
           Text(
               'Last Geo Storm Date: ${widget.homeState.solarActivities.lastStorm.startTime}'),
         ],
       );
     },
   );
 }

 @override
 Widget build(BuildContext context) {
   return Scaffold(body: SafeArea(child: _body()));
 }
}
Enter fullscreen mode Exit fullscreen mode

HomeState object in initializator as an argument and extremely simple UI. If isLoading == true, we show an activity indicator. If not, we present data of the last solar activities.

Application

Finish him! We have everything we need, now it's time to keep it together. The application layer includes dependency injections and initializations. Create directory dependencies in the application directory and add two files there.

import 'package:sunFlare/domain/repos/geo_storm_repo.dart';
import 'package:sunFlare/domain/repos/solar_flare_repo.dart';
import 'package:sunFlare/data/repos/geo_storm_repo.dart';
import 'package:sunFlare/data/repos/solar_flare_repo.dart';
import 'package:sunFlare/data/services/nasa_service.dart';

class RepoModule {
 static GeoStormRepo _geoStormRepo;
 static SolarFlareRepo _solarFlareRepo;

 static NasaService _nasaService = NasaService();

 static GeoStormRepo geoStormRepo() {
   if (_geoStormRepo == null) {
     _geoStormRepo = GeoStormRepoImpl(_nasaService);
   }
   return _geoStormRepo;
 }

 static SolarFlareRepo solarFlareRepo() {
   if (_solarFlareRepo == null) {
     _solarFlareRepo = SolarFlareRepoImpl(_nasaService);
   }
   return _solarFlareRepo;
 }
}
Enter fullscreen mode Exit fullscreen mode
import 'package:sunFlare/domain/use_cases/solar_activities_use_case.dart';
import 'package:sunFlare/presentation/home_state.dart';
import 'repo_module.dart';

class HomeModule {
 static HomeState homeState() {
   return HomeState(SolarActivitiesUseCase(
       RepoModule.geoStormRepo(), RepoModule.solarFlareRepo()));
 }
}
Enter fullscreen mode Exit fullscreen mode

Come back to app.dart and throw HomeModule.homeState() to Home constructor:

import 'package:flutter/material.dart';
import 'package:sunFlare/application/dependencies/home_module.dart';
import 'package:sunFlare/presentation/home.dart';

class Application extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return MaterialApp(
     title: 'Flutter Demo',
     theme: ThemeData(
       primarySwatch: Colors.blue,
       visualDensity: VisualDensity.adaptivePlatformDensity,
     ),
     home: Home(homeState: HomeModule.homeState()),
   );
 }
}
Enter fullscreen mode Exit fullscreen mode

Run the application and enjoy the result :)

Result

Congratulations! We’ve got it. Now you understand how to build the clean architecture of the flutter application. In case you don’t understand something, feel free to ask me in the comments.

Full code example you can find at GitHub

Discussion (5)

Collapse
ricardv profile image
Vincent RICARD

Hi there, thanks for this great post. If I may, I have some questions.

I have difficulties understanding in which layer I can define some logic that describes how my app behave, what a feature is all about and how it behaves.

To me it is hard to understand what is application logic and what is business logic. So let's take some examples.

1) Assume you want to get continuous update of something happening and you have a remote data source that provides a dart stream to do so. Where (in which layer) and when (which class, method) do you define how frequent you want this update to be ? Who should decide how frequent it is?

2) Assume you that data you received to be raw and you want it to be processed to extract some information from it in order to fill the UI.

Where do you define the rules behind such logic that will drive the process to extract the raw data?
I know the actual data processing is to take place in a data source. But its job is to process data. How to process data is not its decision, right? So where do I define the configuration of the processing?

3) Final question. Once 2 and 3 are solved, I am still facing with some issues.
You might gather 2 and 3 are specific use cases:
1 - to get the data
2 - to process it
But in order to process data (2), I need to receive it (1). In terms of flow, where does it end when I receive the data (1) ?

  • in the use case to get it ?
  • in the BLoC, without notifying the UI ?
  • in the UI, via a BLoC state, then firing a new event ?

I hope you can help. Thanks a lot in advance!

Collapse
akamaicloud profile image
Otto Akama

Thanks for the tut. Really helpful. What's next on your list? ;)

Collapse
george_andronchik profile image
gandronchik Author

Thank you for feedback) I am going to write the article about service based architecture, how to migrate from monolith and how to migrate from service based to microservices. However this article will be on Russian. Should I translate it to English as well?

Collapse
akamaicloud profile image
Otto Akama

Yes, please do write in English too.

Collapse
akamaicloud profile image
Otto Akama

Thanks a bunch for the tutorial. Would love to see a Clean Architecture implementation with GetX using AppWriter for backend for a concept like WhatsApp or Instagram. ;)