Clean Architecture with states_rebuilder has Never Been Cleaner

MELLATI Fatah
Flutter Community
Published in
19 min readJan 16, 2020

No matter how good your programming skills are and how many design patterns you master, for the enterprise-wide application, you have to architect your application in a way that makes it readable, maintainable, testable, and extensible. In this article, I will show you how to implement The Clean Architecture by taking advantage of the KISSness of states_rebuilder.

What is an Architecture and why?

َ An architecture is a way to factor your code into functional segments (inside folders and files) and keep clean boundaries between them so that it is easy to understand, easy to refer yourself inside and easy to modify as the project grows.

First scenario: A real-life example of no architecture:
Imagine that your home library has about ten books. No matter how you organize it, you can easily find the book you want the time you want.

Second scenario: A real-life example of an architecture:
Now, imagine you work in a university library with thousands of books. Then, you have to follow a convention of book arrangement. You may classify the books by subjects, language, author name or alphabetically. No matter your philosophy of the arrangement, you have to stick to one architecture so that when a student needs a particular book, you know exactly where to find it with your eyes closed.

In the software development realm:

You encounter the first scenario in tutorials and demo apps. The tutor aims to explain a particular aspect of the programming language. He can, without loss of clarity, put all the code inside one file. In demo apps, the app is in general small without any concern for future change. In these cases, wasting time following a particular architecture and organizing the code in folders and files will be overkilling for the tutor and confusion for the reader.

The second scenario is the situation of real-world enterprise apps. Real apps request for maintainability, extensibility, testability. To fulfill this request, the app code must be clean. The cornerstone of clean code is clean and effective architecture.

the benefit of a good architecture
1- Maintainability: no messed code, no cross-referencing between segments.
2- Scalability: You can add new functionality more easily
3- Testability: You can mock dependencies.

All the above three features are tightly related to change. No matter how well you design an application, over time an application must change. It must change to fix bugs (maintainability). It must change to grow (Scalability). But what about testability and how it is linked to change? Look at this sample of code:

int counter=0;
void increment(){
counter++;
}

It is easy to write a test to the increment() method to verify that the counter variable is incremented by one after executing the method. But what a big deal! Isn’t this ridiculous and a waste of time! No matter how complex the method is, one can verify that it works as expected by other means than unit testing. Why testing? the answer is the change. Let’s say that the requirements changed and you edited the increment() method to be :

int counter=0;
void increment(){
incrementerService(counter);
}

Now after this change, by running the test again you can track any unwanted behavior following the change (perhaps incrementerService(counter) return the wrong result). If you don’t have any test and the change causes bugs in your program, it will be difficult to track where did you get it wrong. Testing is an early detection utility of bugs after code refactoring.

Testing your code has another important benefit. If you follow good design patterns and clean architecture, you make your code testable, on the other flip side the easiness of writing tests witnesses the good quality of your code and in the same sense trouble writing tests is a good sign of the bad quality of your code. If you want to take your skills to the next level, shift the gears and consider Test Driven Development TDD. In TDD, you write the test before even writing a single line of code. It seems a little strange to test inexistent code and one can not resist coding first especially at the early stage of the app when you are not clear about what exactly you want. Just look at this approach the same way as setting your goals in project management before starting your project. If your vision is clear, your goals will be clear and the reverse is true.

In case of bug fixing or new feature adding, TDD is a must. Before fixing a bug try to write a test that reproduces the bug; if succeded you find yourself understanding the origin of the bug. And as has been said, understanding the problem is half of the solution. After fixing the bug, and by running all new and old tests you are one hundred percent sure that you fixed the problem without any unwanted side effect.

If you are adding a new requested feature, your intention is so clear that writing a test that expects the requested feature is very easy, and by looping between Red, Green, and refactor technique of TDD you can come out with the best solution. Again after changing the code run all the tests to check the validity fo your code.

Do not underestimate tests and be sure that the time you spend writing tests will save your life in the future.

The clean layered architecture

The architecture consists of something like onion layers, the innermost layer is the domain layer, the middle layer is the service layer and the outer layer consists of three parts: the user interface UI, data_source and infrastructure. In coding, each of the parts of the architecture is represented using a folder. So the topmost folders are:

Arrows in the picture above indicate code dependencies which can only point inwards. Nothing in an inner circle can know anything at all about something in an outer circle. In particular, the name of something declared in an outer circle must not be mentioned by the code in the inner circle.

1- Domain (or Domain Model):

It is the core of your app’s business logic, It contains all the logic pertaining to the business domain. By the way, if you want to switch gears again and take your programming skills to the next level, don’t waste more time and consider Domain-Driven Design (DDD). In the context of DDD, experts in the problem domain work with the development team to get out with what is called the analysis model. The implementation of the analysis model is the domain model and this is what this folder should contain. If you are working on an app for yourself, you are the domain expert and the developer at the same time, so you have to think with your self to get out with mental models of the business using a common language that anyone of the same business can understand. This mental model free from any technical details is the analysis model. The domain model is the traduction of the analysis model in terms of code using the common language of the business (ubiquitous language).

The domain folder contains at least four subfolders.

entities folder: Entities are mutable objects uniquely identified by their IDs. In most cases, entities are the in-memory representation of the data that was retrieved from the persistence store (data_source). An entity can have methods it must encapsulate all the domain logic it controls. Among the logic that should an entity have is the validation logic, which will be executed just before persisting the entity state to ensure that has the right state.

entities must be validated just before persistance

value objects folder: Value objects are immutable objects which have value equality and self-validation but no IDs. Value objects come to the rescue if you have many primitive fields in your entities. In this case, it may be possible to move some of them with the associated behavior and validation to a new value object classes. Validation is performed at the time of construction so that value objects are either instantiated in the right state or an error is thrown.

Value objects are such a good concept that you do keep refactoring your code to get more of them.

value objects must be validated the time of constraction

NB: This is by no means a comprehensive tutorial on TDD or DDD. For more details on both concepts see the following reference:

1- Test-Driven development: link
2- Domain-Driven Design: link

exception folder: This folder should contain all exception classes that entities and value objects are expected to throw.

common folder: contains common helper utilities shared inside the domain (constants, enumerations, util classes …).

This is not an exhaustive list of folders, you can add any number of folders that will help you organize your domain logic.

2- Service layer:

The second layer is named service for it holds the application service layer. The application service layer is the client of the domain layer. It hides (or in technical terms “abstracts”) the details of the domain and exposes a programming interface (API) to represent the business use cases and behavior of the application. it is all about coordination and orchestration through delegation to domain and external services. The service layer must inversely depend on external services (Infrastructure and data_source) and this is done by defining a set of interfaces that should any external service implement. The set of interfaces defined is placed in the interfaces folder.

The service folder is made up of at least three subfolders.

interfaces folder: contains interfaces that should any external service implement.

exception folder: This folder should contain all exception classes that the services classes and external service are expected to throw. External services in the infrastructure and data_source layer should not throw their own defined exceptions, but their errors should be caught and custom exceptions defined in this folder should be thrown instead.

common folder: contains common helper utilities shared inside the service layer (constants, enumerations, util classes …).

3- Infrastructure:

Applications may rely on external resources, i.e; getting persistent data, fetching web, make a call or send a message or email, using 3rd-party libraries. In this case, the code using API of these third-party libraries is contained in the infrastructure folder (infrastructure layer). The service layer must communicate with the infrastructure layer through interfaces and infrastructure must implement interfaces defined in the service layer. This decoupling is mandatory to ensure our app does not depend on any 3rd-party library.

It is here the right place to put all the logic that deals with communication with platform-specific services such as sending emails, checking network connection, using GPS service, camera …

4- data_source:

Data_source is just a detail like the infrastructure, but it is such an important detail that gives it the merit of having its own folder. data_source is responsible for orchestrating the fetching and the persistence of data from one or more sources (API calls to a server, a local database, or both). It is here where the row data is converted to entities and value_object.

5- UI folder:

The UI is concerned only with the visual aspects of the application. In flutter, UI views have a top-level widget that contains other widgets, which contain other widgets and so on until you get to the leaf widget.

The UI folder contains at least four subfolders.

pages folder: Since a UI is a collection of pages that the user can navigate between them, the first subfolder of the UI folder is the pages directory. Typically, you dedicate a folder for each page even if it has one file. The advantage of dedicating one folder for a page is to increase readability.

widgets: It contains small reusable widgets that should be independent of the application. They can be used in other applications with a simple copy and paste and with little change if necessary. For example, this folder may contain your best-designed FAB, your super attractive CircularProgressBar, your awsome list tile … etc. By the time you constitute your custom widget library and you know where to find them if you need them in other apps.

Even if you are using widgets from external libraries, it is more convenient to adapt their interface and create your own and use yours throughout the user interface (this is a practical case of the adapter model). Your advantage is to dissociate your user interface from the concrete implementation of external libraries, and in the future, if you change your mind and decide to use another library, you will change one place: here inside the widgets folder.

exception folder: Here you handle exceptions thrown from the domain and service layer.

common folder: contains common helper utilities used in the UI layer (constant, enumeration, style, util classes …).

A big enterprise app may have many areas of interest. By area of interest, I mean an ensemble of related tasks and use cases that share the same knowledge to achieve common goals. In this case, each area of interest can have its own architecture as described above. In domain-driven design, these areas of interest are called bounded context.

The architecture in practice

As an example, I choose the demo app used by Dane Mackier in his tutorials. The app has three screens. Login, Home and PostDetails.It communicates with the JSONPlaceholder API, gets a User profile from the login using the ID entered. Fetches and shows the Posts on the home view and shows post details with an additional fetch to show the comments.

I. Domain

I.1 entities :

There are three entities: User, Post, and Comment.

file:lib/domain/entities/user.dart

class User {
int id;
String name;
String username;
//Typically called form service layer to create a new user
User({this.id, this.name, this.username});
//Typically called from the data_source layer after getting data from an external source.
User.fromJson(Map<String, dynamic> json) {
id = json['id'];
name = json['name'];
username = json['username'];
}
//Typically called from service or data_source layer just before persisting data.
//Here is the appropriate place to check data validity before persistence.
Map<String, dynamic> toJson() {
//validate
_validation();

final Map<String, dynamic> data = new Map<String, dynamic>();
data['id'] = this.id;
data['name'] = this.name;
data['username'] = this.username;
return data;
}
_validation() {
if (name == null) {
//NullNameException is defined in the exception folder of the domain
throw NullNameException();
}
}
}

file:lib/domain/entities/post.dart

class Post {
int id;
int userId;
String title;
String body;
int likes;
Post({this.id, this.userId, this.title, this.body, this.likes});Post.fromJson(Map<String, dynamic> json) {
userId = json['userId'];
id = json['id'];
title = json['title'];
body = json['body'];
likes = 0;
}
Map<String, dynamic> toJson() {
_validation();
final Map<String, dynamic> data = new Map<String, dynamic>();
data['userId'] = this.userId;
data['id'] = this.id;
data['title'] = this.title;
data['body'] = this.body;
data['likes'] = this.likes;
return data;
}
//Entities should contain all the logic it controls
incrementLikes() {
likes++;
}
_validation() {
if (userId == null) {
throw ValidationException('User id can not be undefined');
}
}
}

file:lib/domain/entities/comment.dart

class Comment {
int id;
int postId;
String name;
//Email is a value object
Email email;

String body;
Comment({this.id, this.postId, this.name, this.email, this.body});Comment.fromJson(Map<String, dynamic> json) {
postId = json['postId'];
id = json['id'];
name = json['name'];
email = Email(json['email']);
body = json['body'];
}
Map<String, dynamic> toJson() {
_validation();
final Map<String, dynamic> data = new Map<String, dynamic>();
data['postId'] = this.postId;
data['id'] = this.id;
data['name'] = this.name;
data['email'] = this.email.email;
data['body'] = this.body;
return data;
}
_validation() {
if (postId == null) {
throw ValidationException('No post is associated with this comment');
}
}
}

I.2 value_objects:

For a demonstration purposes, I refactor the email field to be value_object class.

file:lib/domain/value_objects/email.dart

//value objects are immutable
@immutable
class Email {
Email(this.email) {
if (!email.contains('@')) {
//Validation at the time of construction
throw ValidationException('Your email must contain "@"');
}

}
final String email;
}

Entities are validated just before persistence whereas value_objects are validated the time of instantiation

I.3 exceptions:

file:lib/domain/exceptions/validation_exception.dart

class ValidationException extends Error {
ValidationException(this.message);
final String message;
}

The domain layer is complete. It is the most independent part of the application and reflects the enterprise-wide application logic.

II. service

II.1 interfaces:

file:lib/service/interfaces/i_api.dart

abstract class IApi {
Future<User> getUserProfile(int userId);
Future<List<Post>> getPostsForUser(int userId);
Future<List<Comment>> getCommentsForPost(int postId);
}

The data_source layer part must implement this interface.

II.2 exceptions:

file:lib/exceptions/input_exception.dart

class NotNumberException extends Error {
final message = 'The entered value is not a number';
}

file:lib/exceptions/fetch_exception.dart

class NetworkErrorException extends Error {
final message = 'A NetWork problem';
}
class UserNotFoundException extends Error {
UserNotFoundException(this._userID);
final int _userID;
String get message => 'No user find with this number $_userID';
}
class PostNotFoundException extends Error {
PostNotFoundException(this._userID);
final int _userID;
String get message => 'No post fount of user with id: $_userID';
}
class CommentNotFoundException extends Error {
CommentNotFoundException(this._postID);
final int _postID;
String get message => 'No comment fount of post with id: $_postID';
}

II.3 common:

the common folder will contain one helper file to parser input string to integer and validate it.

file:lib/common/input_parser.dart

class InputParser {
static int parse(String userIdText) {
var userId = int.tryParse(userIdText);
if (userId == null) {
throw NotNumberException();
}
return userId;
}
}

Typically and not necessary, for each entity, there is a corresponding service class that is responsible for its instantiation and its persistence by delegating to external services. These service classes are also responsible for processing the entities and value objects so that they are suitable for use cases.

In our app, we have three service classes: AuthenticationService, PostsService, CommentsService.

In the root of the service class:

file:lib/service/authentication_service.dart

class AuthenticationService {
AuthenticationService({IApi api}) : _api = api;
IApi _api;
User _fetchedUser;
User get user => _fetchedUser;
void login(String userIdText) async {
//Delegate the input parsing and validation
var userId = InputParser.parse(userIdText);
_fetchedUser = await _api.getUserProfile(userId);//// TODO1 : throw unhandled exception////TODO2: Instantiate a value object in a bad state.////TODO3: try to persist an entity is bad state.}
}

AuthenticationService is responsible for fetching for user form input id and caching the obtained user in memory (the getter user).

There are three TODOs that we will fill out later at the end of the article to see how the application behaves when throwing unhandled exceptions, instantiating a value object in a bad state and finally the persisting of an entity in a bad state.

file:lib/service/posts_service.dart

class PostsService {
PostsService({IApi api}) : _api = api;
IApi _api;
List<Post> _posts;
List<Post> get posts => _posts;
void getPostsForUser(int userId) async {
_posts = await _api.getPostsForUser(userId);
}
//Encapsulation of the logic of getting post likes.
int getPostLikes(postId) {
return _posts.firstWhere((post) => post.id == postId).likes;
}
//Encapsulation of the logic of incrementing the like of a post.
void incrementLikes(int postId) {
_posts.firstWhere((post) => post.id == postId).incrementLikes();
}
}

file:lib/service/comments_service.dart

class CommentsService {
IApi _api;
CommentsService({IApi api}) : _api = api;
List<Comment> _comments;
List<Comment> get comments => _comments;
Future<void> fetchComments(int postId) async {
_comments = await _api.getCommentsForPost(postId);
}
}

And we’ve done with the service layer and all the business logic of our app.

III. data_source

file:lib/data_source/api.dart

class Api implements IApi {
static const endpoint = 'https://jsonplaceholder.typicode.com';
var client = new http.Client();Future<User> getUserProfile(int userId) async {
var response;
try {
response = await client.get('$endpoint/users/$userId');
} catch (e) {
//Handle network error
//It must throw custom errors classes defined in the service layer
throw NetworkErrorException();
}
//Handle not found page
if (response.statusCode == 404) {
throw UserNotFoundException(userId);
}
return User.fromJson(json.decode(response.body));
}
Future<List<Post>> getPostsForUser(int userId) async {
var posts = List<Post>();
var response;
try {
response = await client.get('$endpoint/posts?userId=$userId');
} catch (e) {
throw NetworkErrorException();
}
if (response.statusCode == 404) {
throw PostNotFoundException(userId);
}
var parsed = json.decode(response.body) as List<dynamic>;for (var post in parsed) {
posts.add(Post.fromJson(post));
}
return posts;
}
Future<List<Comment>> getCommentsForPost(int postId) async {
var comments = List<Comment>();
var response;
try {
response = await client.get('$endpoint/comments?postId=$postId');
} catch (e) {
throw NetworkErrorException();
}
if (response.statusCode == 404) {
throw CommentNotFoundException(postId);
}
var parsed = json.decode(response.body) as List<dynamic>;for (var comment in parsed) {
comments.add(Comment.fromJson(comment));
}
return comments;
}
}

The Api class implements the IApi class form the interface folder of the service layer. Errors must be catches and custom error defined in the service layer must be thrown instead.

IV. infrastructure

Not needed in this example. It can have external libraries that deal with platform-specific tasks such as check for network connection, use GPS, use email service ….

V. User Interface UI

As you saw, the domain, service, data_source, and infrastructure layers are constituted with plain old dart classes and have nothing to do with states_rebuilder.

It is in the UI that we need to use states_rebuilder and it is important to read the last articles on states_rebuilder and watch ResoCoder tutorial to be able to follow with me the next couple of lines:

file:lib/main.dart

Because we need the authenticated user to be available throughout all the widget tree we inject it before the MaterialApp widget.

The first page is the login page:

file:lib/pages/login_page/login_page.dart

I would like to emphasize that error handling is centralized in ErrorHandler class which will be in the common folder of the UI layer.

If the user is successfully authenticated we navigate to the home page

file:lib/pages/home_page/home_page.dart

From this moment, the user is available and to get it we can simply by using Injector.get, because we no longer need it to be reactive.

final user = Injector.get<AuthenticationService>().user;

With states_rebuilder, you always have the choice of getting an injected model using Injector.get and Injector.getAsReactive. Unless you need reactivity, prefer Injector.get

The postsService class is injected inside the homePage widget. If you navigate to the next page using pushReplacementNamed (or pushReplacement) the homePage will be disposed and the postsService will be unregistered. If you want to keep using the injected instance of postsService on the next page, you reinject it with the help of the reinject parameter of the Injector:

for our case, in the lib/router.dart file replace

case ‘post’:
var post = settings.arguments as Post;
return MaterialPageRoute(builder: (_) => PostPage(post: post));

by

case ‘post’:
var post = settings.arguments as Post;
return MaterialPageRoute(builder: (_) => Injector(
reinject: [Injector.getAsReactive<PostsService>()],
builder: (context) {
return PostPage(post: post));
},

);

file:lib/pages/post_page/post_page.dart (entry file)

We put the like_button.dart and comments.dart file inside the post_page folder and not in the widgets folder because they depend on the service and domain layer and are not used by other pages. For me, the widgets folder is reserved for small, reusable and app independent widgets.

file:lib/pages/post_page/like_button.dart

file:lib/pages/post_page/comments.dart

It remains to us the ErrorHandler class.

file:lib/pages/exceptions/error_handler.dart

class ErrorHandler {
//go through all custom errors and return the corresponding error message
static String errorMessage(dynamic error) {
if (error == null) {
return null;
}
if (error is ValidationException) {
return error.message;
}
if (error is NotNumberException) {
return error.message;
}
if (error is NetworkErrorException) {
return error.message;
}
if (error is UserNotFoundException) {
return error.message;
}
if (error is PostNotFoundException) {
return error.message;
}
if (error is CommentNotFoundException) {
return error.message;
}
// throw unexpected error.
throw error;
}
//Display an AlertDialog with the error message
static void showErrorDialog(BuildContext context, dynamic error) {
if (error == null) {
return;
}
showDialog(
context: context,
builder: (context) {
return AlertDialog(
content: Text(errorMessage(error)),
);
},
);
}
}

The error handling logic is centralized in ErrorHandler.

  • Whenever we want to display an error message we use ErrorHandler.errorMessage(error).
LoginHeader(
ErrorHandler.errorMessage(authServiceRM.error),
controller: controller,
),
  • Whenever we want to display a side effect AlertDialog we use ErrorHandler.errorMessage(error) with the onError parameter of setState:
postsServiceRM.setState(
(state) => state.getPostsForUser(user.id),
onError: ErrorHandler.showErrorDialog,
);

That’s it; pure dart business logic classes, simple UI widgets, and many other features about managing the stats, DI and app lifecycle not mentioned here you can read about it in the official documentation.

Some changes:

Good app architecture makes changes easier with fewer side effects.

If you notice in the GIF, if I enter a number greater than 10, the app fetches for it and get a 404 error, and displays an error saying that no user found with the entered number.

A good practice is to catch an error as early as it appears. For this good reason, I have to validate input and prevent fetching non-existing numbers.

To do so, with this architecture we simply change the following files:

file:lib/common/input_parser.dar

throw NotInRangeException if userID is less than one or greater than 10

class InputParser {
static int parse(String userIdText) {
var userId = int.tryParse(userIdText);
if (userId == null) {
throw NotNumberException();
}

if (userId < 1 || userId > 10) {
throw NotInRangeException();
}
return userId;
}
}

file:lib/exceptions/input_exception.dart

Add NotInRangeException exceptions.

class NotNumberException extends Error {
final message = 'The entered value is not a number';
}
class NotInRangeException extends Error {
final message = 'Tne entered value is not between 1 and 10';
}

and handle it:

file:lib/pages/exceptions/error_handler.dart

class ErrorHandler {
static String errorMessage(dynamic error) {
..
if (error is NotInRangeException) {
return error.message;
}

..
}
}

That all to get what you want:

What if I want to show a snackBar containing the error.

With states_rebuilder, error handling and side effect management are a joy.

At the end of file:lib/pages/exceptions/error_handler.dart, we add:

class ErrorHandler {
static String errorMessage(dynamic error) {
..
//Display an snackBar with the error message
static void showSnackBar(BuildContext context, dynamic error) {
if (error == null) {
return;
}
Scaffold.of(context).showSnackBar(
SnackBar(
content: Text('${errorMessage(error)}'),
),
);
}

..
}
}

in the file:lib/pages/login_page/login_page.dart and in onPressed callback we add:

onPressed: () {authServiceRM.setState(
(state) => state.login(controller.text),
onError: ErrorHandler.showSnackBar,
onData: (context, authServiceRM) {
Navigator.pushNamed(context, '/');
},
);
},

And that all to get what we want:

Some TODOS:

In file:lib/service/authentication_service.dart, we left with some TODOS to check validation functionallity of states_rebuilder.

class AuthenticationService {
AuthenticationService({IApi api}) : _api = api;
IApi _api;
User _fetchedUser;
User get user => _fetchedUser;
void login(String userIdText) async {
//Delegate the input parsing and validation
var userId = InputParser.parse(userIdText);
_fetchedUser = await _api.getUserProfile(userId);//// TODO1 : throw unhandled exception////TODO2: Instantiate a value object in a bad state.////TODO3: try to persist an entity is bad state.}
}
  • The first TODO is to throw an unexpected exception:
//// TODO1 : throw unhandled exception
throw Exception();

When you click the login button the app will crash.

  • The second TODO is to try instantiating the Email value object in a non-valid state:
////TODO2: Instantiate a value object in a bad state.
Comment(
id: 1,
email: Email('email.com'), //Bad email
name: 'Joe',
body: 'comment',
postId: 2,
);

When you click the login button the red validation message will appear.

  • The last TODO is to try to persist an entity is a bad state.
////TODO3: try to persist an entity is bad state.
Comment(
id: 1,
email: Email('email@m.com'), //good email
name: 'Joe',
body: 'comment',
postId: 2,
)
..postId = null// bad state
..toJson();

When you click the login button the validation message telling you that you can not persist a comment with not postID.

You can find the code of this article and many other tutorials here.

Now states_rebuilder is part of the TodoMVC for Flutter samples. Check flutter_architecture_samples and see how the same app is implemented by many state management solutions.

Please try the library, star ⭐️ it in the GitHub, give it a thumb up 👍 in the pub.dev and clap 👏 and share 👭 this article.

--

--