Last updated: September 27, 2026
After completing this codelab, students will be able to:
week5_offline_notes) or Week 6 (campus_notify) project. This codelab refactors that project, it does not start from scratch.Five principles, one sentence each plus the smell of violating them:
| Principle | Practical meaning | Violation smell in Flutter |
|---|---|---|
| Single Responsibility | One class has one reason to change | A 400-line widget that fetches an API, parses JSON, and formats dates at once |
| Open/Closed | Add behavior by extension, not by editing old code | Every new data source forces edits to already-working widgets |
| Liskov Substitution | A replacement must substitute the original without breaking | A fake test repository throwing errors the real one never throws |
| Interface Segregation | Do not force clients to depend on what they never use | One giant AppRepository interface for auth + notes + push at once |
| Dependency Inversion | Depend on abstractions, not concretions | A notifier calling Dio / sqflite directly instead of a repository |
presentation (widgets, notifiers, router)
|
v depends on
domain (entities, repository interfaces, use cases, failures)
^
| implemented by
data (models, repository impls, Dio, SQLite, secure storage)
The golden rule (dependency rule): dependencies only point inward. domain knows nothing about Flutter, Dio, or SQLite — so business logic can be purely unit-tested.
| Structure | Shape | When to use it |
|---|---|---|
feature-first | lib/features/notes/{data,domain,presentation} | Multi-feature projects (this codelab's choice): one feature can be understood/removed without touching others |
layer-first | lib/{data,domain,presentation} globally | Tiny single-feature projects; fast but messy as features grow |
toMap/fromMap/toJson). Lives in domain.data.fetchNotes()). Lives in domain.data.GetNotes, SyncNotes). Lives in domain, called by presentation.get_it). Widgets never new a repository themselves.Open your Week 5 or 6 project and fill in this table in the README (example for campus_notify):
| File | Current layer | Problem |
|---|---|---|
pages/home_page.dart | presentation | calls Dio directly? formats dates + parses JSON in the widget? |
data/api_client.dart | data | OK if only used by repositories, not widgets |
providers/auth_provider.dart | presentation (state) | OK if it only calls repositories/use cases |
data/auth_repository.dart | data (+mixed contract) | interface and implementation still one class |
Search your project with grep:
# Widgets touching network / database directly
rg "Dio\(|http\.|openDatabase|SharedPreferences\.getInstance|FlutterSecureStorage" lib/pages lib/widgets
# Business logic inside build()
rg "DateFormat|jsonDecode|\.toIso8601String" lib/pages lib/widgets
# Manual instantiation (leaking DI)
rg "Repository\(|Dio\(BaseOptions" lib/pages lib/providers
Each hit is one refactor item. Goal: all three searches return zero hits in the presentation folders after Lab 3.
For one feature (notes or announcements):
lib/
├── core/
│ ├── failures.dart # Domain failures (pure Dart)
│ └── providers.dart # (optional) cross-feature providers
├── features/
│ └── notes/
│ ├── domain/
│ │ ├── entities/note.dart
│ │ ├── repositories/note_repository.dart # interface!
│ │ └── usecases/get_notes.dart
│ │ └── usecases/add_note.dart
│ ├── data/
│ │ ├── models/note_model.dart
│ │ └── repositories/note_repository_impl.dart
│ └── presentation/
│ ├── providers/notes_providers.dart # notifiers + DI
│ └── pages/notes_page.dart
└── routes.dart # GoRouter + route constants
Create lib/features/notes/domain/entities/note.dart. No Flutter import, no mapping:
class Note {
const Note({
this.id,
required this.title,
this.body = '',
required this.updatedAt,
this.dirty = false,
});
final int? id;
final String title;
final String body;
final DateTime updatedAt;
final bool dirty;
}
Create lib/core/failures.dart and lib/features/notes/domain/repositories/note_repository.dart:
sealed class Failure {
const Failure(this.message);
final String message;
}
class LocalFailure extends Failure {
const LocalFailure(super.message);
}
class NetworkFailure extends Failure {
const NetworkFailure(super.message);
}
import '../../domain/entities/note.dart';
import '../../../../core/failures.dart';
abstract class NoteRepository {
Future<({List<Note> notes, Failure? failure})> fetchNotes();
Future<({Note? note, Failure? failure})> addNote({
required String title,
String body = '',
});
}
This example uses Dart records to make success/failure explicit without exceptions leaking into presentation. Industry alternative: the fpdart/dartz packages (Either<Failure, T>) — the contract pattern is the same.
Create lib/features/notes/data/models/note_model.dart (mapping only here) and note_repository_impl.dart:
import '../../domain/entities/note.dart';
class NoteModel extends Note {
const NoteModel({
super.id,
required super.title,
super.body = '',
required super.updatedAt,
super.dirty = false,
});
Map<String, Object?> toMap() => {
'id': id,
'title': title,
'body': body,
'updated_at': updatedAt.toIso8601String(),
'dirty': dirty ? 1 : 0,
};
factory NoteModel.fromMap(Map<String, Object?> map) {
return NoteModel(
id: (map['id'] as num?)?.toInt(),
title: map['title'] as String? ?? '',
body: map['body'] as String? ?? '',
updatedAt: DateTime.tryParse(map['updated_at'] as String? ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
dirty: ((map['dirty'] as num?)?.toInt() ?? 0) == 1,
);
}
Note toEntity() => Note(
id: id,
title: title,
body: body,
updatedAt: updatedAt,
dirty: dirty,
);
}
import 'package:sqflite/sqflite.dart';
import '../../../../core/failures.dart';
import '../../domain/entities/note.dart';
import '../../domain/repositories/note_repository.dart';
import '../models/note_model.dart';
class NoteRepositoryImpl implements NoteRepository {
NoteRepositoryImpl({required Future<Database> Function() openDb})
: _openDb = openDb;
final Future<Database> Function() _openDb;
@override
Future<({List<Note> notes, Failure? failure})> fetchNotes() async {
try {
final db = await _openDb();
final rows =
await db.query('notes', orderBy: 'updated_at DESC');
final notes =
rows.map((r) => NoteModel.fromMap(r).toEntity()).toList();
return (notes: notes, failure: null);
} catch (e) {
return (
notes: const <Note>[],
failure: LocalFailure('Failed to read notes: $e'),
);
}
}
@override
Future<({Note? note, Failure? failure})> addNote({
required String title,
String body = '',
}) async {
try {
final db = await _openDb();
final now = DateTime.now();
final id = await db.insert(
'notes',
NoteModel(
title: title, body: body, updatedAt: now, dirty: true)
.toMap(),
);
return (
note: Note(id: id, title: title, body: body,
updatedAt: now, dirty: true),
failure: null,
);
} catch (e) {
return (note: null,
failure: LocalFailure('Failed to save note: $e'));
}
}
}
Create lib/features/notes/domain/usecases/get_notes.dart:
import '../../../../core/failures.dart';
import '../entities/note.dart';
import '../repositories/note_repository.dart';
class GetNotes {
const GetNotes(this._repository);
final NoteRepository _repository;
Future<({List<Note> notes, Failure? failure})> call() {
return _repository.fetchNotes();
}
}
Create lib/features/notes/presentation/providers/notes_providers.dart. This is the single wiring point:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/repositories/note_repository_impl.dart';
import '../../domain/repositories/note_repository.dart';
import '../../domain/usecases/get_notes.dart';
import '../../domain/entities/note.dart';
// Data layer: the database opener is injected (easy to fake in tests)
final noteRepositoryProvider = Provider<NoteRepository>((ref) {
return NoteRepositoryImpl(openDb: openNotesDb);
});
// Domain layer: the use case receives an abstraction, not an impl
final getNotesProvider = Provider<GetNotes>((ref) {
return GetNotes(ref.watch(noteRepositoryProvider));
});
// Presentation layer: state for the UI
final notesProvider = FutureProvider<List<Note>>((ref) async {
final result = await ref.watch(getNotesProvider).call();
if (result.failure != null) throw Exception(result.failure!.message);
return result.notes;
});
The clean notes_page.dart contains no SQL, Dio, JSON, or raw date formatting:
class NotesPage extends ConsumerWidget {
const NotesPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(notesProvider);
return state.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (e, _) => ErrorView(
message: '$e',
onRetry: () => ref.invalidate(notesProvider),
),
data: (notes) => notes.isEmpty
? const Center(child: Text('No notes yet.'))
: NotesList(notes),
);
}
}
Run these three checks; all must pass:
# 1. Presentation free of raw data (must return ZERO hits)
rg "Dio\(|openDatabase|getDatabasesPath|FlutterSecureStorage|SharedPreferences\.getInstance|jsonDecode" lib/features/*/presentation lib/pages
# 2. Domain free of frameworks & packages (must return ZERO hits)
rg "import 'package:flutter|import 'package:dio|import 'package:sqflite|import 'package:firebase" lib/features/*/domain lib/core
# 3. Static analysis + tests
flutter analyze
flutter test
AI may propose a folder reorganization, but you assess the trade-offs. Wrong architecture (over-engineering simple CRUD, or under-engineering complex features) costs more than ugly code.
Ask your AI coding assistant with this prompt:
My Flutter project: campus_notify (auth + FCM + announcement list).
Current state: lib/{data, providers, pages, messaging} folders,
repositories mixed with implementations, widgets calling Dio directly.
Tasks:
1. Propose a feature-first Clean Architecture structure
(presentation/domain/data) for the auth + announcements features.
2. For each old file, state its new destination (move/split/delete).
3. Flag what would be over-engineering if applied to simple CRUD,
and when a use case is truly needed vs repository straight to notifier.
4. Show the Riverpod DI wiring (no extra DI package).
Explain the trade-off of every decision.
Before accepting the AI proposal, verify and record your findings in the README/docs:
domain and the implementation in data? (reject it if the AI puts both in one folder).domain free of Flutter/Dio/SQLite/Firebase imports? Check with grep, not by skimming.toMap/fromMap/toJson only in the model)?new Repository() themselves?auth or announcements): entity + repository interface + impl + 1 use case + provider wiring. Do not leave one feature clean and the rest dirty.lib/core/format.dart so they can be tested without widgets.Failure objects before they reach the UI; the UI only ever receives a message.Create test/get_notes_test.dart. The domain is tested purely — no SQLite, no Dio, no Firebase:
import 'package:flutter_test/flutter_test.dart';
class FakeNoteRepository implements NoteRepository {
FakeNoteRepository({this.items = const [], this.fail = false});
final List<Note> items;
final bool fail;
@override
Future<({List<Note> notes, Failure? failure})> fetchNotes() async {
if (fail) {
return (
notes: const <Note>[],
failure: const LocalFailure('db locked (simulated)'),
);
}
return (notes: items, failure: null);
}
@override
Future<({Note? note, Failure? failure})> addNote({
required String title,
String body = '',
}) {
throw UnimplementedError();
}
}
void main() {
test('GetNotes forwards the repository list', () async {
final repo = FakeNoteRepository(items: [
Note(title: 'A', updatedAt: DateTime(2026, 9, 27)),
]);
final result = await GetNotes(repo).call();
expect(result.failure, isNull);
expect(result.notes.length, 1);
expect(result.notes.first.title, 'A');
});
test('GetNotes forwards failure without throwing', () async {
final repo = FakeNoteRepository(fail: true);
final result = await GetNotes(repo).call();
expect(result.failure, isA<LocalFailure>());
expect(result.notes, isEmpty);
});
}
Run:
flutter analyze
flutter test
| Symptom | Common cause | Fix |
|---|---|---|
| Import cycles / files importing each other | Domain/data importing presentation, or features importing each other's presentation | Check arrow direction: only presentation → domain ← data; features talk via routes, not widget imports |
The argument type 'NoteModel' can't be assigned to 'Note' | The impl returns a model where the contract demands an entity | Always convert with .toEntity() at the data → domain boundary |
ref.watch outside widgets/providers | DI wiring written in plain functions or constructors | Wire only inside Provider/Notifier or widgets via ConsumerWidget |
| Tests need a real database | The use case tested with the real NoteRepositoryImpl | Always test the domain with a fake implementing the interface |
| Refactor breaks working features | No snapshot commit + no per-feature manual test | Commit a snapshot first; after refactoring test each feature ±5 minutes and compare screenshots |
flutter analyze is clean and all tests pass.Refactor your Week 5 or 6 project into feature-first Clean Architecture (continue the same repository, not a new project):
notes or announcements) split into domain/ (entity, repository interface, use case, failure) + data/ (model, repository impl) + presentation/ (provider wiring, pages).screenshots/.docs/.07-week-7-clean-architecture/ (or continue the source week's folder + note the refactor in the README) with lib/, test/, docs/, README.md, and screenshots/. The README explains the objective, the layer diagram + dependency direction, main features, tech stack, how to run, and the result achieved.