Hacker News App which is using the following API
The main purpose of this project is to consolidate all ideas in the Flutter Community about architectures and create an universal architecture for the Flutter App.
Flutter is easy to learn and develop with, but when project is getting bigger it's becoming very verbose and hard to maintain.
It's very easy to make bad structured and unmaintainable applications.
Solution is obvious - create set of rules that flutter developers will follow while creating their apps.
If we have already defined architecture and rules it's very easy to onboard new developers or switch between different projects since almost all patterns are the same. The only difference is small implementation details but the skeleton is still the same.
My proposal is SMUS approach but I'm open for other suggestions. New ideas and contributions are highly welcomed.
If you have any questions just create an issue and we'll discuss it.
It's just a pure model class.
Model should not depend on any other layer except for itself.
All other layers are dependent on Model.
All models must be immutable to avoid some unexpected change of the value in the future (Rule #1).
When you start to design your app the first thing you need to do is to define the business rules aka models.
Sooner or later you will face the following problems in your model layer:
copyWith()
functionality and deep copy.- Support assigning a value to null in
copyWith()
. ==
operator of 2 instances, since Dart comparing objects by pointer and not the value.- Override of
toString()
. - Proper immutability of your models.
All of the problems above and even more are solved by freezed package from Remi.
We will use it to reduce amount of boilerplate code and keep our codebase clean.
If you don't like freezed or any other third-party solution you can easily solve this problems by yourself, except for deep copy I found it quite difficult to implement.
So, we have 2 templates:
-
freezed case (fclass snippet):
@freezed class Model with _$Model{ const factory Model() = _Model; }
In this case all needed features are already implemented. Freezed classes are immutable by default.
-
by ourselves (iclass snippet):
@immutable class Model { const Model(); }
In this case each feature needs to be implemented separately. Life savior is Data Class Generator plugin for VSCode.
Notice that we are using const
constructors everywhere where possible (Rule #2).
And all parameters of the model must be final
(Rule #3), derived from Rule #1.
Which template is better to use?
The rule of thumb: Start from immutable
class and then if you need additional features use freezed
class. YAGNI
Source is divided into 3 sublayers.
Service is responsible for delivering data from API to our app and vice versa.
From API It receives Raw Data as Map<String, dynamic>
which is used inside Repository.
From Repository it receives Map<String, dynamic>
and sends it to the API.
Service is used only inside a Repository.
All networking is done by dio
package.
Responsibility of DTO is to perform 4 operations:
toJson()
- convertsDTO
toMap<String, dynamic>
format.fromJson()
- convertsMap<String, dynamic>
toDTO
.toModel()
- convertsDTO
toModel
.fromModel()
- convertsModel
toDTO
.
DTO is used only inside a Repository.
All serialization is done by json_serializable
package.
Repository is just an intermediary between our state and services.
Converts Raw Data fromJson()
toModel()
and fromModel()
toJson()
.
Here we are also writing try-catch blocks.
We can also perform some finishing touches on the data like sorting, parsing and so on.
This layer is responsible for state management in the app. It directly works with Repository, Model and UI layer.
Since I want this architecture to be universal implementation details of this layer may differ and will depend on state management approach you will choose.
I will show you my approach using Riverpod.
State is divided into 3 sublayers:
- Notifiers - managing actual state of the app by using
StateNotifier
from Riverpod. - Providers - providing notifiers and other data.
- Repositories - providing repositories (from source layer), also can perform some operations with them. Repositories can be either
FutureProvider
orStreamProvider
.
- We are not using
ChangeNotifier
because it's not immutable.
final homeProvider = Provider<int>((ref) {
return 1;
});
In the specific example above you can think that there is no point to use short naming but when our application grows we need to name our providers as much descriptive as we can and sometimes the name of variables can become too long which is not good for eye and usage, like this: deletedInformationOfPostStateNotifierProvider
.
So, the suggestion is to use short convention naming for our state layer:
- Repositories:
- Frep =
FutureRepository
- Srep =
StreamRepository
- Frep =
- Providers:
- Pod =
Provider
- Fpod =
FutureProvider
- Spod =
StreamProvider
- Stpod =
StateProvider
- Notipod =
StateNotifierProvider
- Pod =
- Notifier =
StateNotifier
, we are dropping "State" part since we aren't usingChangeNotifier
andValueNotifier
.
This layer can get messy really fast. So, here we need to be very careful.
But I have solution how to deal with organization of our widgets.
Everyone knows that all widgets in flutter app are living inside a tree. If you didn't know this just open a DevTool.
So, why don't we just follow tree-like organizational structure for our widgets the same way they are structured under the hood of framework.
We will also use power of Flutter's composition.
Example:
Code
// Path ui/my_screen.dart
class MyScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
MyScreenText(),
MyScreenButton(),
MyScreenDescription(),
],
);
}
}
// Path ui/components/my_screen_text.dart
class MyScreenText extends StatelessWidget {
@override
Widget build(BuildContext context) {
// ... some code here
}
}
// Path ui/components/my_screen_button.dart
class MyScreenButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
// ... some code here
}
}
// Path ui/components/my_screen_description/my_screen_description.dart
class MyScreenDescription extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
MyScreenDescriptionImage(),
MyScreenDesciptionCard(),
],
);
}
}
// Path ui/components/my_screen_description/components/my_screen_description_image.dart
class MyScreenDescriptionImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
// ... some code here
}
}
// Path ui/components/my_screen_description/components/my_screen_description_card.dart
class MyScreenDesciptionCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
// ... some code here
}
}
//! The name of the widgets can be much shorter and descriptive, I used long names for illustration purposes
Folder structure
.
βββ ...
βββ ui
β βββ components
β β βββ my_screen_description
| | | βββ components
| | | | βββ my_screen_description_card.dart
| | | | βββ my_screen_description_image.dart
| | | βββ my_screen_description.dart
β β βββ my_screen_button.dart
| | βββ my_screen_text.dart
β βββ my_screen.dart
βββ ...
Now imagine that we need to use MyScreenDesciptionCard()
also in the MyScreen()
.
By having tree-like structure we can easily refactor our code, so we can lift MyScreenDesciptionCard()
by one level up.
Since the level of the widget is increased we need to give it a new name and rename the file because the previous name doesn't make sense anymore.
The good name will be MyScreenCard()
since it placed on the same level as other widgets of MyScreen()
.
So, we have:
Code
// Path ui/my_screen.dart
class MyScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
MyScreenText(),
MyScreenButton(),
MyScreenDescription(),
],
);
}
}
// Path ui/components/my_screen_text.dart
class MyScreenText extends StatelessWidget {
@override
Widget build(BuildContext context) {
// ... some code here
}
}
// Path ui/components/my_screen_button.dart
class MyScreenButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
// ... some code here
}
}
// Path ui/components/my_screen_card.dart
class MyScreenCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
// ... some code here
}
}
// Path ui/components/my_screen_description/my_screen_description.dart
class MyScreenDescription extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
MyScreenDescriptionImage(),
],
);
}
}
// Path ui/components/my_screen_description/components/my_screen_description_image.dart
class MyScreenDescriptionImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
// ... some code here
}
}
//! The name of the widgets can be much shorter and descriptive, I used long names for illustration purposes
Folder structure
.
βββ ...
βββ ui
β βββ components
β β βββ my_screen_description
| | | βββ components
| | | | βββ my_screen_description_image.dart
| | | βββ my_screen_description.dart
β β βββ my_screen_button.dart
| | βββ my_screen_card.dart
| | βββ my_screen_text.dart
β βββ my_screen.dart
βββ ...
-
Dependency illustration
Arrows illustrate dependency. For example, UI is dependent on the State and Model.
-
Data flows
We have 2 general bidirectional flows in the application
-
Folder Structure of a single feature
. βββ ... βββ feature β βββ model | | βββ some_model.dart | | βββ some_other_model.dart β βββ source | | βββ dto | | | βββ some_dto.dart | | | βββ some_other_dto.dart | | βββ repository | | | βββ repositories | | | | βββ some_repository.dart | | | | βββ some_other_repository.dart | | | βββ feature_repository.dart # acts like facade for other repositories | | βββ service | | βββ services | | | βββ some_service.dart | | | βββ some_other_service.dart | | βββ feature_service.dart # acts like facade for other services | βββ state | | βββ notifiers | | | βββ some_notifier | | | | βββ state # can be optional if you are using model | | | | | βββ some_notifier_state.dart | | | | βββ union # optional, use only if you need | | | | | βββ some_notifier_union.dart | | | | βββ some_notifier.dart | | | βββ some_other_notifier.dart | | βββ providers | | | βββ some_fpod.dart | | | βββ some_notipod.dart | | | βββ some_pod.dart | | | βββ some_spod.dart | | | βββ some_stpod.dart | | βββ repositories # under question (see below) | | βββ some_frep.dart | | βββ some_srep.dart | βββ ui | βββ components | | βββ some_complex_component | | | βββ components | | | | βββ some_part.dart | | | | βββ some_other_part.dart | | | βββ some_complex_component.dart | | βββ some_button.dart | | βββ some_text.dart | βββ feature.dart βββ ...
- I listed all possible providers and repositories for illustration. You don't have to use all of them if you don't need.
- All endings in the
model
,source
andstate
layers are conventions. (e.g._model
,_dto
,_fpod
and so on.) - Naming of core folders is constant (e.g.
ui
,source
,dto
,service
,services
,notifiers
(in case you are using riverpod) and so on.) - In the
ui
layer conventions are following:- Every feature and subfeature must have folder called
components
and dart file named by feature's name. In our case we have subfeature(complex component) called "some_complex_component" which has foldercomponents
and dart file named by itself. Single dart file is considered as component. (e.g.some_part.dart
) - Naming of components is up to you.
- Every feature and subfeature must have folder called
- There is some ambiguity between repositories and future/stream providers. Repositories are future/stream providers that are working with repositories from our source layer. But if we think about it almost in 90% of cases the only reason why we need to use future/stream providers is to access repoistory from the source layer. So, if that's the case do we need to complicate things and create another sublayer for those kind of things? Probably it will better if we'll use just future/stream providers (aka fpod and spod) for this kind of things. So at the end we have just 2 sublayers: notifiers and providers.
- All models must be
immutable
. - Always use const constructors where possible.
- All parameters must be
final
.
Naming is very important part of every architecture.
It needs to be descriptive and useful through our development process.
In the following example assume that we are making some feature which is called "Home".
// Source
class HomeDto {}
class HomeRepository {}
class HomeService {}
// Model
class HomeModel {}
// UI
class Home extends StatelessWidget {}
// We won't use short naming for Source, Model, and UI because this will become very tedious in the future
// We need our code to be maximum descriptive, so, the only compromise we made is naming of our state layer
// State
class HomeState {}
class HomeUnion {}
class HomeNotifier extends StateNotifier<HomeModel> {}
// Naming of repositories and providers discussed in the "state" part of the docs.
The naming above is strict and shouldn't be violated.
- Name of the file must have snake_case
- Name of the object must have camelCase
For linting we are using lint package with our own modifications.
See analysis_options.yaml
file.
In progress
It's very important to construct a good .gitignore
file since the default one is lacking some files.
This will help to avoid merge conflicts in the team and keep codebase more organized.
See .gitignore
file.
- "Keep it simple" - KIS
- "Don't repeat yourself" - DRY
- "You are not gonna need it" - YAGNI
- We donβt make things easy to do, we make things easy to understand. [5 - Bill Kennedy]
- You write things in the concrete first, then you ask: "Does that require an abstraction layer?β [5]
- Don't do something for the sake of doing it. [5]
- You shouldnβt be writing code for yourself. You should be writing code for the next person that will maintain it. [5]
- Make it work, make it right, make it fast, make it testable. (4 steps of refactoring).
- Donβt make something complex until you absolutely have no choice. [5]
- You should be writing code that you need today, not tomorrow. [5]
- The only way to go fast is to go well. [4 - Uncle Bob]
- The function should do one thing. [4]
- Architecture is not about making decisions early, it's about making decisions late. [4]
- Write tests for every bit of the code. [4]
- Don't make tests coupled to the system. [4]
- freezed - code generation for immutable classes
- dio - http client
- json_serializable - dart build system builders for handling JSON
- flutter_riverpod - simple way to access state from anywhere
- hooks_riverpod - riverpod with hooks
- flutter_hooks - additional hooks' functionality
- font_awesome_flutter - regular icons
- google_fonts - 1100+ fonts from Google (Internet access)
- device_preview - how your app looks and performs on other devices
- flutter_screenutil - adapting screen and font size
Snippets are available in the VSCode plugin - SMUS Snippets.
iClass
- creates immutable classfClass
- creates freezed classfpart
- creates freezed part
fromJson
- createsfromJson()
factory for json_serializabletoJson
- createstoJson()
function for json_serializablefromModel
- createsfromModel()
factorytoModel
- createstoModel()
functiondto
- creates DTO class with all boilerplate needed - imports, parts,fromJson()
,toJson()
,fromModel()
,toModel()
.repo
- creates repository function with all needed boilerplategpart
- creates generated part forjson_serializable
pod
- creates plain Providerfpod
- creates Future Providerspod
- creates Stream Providerstpod
- creates State Providernotipod
- creates State Notifier Providerfrep
- creates Future Repositorysrep
- creates Stream Repositorynotifier
- create State Notifier class
-
If you want the same folder layout as in the screenshots, install Material Icon Theme plugin and write the following code inside your VSCode's
settings.json
file:"material-icon-theme.folders.associations": { "ui": "layout", "utilities": "utils", "source": "server", "dto": "mappings", "repository": "pipe", "repositories": "pipe", "notifiers": "redux-store", "state": "react-components", },
- This architecture is inspired by DDD created by Eric Evans which was introduced to me by ResoCoder in his awesome tut.
- It was very useful too see BLoC architecture by Felix Angelov that was introduced to me by Flutterly in his amazing video series: "BLoC From Zero to Hero".
- Huge thanks to Remi Rousselet's for creating such great packages like provider, riverpod, freezed and flutter_hooks.
- Some principles and rules were borrowed from Uncle Bob's talk "Clean Code".
- Thanks to Bill Kennedy for his talk at the Go Time Podcast #172 (Design Philosophy).
- Logo made by Freepik from www.flaticon.com