Explaining Clean Architecture in Flutter [Part 2: Practice]

Written by andronchik | Published 2021/10/05
Tech Story Tags: flutter | architecture | clean-architecture | software-architecture | flutter-for-mobile-app | mobile-programming-flutter | clean-architecture-flutter | dart

TLDR The app receives info about solar flares and storms from the NASA API and presents it on the view. We are going to develop a simple app. The app is designed by flutter and is based on a simple domain. The domain layer is the most important part of the application and it’s the first layer you should design. The first step behind is to create a domain and then design the application for the client application. Flutter is a free app that can be downloaded from iOS and Android.via the TL;DR App

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

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

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());
}

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(),
   );
 }
}

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();
 }
}

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,
 });
}

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,
 });
}

We are going to implement a use case for collecting the 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,
 });
}

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,
 });

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,
 });
}

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);
 }
}

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 them.

The tree of domain layer directory should looks like this:

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'];
}

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'];
}

DTO means Data Transfer Object. It’s a 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();
 }
}

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);
 }
}

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);
 }
}

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();
 }
}

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();
 }
}

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.

User Interface


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.

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

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

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

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;
 }
}

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

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()));
 }
}

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;
 }
}

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()));
 }
}

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()),
   );
 }
}

Run the application and enjoy the 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.


Written by andronchik | full-stack engineer and architect
Published by HackerNoon on 2021/10/05