Last updated: September 27, 2026

Bahasa Indonesia | English

Learning Objectives

After completing this codelab, students will be able to:

Prerequisites

1. SOLID in one Flutter example

Five principles, one sentence each plus the smell of violating them:

PrinciplePractical meaningViolation smell in Flutter
Single ResponsibilityOne class has one reason to changeA 400-line widget that fetches an API, parses JSON, and formats dates at once
Open/ClosedAdd behavior by extension, not by editing old codeEvery new data source forces edits to already-working widgets
Liskov SubstitutionA replacement must substitute the original without breakingA fake test repository throwing errors the real one never throws
Interface SegregationDo not force clients to depend on what they never useOne giant AppRepository interface for auth + notes + push at once
Dependency InversionDepend on abstractions, not concretionsA notifier calling Dio / sqflite directly instead of a repository

2. Three layers and the dependency rule

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.

3. Feature-first vs layer-first

StructureShapeWhen to use it
feature-firstlib/features/notes/{data,domain,presentation}Multi-feature projects (this codelab's choice): one feature can be understood/removed without touching others
layer-firstlib/{data,domain,presentation} globallyTiny single-feature projects; fast but messy as features grow

4. Role vocabulary

1. Map files to layers

Open your Week 5 or 6 project and fill in this table in the README (example for campus_notify):

FileCurrent layerProblem
pages/home_page.dartpresentationcalls Dio directly? formats dates + parses JSON in the widget?
data/api_client.dartdataOK if only used by repositories, not widgets
providers/auth_provider.dartpresentation (state)OK if it only calls repositories/use cases
data/auth_repository.dartdata (+mixed contract)interface and implementation still one class

2. Flag three classic violations

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.

3. Sketch the target structure

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

1. Pure entity (domain)

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;
}

2. Failures and the repository contract (domain)

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.

3. Model and implementation (data)

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'));
    }
  }
}

4. Use case: one business operation

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();
  }
}

1. Dependency injection with Riverpod

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;
});

2. Pages only read state

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),
    );
  }
}

3. Verify the dependency rule

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

The role of AI in this codelab

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.

AI Prompt Challenge

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.

AI Verification Checklist

Before accepting the AI proposal, verify and record your findings in the README/docs:

Refactoring Challenge

  1. Apply the same pattern to a second feature (auth or announcements): entity + repository interface + impl + 1 use case + provider wiring. Do not leave one feature clean and the rest dirty.
  2. Extract date formatting and route parsing into pure functions in lib/core/format.dart so they can be tested without widgets.
  3. Convert all raw exceptions into user-friendly Failure objects before they reach the UI; the UI only ever receives a message.

Testing: use cases with a fake repository

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

Common errors and fixes

SymptomCommon causeFix
Import cycles / files importing each otherDomain/data importing presentation, or features importing each other's presentationCheck 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 entityAlways convert with .toEntity() at the data → domain boundary
ref.watch outside widgets/providersDI wiring written in plain functions or constructorsWire only inside Provider/Notifier or widgets via ConsumerWidget
Tests need a real databaseThe use case tested with the real NoteRepositoryImplAlways test the domain with a fake implementing the interface
Refactor breaks working featuresNo snapshot commit + no per-feature manual testCommit a snapshot first; after refactoring test each feature ±5 minutes and compare screenshots

Self-verification checklist

Mini project / Industry Challenge

Refactor your Week 5 or 6 project into feature-first Clean Architecture (continue the same repository, not a new project):

  1. At least 1 complete feature (notes or announcements) split into domain/ (entity, repository interface, use case, failure) + data/ (model, repository impl) + presentation/ (provider wiring, pages).
  2. Dependency rule proven: include the three sterility grep results in the README.
  3. Identical behavior: before/after screenshots per feature under screenshots/.
  4. At least 2 passing tests (success + failure use case with a fake repository).
  5. Complete the AI Challenge and document the prompt, the proposal-vs-final-decision table, and the technical rationale in docs/.
  6. Push to the portfolio repository under 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.

Reflection

Supporting references