Architecture will make code easier to maintain, test and understand.
Here is the top-level folder structure you will find in the project under the /lib:
- app contains app run_app with various setups like setup of flutter.onError crash handling and dependency initialization.
- common contains code that's common to all layers and accessible by all layers.
- device is an outer layer that represents communication with device hardware (e.g. sensors) or software (calendar, permissions).
- source_remote is an outer layer that represents communication with remote sources (web, http clients, sockets).
- source_local is an outer layer that represents communication with local sources (database, shared_prefs).
- domain is the inner layer that usually contains interactors, data holders. This layer should only contain business logic and not know about specific of ui, web, etc. or other layers.
- ui is the layer where we package by feature widgets and presenters. Presenters contains presentation logic and they access domain and are provided in the view tree by Provider/Riverpod package.
In previously defined layers there are several classes that you'll often use.
Repository and Manager
Repository is outer part of our application. It belongs to source_remote, source_local or device. It uses concrete implementations like dio, hive, add2calendar, other plugins and abstracts them from the rest of the application.
Repository should be behind an interface. This means that you should create interface YourRepository
and concrete class YourRepositoryImpl implements YourRepository
. The YourRepository
interface belongs to the domain and the YourRepositoryImpl
belongs to the outer layers. This way the domain can access repository, but it does not know about specific implementation of that interface (dependency inversion princinple).
Example of repository to fetch list of meetups:
//domain/repository/meetup_repository/meetup_repository.dart
abstract class MeetupRepository {
Future<List<Meetup>> getListOfMeetups();
}
//source_remote/impl/meetup_repository/meetup_repository_impl.dart
class MeetupRepositoryImpl implements MeetupRepository {
MeetupRepositoryImpl(this._dio);
final Dio _dio;
@override
Future<List<Meetup>> getListOfMeetups() async {
final Response<String> response = await _dio.post<String>('/api/meetups');
return MeetupsResponse.fromJson(jsonDecode(response.data)).meetups;
}
}
Manager works the same as Repository, but we use it just for the better naming. Sometimes this layer can actively manage, e.g. by adding events to calendar, turning on bluetooth or managing permissions. Instead of calling them BluetoothRepository, we use something like BluetoothManager.
Interactor
Interactor is the inner part that belongs to domain. Domain contains business logic of the application, they can access other classes from domain including repositories. Interactor is also behind an interface for easier testing, so we create YourInteractor
and YourInteractorImpl
. Main job of interactor is combining diferrent repositories and handling business logic.
Example of interactor to add meetup event to calendar
//domain/interactor/add_event_to_interactor/add_event_to_interactor.dart
abstract class AddEventToCalendarInteractor {
Future<void> addEventToCalendar(Meetup event);
}
//domain/interactor/add_event_to_interactor/add_event_to_interactor_impl.dart
class AddEventToCalendarInteractorImpl extends AddEventToCalendarInteractor {
AddEventToCalendarInteractorImpl(this._calendarManager, this._meetupRepository);
final CalendarManager _calendarManager;
final MeetupRepository _meetupRepository;
@override
Future<void> addEventToCalendar(Meetup meetup) async {
final dateTimeOfEvent = await _meetupRepository.getMeetupEventDate(meetup);
final CalendarEvent event = CalendarEvent(meetup.name, dateTimeOfEvent);
return await _calendarManager.addEventToCalendar(event);
}
}
Presenters and widgets
Presenters and widgets are part of the presentation that we put in the ui
. They are packaged together by layer which makes it easier to navigate because they work closely together. Provider contains presentation logic, usually controling the view state. Widget observes that state and can rebuild on state change.
This way view is passive and just reacts to changes. It's easy to maintain and test. View should mostly consist of stateless widgets that observe presenter view state.
Example of Meetup presenter:
//ui/meetup/presentation/meetup_screen_presenter.dart
class MeetupScreenPresenter extends ChangeNotifier {
MeetupScreenPresenter(this._addEventToCalendarInteractor);
final AddEventToCalendarInteractor _addEventToCalendarInteractor;
final AddMeetupToFavoritesInteractor _addMeetupToFavoritesInteractor;
void onAddToCalendar(Meetup meetup) {
_addEventToCalendarInteractor.addEventToCalendar(meetup);
}
void onMeetupFavorite(Meetup meetup) {
_addMeetupToFavoritesInteractor.addToFavorites(meetup);
}
}
Example of UI that reacts to presenter and rebuilds:
Consumer<MeetupScreenPresenter>(
builder: (context, presenter, _) {
return _MeetupList(list: presenter.state);
},
),
The requests are done a bit diferrently. Request is a state of its own with values like loading, success and error. That's why we generally don't bundle requests all in one presenter together with all other states into one mega state. Since you often need to listen exactly one request states to show loading indicator or error, it causes problems because provider will update all listeners that listen on mega state. That mega provider can also be large and hard to maintain.
That's why we split them and extend request_provider which will handle states like loading, success and error for us. You can read more about request provider in bits: https://github.com/infinum/flutter-bits/tree/master/request_provider
Models
Models are simple data structures. Usually they will be a part of common (/common/models
). These are the models that are used by multiple layers.
For example, you can have User with @JsonSerializable that's used by source_remote, but that same model is also used by domain and UI.
You can also have models as a be part of specific layer (/source_remote/model
or ui/my_feature/model
) or inside specific feature (/domain/manager/permission_manager/device_permissions.dart
) if that makes more sense.
To differentiate all of these models, we have special naming for some layers.
We suffix the UI models with Ui
(e.g. ArticleUi
) and use this when domain model doesn't fit what
needs to be shown on the screen. These models are only used in the ui.
Models from other outer layers are suffixed with Dto
which stands for Data Transfer Object (e.g. ArticleDto
).
We use them when the structure we receive from outer layers (like from API) is not in format we would like to work with,
so we create ArticleDto
(model from API) and Article
(model for our app).
As we said that inner layer should never know about specific of outer layers, same applies here. Domain should never know about Ui or Dto models. They need to be mapped in repository or presenter.
Data holders
DataHolder
is a singleton class that holds data in memory. It doesn't have an interface and it only has
data and methods to get or set data. Data holders are part of domain and they don't call repositories
or other outer layers.
Mappers
These are classes with static methods that will map models between different layers.
For ArticleDto
-> Article
mapping we create ArticleMapper
. For vice-versa
Article
-> ArticleDto
we create ArticleDtoMapper
.
Mapper could have multiple methods:
class ArticleMapper {
Article map(ArticleDTO dto){...}
Article mapFromXyz(XyzDTO dto){...}
List<Article> mapToList(List<ArticleDTO> dto){...}
}
Mappers are the part of the ui, source_remote, device and other outer layers (/source_remote/mapper
).
If your case doesn't require ui or DTOs then you don't need mappers.