Flutter Quotable is an educational Flutter app built with Riverpod using a Domain Centric (and Packaged) Architecture as purposed in this presentation that was originally made for FlutterBytes Conference.
it primarily focuses on:
- App Architecture (using a Domain-Centric Architecture)
- App Folder Structure (using packages and Melos)
For the app architecture, we don't follow a specific approach like DDD or Clean Architecture, but we use a simplifed version with only one important concept, that it's should be Domain Centric. You can then implement your layers the way you consider best for your app usecase.
Our domain layer encapsulate all of our business logic, and it consists of
- Entities to write our domain model (you can also use ValueObjects, but we won't use it in our example)
- Repositories for our interface and usecases
which is enough to keep things organized and clean, and to avoid unnecessary complexity.
This layer consists of only dart code and does not depend on anything at all (maybe some core models). this ensures a good sepration of concerns and a clear Ubiquitous Language.
Example,
this is how we might write our quote entity, notice how we define its equailty by only the id
field
class Quote extends Equatable {
const Quote({
required this.id,
required this.author,
required this.content,
});
final String id;
final String content;
final String author;
@override
List<Object?> get props => [id];
}
and this is our repository interface
abstract class QuotesRepository {
Future<Either<Failure, Page<Quote>>> quotes({
required int pageIndex,
int limit = 20,
});
}
Consider the data layer as an implementation of our domain, by implementing the abstract repositories
without exposing any details on how we do that. We might get our data from a server, Firebase, or even from local storage, the domain does not (nor any of the other layers) care about these details, it only deals with the interface defined in the domain layer, see Dependency Management for how we manage that.
The data layer consists of
- Repositories Implementations of our domain
- Data Sources, which are used by repositories to communicate with, well, data sources (i.e server, firebase or local)
- Models, which are like DTOs that are responsible for mapping raw data (typically json) from/to our domain entities
Example
class QuotesRepositoryImpl with RepositoryMixin implements QuotesRepository {
QuotesRepositoryImpl(this._remoteDataSource);
final QuotesRemoteDataSource _remoteDataSource;
@override
Future<Either<Failure, Page<Quote>>> quotes({required int pageIndex, int limit = 20}) {
// `request` is a helper method to map the data source response to match our interface
return request(
() => _remoteDataSource.fetchQuotes(pageIndex: pageIndex, limit: limit),
);
}
}
class QuotesRemoteDataSource {
QuotesRemoteDataSource(this._httpService);
final HttpService _httpService;
Future<Page<QuoteModel>> fetchQuotes({required int pageIndex, int limit = 20}) async {
final response = await _httpService.requestPage(
QuotesApis.quotes,
pageIndex: pageIndex,
limit: limit,
);
return Page(
totalCount: response.totalCount,
pageIndex: response.pageIndex,
totalPages: response.totalPages,
items: response.results.map(QuoteModel.fromJson).toList(),
);
}
}
This layer is responsible for the communication between the Presentation Layer and the Data Layer (through the Domain Layer), and it holds most of the ui logic using riverpod
providers.
Example
in the following snippet, we define a quotesProvider
(to be consumed in the presentation layer) to fetch quotes through QuotesRepository.quotes
.
A couple of things to note
- this provider acts as a use case, in this example, to fetch quotes
- It does not depend or know anything about the Data Layer or its implementation details, it communicate through the interface defined in domain layer (see Dependency Management for how we manage that)
final quotesProvider = Provider((ref) {
return QuotesProvider(locator<QuotesRepository>());
});
/// PagingProvider is class that provides a paging controller and callback to be executed every time we request a new page
class QuotesProvider extends PagingProvider<Quote> {
QuotesProvider(this._repository);
final QuotesRepository _repository;
@override
NewPageCallback<Quote> get pageRequest => _fetchQuotes;
Future<Either<Failure, Page<Quote>>> _fetchQuotes(int pageIndex) async {
return _repository.quotes(pageIndex: pageIndex);
}
}
This is top layer that has all the ui code, it consumes providers from the Application Layer.
In the following snippet, we define a QuotesScreen
that consumes the quotesProvider
we defined earlier the Application Layer and displays list of QuoteItem
s.
class QuotesScreen extends ConsumerWidget {
const QuotesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider = ref.watch(quotesProvider);
return Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context)!.quotes)),
body: PagedListView<Quote>(
pagingController: provider.pagingController,
itemBuilder: (context, quote, index) => QuoteItem(qoute: quote),
),
);
}
}
For managing our dependencies, we use a service locator (using getit)
We define our locator instance in the data layer, and inject it with all the repositories implementations of the domain (and any other services needed).
final locator = Locator();
Note that we expose only the locator instance from the data layer to be used across other layers (i.e application), this ensures that as we don't depend on any implementation details.
for example if we want to access our QuotesRepository
, we can do so by just calling the locator with the desired interface, assuming we already registered it (see Registering Dependencies). Also notice that we don't know anything about how that repository is implemented, and we shouldn't.
locator<QuotesRepository>();
For registering a dependency, we don't use getit
directly (although we can), Instead we use injectable (and the power of code generation) to save us the time and effort of manually doing that.
We simply annonate the class we want to register with @LazySingleton()
or @Singleton()
and injectable takes care of everything else.
Example
@LazySingleton()
class QuotesRemoteDataSource {}
note: if you want to register an implementation to an interface, you must use as
parameter and pass in the interface
@LazySingleton(as: QuotesRepository)
class QuotesRepositoryImpl implements QuotesRepository {}