App Architecture & Design Patterns

Capstone: Building a Feature End-to-End

18 min Lesson 12 of 12

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.

Goal: After completing this lesson you will be able to scaffold a brand-new feature completely from scratch, following the same layered discipline in every Flutter project you build.

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();
  }
}
Tip: The 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();
}
Warning: Forgetting to re-run 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.

Key Takeaway: The dependency rule must always point inward — Presentation imports Domain use-cases, Data implements Domain interfaces, and Domain imports nothing outside itself. Enforcing this rule is the whole point of Clean Architecture.