Capstone: Building a Feature End-to-End
Capstone: Building a Feature End-to-End
This capstone lesson consolidates every concept from the tutorial — Clean Architecture, MVVM, the Repository pattern, injectable for dependency injection, and feature-modular folder structure — into one cohesive exercise. You will implement a Saved Articles feature that lets a user bookmark articles and view their saved list, touching every layer of the architecture from Domain to Presentation.
Step 1 — Domain Layer: Entity and UseCase
Start in the innermost layer. Define the pure Dart entity and the use-case interface — no Flutter imports, no framework dependencies.
lib/features/saved_articles/domain/entities/saved_article.dart
// Pure Dart entity — no Flutter, no framework
class SavedArticle {
final String id;
final String title;
final String url;
final DateTime savedAt;
const SavedArticle({
required this.id,
required this.title,
required this.url,
required this.savedAt,
});
}
// lib/features/saved_articles/domain/repositories/saved_article_repository.dart
abstract class SavedArticleRepository {
Future<List<SavedArticle>> getSavedArticles();
Future<void> saveArticle(SavedArticle article);
Future<void> removeArticle(String id);
}
// lib/features/saved_articles/domain/usecases/get_saved_articles.dart
import 'package:injectable/injectable.dart';
@injectable
class GetSavedArticles {
final SavedArticleRepository _repository;
const GetSavedArticles(this._repository);
Future<List<SavedArticle>> call() => _repository.getSavedArticles();
}
// lib/features/saved_articles/domain/usecases/save_article.dart
@injectable
class SaveArticle {
final SavedArticleRepository _repository;
const SaveArticle(this._repository);
Future<void> call(SavedArticle article) => _repository.saveArticle(article);
}
Notice that the abstract SavedArticleRepository lives in the Domain layer. Only the interface belongs here — the concrete implementation is the Data layer's responsibility.
Step 2 — Data Layer: Model, DataSource, and Repository Implementation
Move outward to the Data layer. Create a SavedArticleModel that extends the entity and adds serialisation logic, wire a LocalDataSource backed by shared_preferences, and implement the repository.
lib/features/saved_articles/data/models/saved_article_model.dart
import 'dart:convert';
import '../../domain/entities/saved_article.dart';
class SavedArticleModel extends SavedArticle {
const SavedArticleModel({
required super.id,
required super.title,
required super.url,
required super.savedAt,
});
factory SavedArticleModel.fromJson(Map<String, dynamic> json) =>
SavedArticleModel(
id: json['id'] as String,
title: json['title'] as String,
url: json['url'] as String,
savedAt: DateTime.parse(json['savedAt'] as String),
);
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'url': url,
'savedAt': savedAt.toIso8601String(),
};
}
// lib/features/saved_articles/data/datasources/saved_article_local_datasource.dart
import 'package:injectable/injectable.dart';
import 'package:shared_preferences/shared_preferences.dart';
abstract class SavedArticleLocalDataSource {
Future<List<SavedArticleModel>> getSavedArticles();
Future<void> saveArticle(SavedArticleModel model);
Future<void> removeArticle(String id);
}
@LazySingleton(as: SavedArticleLocalDataSource)
class SavedArticleLocalDataSourceImpl implements SavedArticleLocalDataSource {
static const _key = 'saved_articles';
final SharedPreferences _prefs;
SavedArticleLocalDataSourceImpl(this._prefs);
@override
Future<List<SavedArticleModel>> getSavedArticles() async {
final raw = _prefs.getStringList(_key) ?? [];
return raw
.map((s) => SavedArticleModel.fromJson(jsonDecode(s) as Map<String, dynamic>))
.toList();
}
@override
Future<void> saveArticle(SavedArticleModel model) async {
final list = await getSavedArticles();
if (list.any((a) => a.id == model.id)) return;
list.add(model);
await _prefs.setStringList(
_key, list.map((a) => jsonEncode(a.toJson())).toList());
}
@override
Future<void> removeArticle(String id) async {
final list = await getSavedArticles();
list.removeWhere((a) => a.id == id);
await _prefs.setStringList(
_key, list.map((a) => jsonEncode(a.toJson())).toList());
}
}
// lib/features/saved_articles/data/repositories/saved_article_repository_impl.dart
import 'package:injectable/injectable.dart';
@LazySingleton(as: SavedArticleRepository)
class SavedArticleRepositoryImpl implements SavedArticleRepository {
final SavedArticleLocalDataSource _local;
SavedArticleRepositoryImpl(this._local);
@override
Future<List<SavedArticle>> getSavedArticles() => _local.getSavedArticles();
@override
Future<void> saveArticle(SavedArticle article) =>
_local.saveArticle(SavedArticleModel(
id: article.id,
title: article.title,
url: article.url,
savedAt: article.savedAt,
));
@override
Future<void> removeArticle(String id) => _local.removeArticle(id);
}
Step 3 — Presentation Layer: ViewModel and Widget
The Presentation layer talks only to use-cases — never directly to the repository or data sources. Use ChangeNotifier (or your chosen state-management solution) as the ViewModel.
lib/features/saved_articles/presentation/viewmodels/saved_articles_viewmodel.dart
import 'package:flutter/foundation.dart';
import 'package:injectable/injectable.dart';
enum SavedArticlesStatus { initial, loading, loaded, error }
@injectable
class SavedArticlesViewModel extends ChangeNotifier {
final GetSavedArticles _getSavedArticles;
final SaveArticle _saveArticle;
SavedArticlesViewModel(this._getSavedArticles, this._saveArticle);
SavedArticlesStatus status = SavedArticlesStatus.initial;
List<SavedArticle> articles = [];
String? errorMessage;
Future<void> load() async {
status = SavedArticlesStatus.loading;
notifyListeners();
try {
articles = await _getSavedArticles();
status = SavedArticlesStatus.loaded;
} catch (e) {
errorMessage = e.toString();
status = SavedArticlesStatus.error;
}
notifyListeners();
}
Future<void> bookmark(SavedArticle article) async {
await _saveArticle(article);
await load(); // refresh list
}
}
// lib/features/saved_articles/presentation/pages/saved_articles_page.dart
class SavedArticlesPage extends StatefulWidget {
const SavedArticlesPage({super.key});
@override
State<SavedArticlesPage> createState() => _SavedArticlesPageState();
}
class _SavedArticlesPageState extends State<SavedArticlesPage> {
late final SavedArticlesViewModel _vm;
@override
void initState() {
super.initState();
_vm = getIt<SavedArticlesViewModel>();
_vm.load();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Saved Articles')),
body: AnimatedBuilder(
animation: _vm,
builder: (_, __) {
if (_vm.status == SavedArticlesStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (_vm.status == SavedArticlesStatus.error) {
return Center(child: Text(_vm.errorMessage ?? 'Error'));
}
if (_vm.articles.isEmpty) {
return const Center(child: Text('No saved articles yet.'));
}
return ListView.builder(
itemCount: _vm.articles.length,
itemBuilder: (_, i) => ListTile(
title: Text(_vm.articles[i].title),
subtitle: Text(_vm.articles[i].url),
),
);
},
),
);
}
@override
void dispose() {
_vm.dispose();
super.dispose();
}
}
getIt<SavedArticlesViewModel>() call relies on injectable registering the ViewModel as a factory (not a singleton) so each page gets a fresh instance. Annotate it with @injectable (which defaults to factory) rather than @LazySingleton.Step 4 — Wiring Dependency Injection
Run the build runner once to regenerate the injection module, then register SharedPreferences as an external dependency in your @module class before calling configureDependencies() in main().
lib/injection.dart (excerpt)
// After adding new @injectable / @LazySingleton classes, regenerate:
// dart run build_runner build --delete-conflicting-outputs
@module
abstract class RegisterModule {
@preResolve
Future<SharedPreferences> get prefs => SharedPreferences.getInstance();
}
build_runner after annotating a new class is the single most common mistake. The generated *.config.dart file will be stale and getIt will throw a not registered error at runtime.Step 5 — Feature Module Folder Structure
Every feature in a modular Clean Architecture project follows the same folder convention:
lib/features/saved_articles/domain/entities/lib/features/saved_articles/domain/repositories/(abstract only)lib/features/saved_articles/domain/usecases/lib/features/saved_articles/data/models/lib/features/saved_articles/data/datasources/lib/features/saved_articles/data/repositories/(concrete impl)lib/features/saved_articles/presentation/viewmodels/lib/features/saved_articles/presentation/pages/lib/features/saved_articles/presentation/widgets/
This structure means every layer is independently navigable; a new team member can open any feature folder and immediately understand its responsibilities without reading any other layer.
Summary
You have now implemented a complete feature end-to-end: the SavedArticle entity and repository interface live purely in Domain; the model, data source, and repository implementation live in Data; and the ViewModel plus widget live in Presentation. Dependency injection wires every class together without any layer having a hard import on another layer's concrete type. This is the discipline that makes large Flutter codebases maintainable, testable, and scalable.