As Flutter projects grow in size and complexity, maintaining clean, modular, and testable code becomes essential. Many developers start with a simple structure—mixing business logic, UI, and data access in the same files—but this approach quickly becomes unmanageable as features expand.
That’s where Clean Architecture comes in.
Clean Architecture provides a well-defined way to separate responsibilities and enforce boundaries between layers of your app. It helps you write code that’s scalable, maintainable, and testable—no matter how large your app grows.
In this tutorial, we’ll walk you through building a Flutter app using Clean Architecture principles from the ground up. You’ll learn:
-
How to organize your code into Presentation, Domain, and Data layers.
-
How to implement Dependency Injection and State Management (with Bloc or Riverpod).
-
How to create testable Use Cases and Repositories.
-
How to connect it all together with real-world examples.
We’ll use a simple yet complete example: a Notes App that lets users create and display notes. The goal isn’t just to build the app, but to help you understand the architectural reasoning behind each piece.
By the end, you’ll have a scalable, production-ready Flutter project structure that you can adapt to any type of app—social, e-commerce, or enterprise.
Who is this tutorial for?
This tutorial is designed for Flutter developers who want to go beyond simple prototypes and build apps that can grow sustainably over time.
You’ll benefit from this guide if you are:
-
🧱 A Flutter developer who wants to improve project organization and learn scalable architecture patterns.
-
🧠 A backend or mobile engineer exploring how to apply software architecture concepts (like Domain-Driven Design and SOLID principles) in Flutter.
-
🧩 A team lead or architect seeking a standard approach for structuring Flutter projects across multiple features or teams.
-
🧪 A developer interested in testing, who wants to make their Flutter apps more testable and less dependent on framework code.
Even if you’re new to Clean Architecture, this step-by-step approach will help you understand each concept through practical examples and real project structure.
Prerequisites: Before following along, make sure you have:
-
Basic understanding of Flutter and Dart.
-
Flutter SDK installed and configured.
-
Familiarity with object-oriented programming and async/await.
Why Clean Architecture for Flutter
When developing Flutter apps, it’s easy to start fast—mixing widgets, state logic, and API calls in the same files. This works fine for small apps or quick prototypes. However, as your codebase and team grow, this approach becomes harder to maintain and scale.
That’s where Clean Architecture offers significant benefits.
1. Separation of Concerns
Clean Architecture divides your app into distinct layers with clearly defined responsibilities:
-
Presentation Layer: Handles the UI and user interaction.
-
Domain Layer: Contains business logic, entities, and use cases.
-
Data Layer: Deals with APIs, databases, and data mappers.
Each layer depends only on the inner layer, ensuring changes in one part of the app don’t break others.
2. Testability
Since business logic lives in pure Dart classes (not tied to Flutter), you can easily test it without rendering widgets or starting a simulator. This leads to faster and more reliable automated tests.
3. Maintainability and Scalability
A well-structured architecture helps large teams work in parallel without stepping on each other’s code. You can:
-
Replace APIs or databases without touching the presentation layer.
-
Refactor UI without modifying business logic.
-
Scale your app by adding more features or modules cleanly.
4. Framework Independence
Your Domain Layer doesn’t rely on Flutter or third-party packages. This makes your business rules portable and reusable across platforms (mobile, web, or desktop) and easier to migrate in the future.
5. Real-world Example
Imagine you’re building a note-taking app. Without architecture, you might call APIs directly inside a widget. Later, when you switch to offline storage or a new backend, you’ll have to rewrite much of the app. With Clean Architecture, you simply swap the Data Layer, leaving your UI and Domain untouched.
6. Summary
Clean Architecture helps Flutter developers build apps that are:
-
Modular — easier to navigate and extend.
-
Testable — logic can be verified independently.
-
Resilient — minimal ripple effects when refactoring.
-
Scalable — ready for large teams and long-term projects.
High-level Architecture Overview
Before diving into code, it’s essential to understand how Clean Architecture organizes your Flutter app at a high level. The architecture is structured into three core layers — Presentation, Domain, and Data — each with its own responsibilities and dependency rules.
The key principle is that dependencies flow inward: outer layers depend on inner layers, but never the other way around.
Here’s the general structure:
Presentation → Domain → Data
Let’s look at what each layer does.
🧩 Presentation Layer
The Presentation layer handles UI, user interaction, and state management. It includes widgets, pages, and state controllers such as Bloc, Riverpod, or Provider.
-
Depends on the Domain layer to trigger business logic.
-
Should not directly communicate with APIs or databases.
-
Example: When a user taps the “Add Note” button, a Bloc event calls a Use Case in the Domain layer.
Common contents:
/lib/features/notes/presentation/
├── pages/
├── widgets/
└── bloc/ or provider/
⚙️ Domain Layer
The Domain layer represents the core business logic of your application. It defines what your app does, independent of how it looks or where data comes from.
-
Contains Entities, Use Cases, and Repository Interfaces.
-
Pure Dart — no Flutter imports.
-
Highly testable and the most stable part of your codebase.
Common contents:
/lib/features/notes/domain/
├── entities/
├── usecases/
└── repositories/
🗄️ Data Layer
The Data layer is responsible for fetching and persisting data. It implements the repository interfaces from the Domain layer using one or more data sources.
-
Contains Models, Mappers, and Data Sources (e.g., REST API, local DB, Firebase).
-
Depends only on the Domain layer.
-
Converts raw data into Domain Entities for use by the app.
Common contents:
/lib/features/notes/data/
├── models/
├── datasources/
└── repositories_impl/
🔁 Flow of Control
Here’s how data and control flow between layers:
-
User Interaction (Presentation) — User triggers an action (e.g., tap a button).
-
Business Logic (Domain) — Bloc/ViewModel calls a Use Case.
-
Data Access (Data) — Use Case requests data from Repository → Data Source.
-
Result (Back to Presentation) — Data is processed and returned up the chain for display.
UI → Bloc → Use Case → Repository → Data Source → Repository → Bloc → UI
🧱 Core Principles
-
Dependency Inversion: Outer layers depend on abstractions (interfaces) defined in the Domain.
-
Testability: Each layer can be tested independently with mock dependencies.
-
Scalability: Adding new features or switching data sources doesn’t break existing logic.
With this foundation, we’re ready to set up our Flutter project and implement these layers in code.
Project Setup
In this section, we’ll set up the foundation for our Flutter Clean Architecture project. This includes creating a new Flutter app, organizing folders, and adding the required dependencies.
🛠️ Step 1: Create a new Flutter project
Open your terminal and create a new Flutter app:
flutter create flutter_clean_arch_notes
cd flutter_clean_arch_notes
You can replace flutter_clean_arch_notes with your preferred project name.
📦 Step 2: Add required dependencies
We’ll use several core packages to implement Clean Architecture effectively. Add these dependencies in your pubspec.yaml file or by running the commands below:
flutter pub add get_it
flutter pub add flutter_bloc
flutter pub add equatable
flutter pub add dartz
flutter pub add dio
flutter pub add sqflite
flutter pub add path
flutter pub add path_provider
flutter pub add mocktail --dev
flutter pub add bloc_test --dev
Explanation:
-
get_it → Service locator for dependency injection.
-
flutter_bloc / bloc → State management.
-
equatable → Simplifies equality checks for models and entities.
-
dartz → Functional programming utilities (e.g.,
Eitherfor failure handling). -
dio → HTTP client for API requests.
-
sqflite + path_provider → Local data storage.
-
mocktail + bloc_test → Testing utilities for mocking and Bloc tests.
💡 Optional alternatives: You can replace
flutter_blocwith Riverpod or Provider depending on your preference.
🧱 Step 3: Define folder structure
Let’s organize our codebase to reflect Clean Architecture principles. Create the following folder structure inside your lib directory:
/lib
/core
/error
/usecases
/network
/features
/notes
/data
/models
/datasources
/repositories_impl
/domain
/entities
/repositories
/usecases
/presentation
/bloc
/pages
/widgets
injection_container.dart
main.dart
Explanation:
-
core → Shared logic and utilities (e.g., error handling, base classes, constants).
-
features → Each feature (like
notes) has its own Clean Architecture structure. -
injection_container.dart → Centralized dependency injection setup.
-
main.dart → Entry point of the application.
This modular approach allows you to scale easily by adding more features without clutter.
⚙️ Step 4: Set up dependency injection boilerplate
We’ll use get_it to manage dependencies between layers. Create a new file called injection_container.dart in the lib folder:
import 'package:get_it/get_it.dart';
final sl = GetIt.instance;
Future<void> init() async {
// Register Blocs
// sl.registerFactory(() => NotesBloc(getAllNotes: sl(), addNote: sl()));
// Register Use Cases
// sl.registerLazySingleton(() => GetAllNotes(repository: sl()));
// sl.registerLazySingleton(() => AddNote(repository: sl()));
// Register Repository
// sl.registerLazySingleton<NotesRepository>(() => NotesRepositoryImpl(
// remoteDataSource: sl(),
// localDataSource: sl(),
// ));
// Register Data Sources
// sl.registerLazySingleton<NotesRemoteDataSource>(() => NotesRemoteDataSourceImpl(client: sl()));
// sl.registerLazySingleton<NotesLocalDataSource>(() => NotesLocalDataSourceImpl(database: sl()));
// External dependencies
// sl.registerLazySingleton(() => Dio());
}
We’ll fill in the real implementations later in the Dependency Injection section.
🧪 Step 5: Verify your setup
Run your app to confirm the initial setup works:
flutter run
If everything is configured correctly, the default Flutter counter app should launch.

This ensures your environment and dependencies are set up properly before we begin structuring the architecture.
Folder Structure and Conventions
A clean and consistent folder structure is key to maintaining scalability and readability in large Flutter projects. Following the Clean Architecture pattern, our structure separates responsibilities into three main layers: Presentation, Domain, and Data, plus a Core module for shared logic.
Let’s take a closer look at each layer and its conventions.
🧩 1. Core Layer
The core directory contains components that can be shared across multiple features.
Example structure:
/lib/core
/error
exceptions.dart
failures.dart
/network
network_info.dart
/usecases
usecase.dart
Purpose:
-
error/ → Defines exceptions and failure models.
-
network/ → Contains utilities like
NetworkInfoto check connectivity. -
usecases/ → Contains a base
UseCaseclass to standardize input and output types.
Convention:
-
Keep this layer lightweight and reusable.
-
Avoid any feature-specific code here.
🧠 2. Domain Layer
This is the heart of your app’s business logic and should have no Flutter dependencies.
Example structure:
/lib/features/notes/domain
/entities
note.dart
/repositories
notes_repository.dart
/usecases
get_all_notes.dart
add_note.dart
Purpose:
-
entities/ → Define core business models (e.g.,
Note). -
repositories/ → Define abstract repository interfaces.
-
usecases/ → Encapsulate specific business operations.
Convention:
-
Domain layer talks only to interfaces — it doesn’t know where data comes from.
-
Use cases return
Either<Failure, Type>for error-safe handling usingdartz.
💾 3. Data Layer
Implements the domain contracts (repositories and data sources). It interacts with APIs, local databases, or caches.
Example structure:
/lib/features/notes/data
/models
note_model.dart
/datasources
notes_remote_data_source.dart
notes_local_data_source.dart
/repositories_impl
notes_repository_impl.dart
Purpose:
-
models/ → Convert between JSON and domain entities.
-
datasources/ → Fetch data from remote (API) or local (SQLite) sources.
-
repositories_impl/ → Bridge data sources with domain layer.
Convention:
-
Use the Repository Pattern to combine data from multiple sources.
-
Keep the
modelclass separate fromentityto preserve domain integrity.
🖥️ 4. Presentation Layer
This layer manages UI, state management, and event handling.
Example structure:
/lib/features/notes/presentation
/bloc
notes_bloc.dart
/pages
notes_page.dart
add_note_page.dart
/widgets
note_card.dart
Purpose:
-
bloc/ → Manages UI logic using
flutter_bloc. -
pages/ → Screen-level widgets.
-
widgets/ → Reusable UI components.
Convention:
-
Follow the BLoC pattern: each feature has its own BLoC.
-
Avoid placing business logic directly in widgets.
⚙️ 5. Dependency Injection and Main Entry
Finally, the root level ties everything together:
/lib
injection_container.dart
main.dart
-
injection_container.dart → Centralized setup for dependencies using
get_it. -
main.dart → App entry point where DI is initialized before running the app.
Example snippet:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await init(); // Initialize dependencies
runApp(const MyApp());
}
📘 Naming Conventions Summary
| Type | Naming Convention | Example |
|---|---|---|
| Entities | PascalCase | Note |
| Models | PascalCase + Model suffix | NoteModel |
| Use Cases | Verb + Noun | GetAllNotes, AddNote |
| Data Sources | Suffix DataSource |
NotesRemoteDataSource |
| Repositories | Suffix Repository |
NotesRepositoryImpl |
| BLoCs | Suffix Bloc |
NotesBloc |
| Widgets | PascalCase | NoteCard |
By following this structure and naming scheme, you’ll ensure your Flutter app remains modular, scalable, and easy to maintain.
Implementing the Domain Layer
The Domain Layer is the core of the Clean Architecture. It defines what the app does (business logic) rather than how it does it. This layer should have no Flutter or third-party dependencies, keeping it pure and testable.
In this section, we’ll implement three main parts:
-
Entities
-
Repositories (abstract contracts)
-
Use Cases
🧱 Step 1: Create an Entity
An entity represents a fundamental business model. For this example, let’s create a Note entity.
Create a file at lib/features/notes/domain/entities/note.dart:
import 'package:equatable/equatable.dart';
class Note extends Equatable {
final int? id;
final String title;
final String content;
final DateTime createdAt;
const Note({
this.id,
required this.title,
required this.content,
required this.createdAt,
});
@override
List<Object?> get props => [id, title, content, createdAt];
}
Explanation:
-
We extend Equatable to make equality checks easier.
-
The entity doesn’t depend on any data layer logic (like JSON or database schema).
🧠 Step 2: Define a Repository Contract
A repository interface defines what actions the domain expects from the data layer — without caring about the implementation.
Create lib/features/notes/domain/repositories/notes_repository.dart:
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/note.dart';
abstract class NotesRepository {
Future<Either<Failure, List<Note>>> getAllNotes();
Future<Either<Failure, void>> addNote(Note note);
Future<Either<Failure, void>> deleteNote(int id);
}
Explanation:
-
We use
Either<Failure, Type>from dartz to handle success or failure responses. -
The repository methods return data or a Failure, ensuring robust error management.
⚙️ Step 3: Create Use Cases
A use case encapsulates a single business operation — it acts as the bridge between the UI layer and the domain logic.
3.1 Base UseCase Class
First, define a base use case in lib/core/usecases/usecase.dart:
import 'package:dartz/dartz.dart';
import '../error/failures.dart';
abstract class UseCase<Type, Params> {
Future<Either<Failure, Type>> call(Params params);
}
class NoParams {}
This abstract class enforces a consistent structure for all use cases.
3.2 GetAllNotes Use Case
Create lib/features/notes/domain/usecases/get_all_notes.dart:
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/note.dart';
import '../repositories/notes_repository.dart';
class GetAllNotes extends UseCase<List<Note>, NoParams> {
final NotesRepository repository;
GetAllNotes(this.repository);
@override
Future<Either<Failure, List<Note>>> call(NoParams params) async {
return await repository.getAllNotes();
}
}
3.3 AddNote Use Case
Create lib/features/notes/domain/usecases/add_note.dart:
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/note.dart';
import '../repositories/notes_repository.dart';
class AddNote extends UseCase<void, Note> {
final NotesRepository repository;
AddNote(this.repository);
@override
Future<Either<Failure, void>> call(Note note) async {
return await repository.addNote(note);
}
}
3.4 DeleteNote Use Case
Create lib/features/notes/domain/usecases/delete_note.dart:
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../repositories/notes_repository.dart';
class DeleteNote extends UseCase<void, int> {
final NotesRepository repository;
DeleteNote(this.repository);
@override
Future<Either<Failure, void>> call(int id) async {
return await repository.deleteNote(id);
}
}
✅ Domain Layer Summary
At this point, we’ve defined the entities, repository contract, and use cases — all independent of Flutter or any data source. This layer focuses purely on business rules.
You now have a solid foundation for Clean Architecture’s inner core.
Implementing the Data Layer
The Data Layer provides the actual implementation of the repository contracts defined in the domain layer. It interacts with data sources — both remote (API) and local (database) — and converts raw data into domain entities.
We’ll cover:
-
Data Models
-
Data Sources (Remote & Local)
-
Repository Implementation
🧩 Step 1: Create the Data Model
The model mirrors the domain entity but includes serialization and deserialization logic.
Create lib/features/notes/data/models/note_model.dart:
import '../../domain/entities/note.dart';
class NoteModel extends Note {
const NoteModel({
super.id,
required super.title,
required super.content,
required super.createdAt,
});
factory NoteModel.fromJson(Map<String, dynamic> json) {
return NoteModel(
id: json['id'],
title: json['title'],
content: json['content'],
createdAt: DateTime.parse(json['createdAt']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'content': content,
'createdAt': createdAt.toIso8601String(),
};
}
}
Key idea:
-
NoteModelextendsNoteto preserve entity properties. -
The model adds conversion logic (
toJson,fromJson).
🌐 Step 2: Remote Data Source
The remote data source handles communication with external APIs using Dio.
Create lib/features/notes/data/datasources/notes_remote_data_source.dart:
import 'package:dio/dio.dart';
import '../models/note_model.dart';
abstract class NotesRemoteDataSource {
Future<List<NoteModel>> getAllNotes();
Future<void> addNote(NoteModel note);
Future<void> deleteNote(int id);
}
class NotesRemoteDataSourceImpl implements NotesRemoteDataSource {
final Dio client;
NotesRemoteDataSourceImpl({required this.client});
@override
Future<List<NoteModel>> getAllNotes() async {
final response = await client.get('https://api.example.com/notes');
if (response.statusCode == 200) {
final data = response.data as List;
return data.map((json) => NoteModel.fromJson(json)).toList();
} else {
throw Exception('Failed to load notes');
}
}
@override
Future<void> addNote(NoteModel note) async {
await client.post('https://api.example.com/notes', data: note.toJson());
}
@override
Future<void> deleteNote(int id) async {
await client.delete('https://api.example.com/notes/$id');
}
}
Explanation:
-
Fetches and modifies data over HTTP.
-
Converts API responses into
NoteModelinstances.
💾 Step 3: Local Data Source
The local data source handles offline persistence using sqflite.
Create lib/features/notes/data/datasources/notes_local_data_source.dart:
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import '../models/note_model.dart';
abstract class NotesLocalDataSource {
Future<List<NoteModel>> getCachedNotes();
Future<void> cacheNotes(List<NoteModel> notes);
Future<void> addNote(NoteModel note);
Future<void> deleteNote(int id);
}
class NotesLocalDataSourceImpl implements NotesLocalDataSource {
static const tableName = 'notes';
late Database _db;
Future<void> _initDB() async {
final path = join(await getDatabasesPath(), 'notes.db');
_db = await openDatabase(
path,
version: 1,
onCreate: (db, version) {
return db.execute(
'CREATE TABLE $tableName(id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, content TEXT, createdAt TEXT)',
);
},
);
}
@override
Future<List<NoteModel>> getCachedNotes() async {
await _initDB();
final maps = await _db.query(tableName);
return List.generate(maps.length, (i) => NoteModel.fromJson(maps[i]));
}
@override
Future<void> cacheNotes(List<NoteModel> notes) async {
await _initDB();
await _db.delete(tableName);
for (final note in notes) {
await _db.insert(tableName, note.toJson());
}
}
@override
Future<void> addNote(NoteModel note) async {
await _initDB();
await _db.insert(tableName, note.toJson());
}
@override
Future<void> deleteNote(int id) async {
await _initDB();
await _db.delete(tableName, where: 'id = ?', whereArgs: [id]);
}
}
Explanation:
-
Manages SQLite database creation and queries.
-
Provides caching for offline capability.
🔗 Step 4: Repository Implementation
Finally, let’s implement the repository interface from the domain layer.
Create lib/features/notes/data/repositories_impl/notes_repository_impl.dart:
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../domain/entities/note.dart';
import '../../domain/repositories/notes_repository.dart';
import '../datasources/notes_local_data_source.dart';
import '../datasources/notes_remote_data_source.dart';
import '../models/note_model.dart';
class NotesRepositoryImpl implements NotesRepository {
final NotesRemoteDataSource remoteDataSource;
final NotesLocalDataSource localDataSource;
NotesRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
});
@override
Future<Either<Failure, List<Note>>> getAllNotes() async {
try {
final remoteNotes = await remoteDataSource.getAllNotes();
await localDataSource.cacheNotes(remoteNotes);
return Right(remoteNotes);
} catch (_) {
try {
final localNotes = await localDataSource.getCachedNotes();
return Right(localNotes);
} catch (e) {
return Left(CacheFailure());
}
}
}
@override
Future<Either<Failure, void>> addNote(Note note) async {
try {
final noteModel = NoteModel(
id: note.id,
title: note.title,
content: note.content,
createdAt: note.createdAt,
);
await remoteDataSource.addNote(noteModel);
await localDataSource.addNote(noteModel);
return const Right(null);
} catch (e) {
return Left(ServerFailure());
}
}
@override
Future<Either<Failure, void>> deleteNote(int id) async {
try {
await remoteDataSource.deleteNote(id);
await localDataSource.deleteNote(id);
return const Right(null);
} catch (e) {
return Left(ServerFailure());
}
}
}
Explanation:
-
Fetches data from the remote API and caches it locally.
-
Falls back to cached data if the remote request fails.
-
Maps between
NoteandNoteModelto maintain domain purity.
✅ Data Layer Summary
We’ve implemented the models, data sources, and repository — completing the outer data layer. This layer is now ready to interact with the domain and presentation layers.
At this stage, the app can fetch, cache, and modify data cleanly following Clean Architecture principles.
You can find other Dart files that are required for error and exception handling in the GitHub link that was provided at the end of this tutorial.
Implementing the Presentation Layer
The Presentation Layer handles UI rendering and user interaction. We’ll use Jetpack Compose with Material 3 for a clean, minimalist design and Navigation Compose for screen transitions.
1. Add Dependencies
In app/build.gradle.kts:
dependencies {
// Core
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.5")
// Compose
implementation("androidx.compose.ui:ui:1.7.3")
implementation("androidx.compose.material3:material3:1.3.0")
implementation("androidx.compose.ui:ui-tooling-preview:1.7.3")
debugImplementation("androidx.compose.ui:ui-tooling:1.7.3")
// Navigation
implementation("androidx.navigation:navigation-compose:2.8.3")
// ViewModel for Compose
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.5")
// Activity Compose integration
implementation("androidx.activity:activity-compose:1.9.3")
}
2. Define Navigation Routes
Create a simple sealed class for navigation:
ui/navigation/Screen.kt
package com.example.flutter_clean_arch_notes.feature_note.presentation.ui.navigation
sealed class Screen(val route: String) {
object NotesList : Screen("notes_list")
object AddNote : Screen("add_note")
}
3. Create the NotesList Screen
ui/notes/NotesListScreen.kt
package com.example.flutter_clean_arch_notes.feature_note.presentation.ui.notes
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.flutter_clean_arch_notes.feature_note.domain.model.Note
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotesListScreen(
notes: List<Note>,
onAddNoteClick: () -> Unit,
onDeleteNoteClick: (Note) -> Unit
) {
Scaffold(
topBar = {
TopAppBar(title = { Text("My Notes") })
},
floatingActionButton = {
FloatingActionButton(onClick = onAddNoteClick) {
Icon(Icons.Default.Add, contentDescription = "Add Note")
}
}
) { padding ->
if (notes.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text("No notes yet. Tap + to add one.")
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
items(notes) { note ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
shape = MaterialTheme.shapes.medium
) {
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(note.title, style = MaterialTheme.typography.titleMedium)
IconButton(onClick = { onDeleteNoteClick(note) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete Note")
}
}
}
}
}
}
}
}
4. Create the Add Note Screen
ui/notes/AddNoteScreen.kt
package com.example.flutter_clean_arch_notes.feature_note.presentation.ui.notes
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddNoteScreen(
navController: NavController,
onSaveNote: (String) -> Unit
) {
var title by remember { mutableStateOf("") }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Add Note") },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { if (title.isNotBlank()) onSaveNote(title) },
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(Icons.Default.Add, contentDescription = "Save Note")
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Note title") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
}
}
5. Integrate Navigation and ViewModel
ui/MainApp.kt
package com.example.flutter_clean_arch_notes.feature_note.presentation.ui
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.example.flutter_clean_arch_notes.feature_note.presentation.ui.navigation.Screen
import com.example.flutter_clean_arch_notes.feature_note.presentation.ui.notes.AddNoteScreen
import com.example.flutter_clean_arch_notes.feature_note.presentation.ui.notes.NotesListScreen
import com.example.flutter_clean_arch_notes.feature_note.domain.model.Note
@Composable
fun NotesApp(viewModel: NotesViewModel = viewModel()) {
val navController = rememberNavController()
val notes = viewModel.notes
NavHost(navController = navController, startDestination = Screen.NotesList.route) {
composable(Screen.NotesList.route) {
NotesListScreen(
notes = notes,
onAddNoteClick = { navController.navigate(Screen.AddNote.route) },
onDeleteNoteClick = { viewModel.deleteNote(it) }
)
}
composable(Screen.AddNote.route) {
AddNoteScreen(
onBackClick = { navController.popBackStack() },
onSaveNote = {
viewModel.addNote(Note(title = it, content = ""))
navController.popBackStack()
}
)
}
}
}
6. Connect ViewModel
ui/NotesViewModel.kt
package com.example.flutter_clean_arch_notes.feature_note.presentation.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import com.example.flutter_clean_arch_notes.feature_note.domain.usecase.AddNoteUseCase
import com.example.flutter_clean_arch_notes.feature_note.domain.usecase.DeleteNoteUseCase
import com.example.flutter_clean_arch_notes.feature_note.domain.usecase.GetNotesUseCase
import com.example.flutter_clean_arch_notes.feature_note.domain.model.Note
import com.example.flutter_clean_arch_notes.feature_note.domain.repository.NotesRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class NotesViewModel(
repository: NotesRepository
) : ViewModel() {
// ✅ Instantiate use cases with repository
private val getNotesUseCase = GetNotesUseCase(repository)
private val addNoteUseCase = AddNoteUseCase(repository)
private val deleteNoteUseCase = DeleteNoteUseCase(repository)
private val _notes = MutableStateFlow<List<Note>>(emptyList())
val notes: StateFlow<List<Note>> = _notes
init {
// Load initial notes
viewModelScope.launch {
getNotesUseCase().collectLatest { notesList ->
_notes.value = notesList
}
}
}
fun addNote(note: Note) {
viewModelScope.launch {
addNoteUseCase(note)
}
}
fun deleteNote(id: Int) {
viewModelScope.launch {
deleteNoteUseCase(id)
}
}
}
7. Launch from MainActivity
MainActivity.kt
package com.example.flutter_clean_arch_notes
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import com.example.flutter_clean_arch_notes.feature_note.presentation.ui.NotesApp
import com.example.flutter_clean_arch_notes.feature_note.presentation.ui.theme.NotesAppTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
NotesAppTheme {
NotesApp()
}
}
}
}
You can find other Kotlin files that are required for this presentation layer in the GitHub link that was provided at the end of this tutorial.
✅ Result:
A clean, minimalist Material 3 Notes App where:
-
The list screen shows all notes with a delete icon.
-
The FAB navigates to the Add Note screen.
-
The Add Note screen allows entering a new note and saving it.
-
All UI uses Material 3’s rounded corners, typography, and spacing defaults.
Testing and Debugging
Testing is a cornerstone of Clean Architecture. Each layer can be verified independently:
-
Domain layer → Unit tests (pure logic, no dependencies)
-
Data layer → Mocked repository tests
-
Presentation layer → UI tests (Widget tests) and ViewModel tests
We’ll focus on two key aspects:
-
Unit testing the ViewModel
-
UI testing the Notes list screen
1. Unit Testing the ViewModel
Since our NotesViewModel manages business logic and state, we’ll test it to ensure:
-
Adding a note updates the notes list
-
Deleting a note removes it correctly
Create a new file:
test/presentation/viewmodel/notes_viewmodel_test.dart
✅ Example: notes_viewmodel_test.dart
// lib/presentation/viewmodel/notes_view_model.dart
import 'package:flutter/foundation.dart';
import 'package:flutter_clean_arch_notes/core/usecases/usecase.dart';
import 'package:flutter_clean_arch_notes/features/notes/domain/entities/note.dart';
import 'package:flutter_clean_arch_notes/features/notes/domain/usecases/add_note.dart';
import 'package:flutter_clean_arch_notes/features/notes/domain/usecases/delete_note.dart';
import 'package:flutter_clean_arch_notes/features/notes/domain/usecases/get_all_notes.dart';
class NotesViewModel extends ChangeNotifier {
final GetAllNotes getNotesUseCase;
final AddNote addNoteUseCase;
final DeleteNote deleteNoteUseCase;
List<Note> _notes = [];
List<Note> get notes => _notes;
NotesViewModel({
required this.getNotesUseCase,
required this.addNoteUseCase,
required this.deleteNoteUseCase,
});
Future<void> loadNotes() async {
_notes = (await getNotesUseCase(NoParams())) as List<Note>;
notifyListeners();
}
Future<void> addNote(Note note) async {
await addNoteUseCase(note);
_notes.add(note);
notifyListeners();
}
Future<void> deleteNote(Note note) async {
await deleteNoteUseCase(note as int);
_notes.removeWhere((n) => n.id == note.id);
notifyListeners();
}
}
2. UI Testing (Compose UI)
Flutter equivalent: if this were a Flutter app, we’d use flutter_test.
For Jetpack Compose, use compose-ui-test to simulate user actions.
Create a file:
androidTest/presentation/ui/NotesListScreenTest.kt
✅ Example: NotesListScreenTest.kt
package com.example.flutter_clean_arch_notes.feature_note.presentation.ui.notes
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.example.flutter_clean_arch_notes.feature_note.domain.model.Note
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
class NotesListScreenTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun notesList_displaysNotesProperly() {
val notes = listOf(
Note(1, "First", ""),
Note(2, "Second", "")
)
composeRule.setContent {
NotesListScreen(
notes = notes,
onAddNoteClick = {},
onDeleteNoteClick = {}
)
}
composeRule.onNodeWithText("First").assertIsDisplayed()
composeRule.onNodeWithText("Second").assertIsDisplayed()
}
@Test
fun addButton_existsAndClickable() {
composeRule.setContent {
NotesListScreen(
notes = emptyList(),
onAddNoteClick = {},
onDeleteNoteClick = {}
)
}
composeRule.onNodeWithContentDescription("Add Note").assertExists()
}
}
✅ Explanation
-
Uses
createComposeRule()for UI testing. -
Verifies that notes render correctly and buttons exist.
-
Encourages test-driven UI changes.
3. Debugging Tips
-
Use Android Studio’s Layout Inspector
Inspect Composables’ structure and recomposition count. -
Enable
StrictMode(in debug builds)
Helps catch slow IO operations on the main thread. -
Use
Timberfor structured logging
ReplaceLog.d()withTimber.d()for readable logs. -
Flow debugging
Useflow.onEach { println(it) }.launchIn(scope)to trace data streams. -
Crashlytics or Sentry (production)
Add lightweight error monitoring for release builds.
✅ Summary
You’ve now learned how to:
-
Unit test your ViewModel logic
-
UI test your Compose screens
-
Apply structured debugging practices
With tests in place, your Flutter-style Clean Architecture Notes app (in Kotlin Compose form) is maintainable, scalable, and resilient.
Best Practices and Final Thoughts
Now that your cross-platform Notes App is complete — featuring Flutter UI, Kotlin Clean Architecture backend, and SQLite persistence — let’s go over key best practices and final recommendations to ensure maintainability, scalability, and reliability.
1. Follow Clean Architecture Principles Strictly
Keep the boundaries clear between:
-
Presentation (Flutter UI & ViewModels)
-
Domain (use cases, entities)
-
Data (repositories, data sources)
Each layer should only depend on abstractions, not concrete implementations. This makes refactoring or replacing parts (e.g., switching to Room, or adding remote sync) much easier.
2. Use Dependency Injection (DI)
Avoid manually instantiating repositories or use cases. Instead, use:
-
get_it(Flutter) for dependency injection and singleton management. -
Koin or Hilt (Kotlin) if you expand the Android-native part.
DI promotes testability and scalability as the app grows.
3. Handle Errors Gracefully
Instead of letting errors propagate, use Failure classes and Either or Result wrappers for safe data flow.
For instance, if reading from SQLite fails, show a friendly error message in the Flutter UI while logging the technical error for debugging.
4. Write Tests Early and Often
Separate your testing strategy into:
-
Unit Tests for UseCases and ViewModels (in Dart or Kotlin).
-
UI Tests for Flutter screens and Android Compose (if using hybrid modules).
-
Integration Tests for the database and data flow.
Keep test data deterministic and isolated to avoid flaky tests.
5. Database Optimization
Use transactions for batch operations and indexing on frequently queried columns like id or title in SQLite.
If your app grows, consider using Room (Kotlin) or Drift (Flutter) for type-safe ORM integration.
6. Consistent Code Style
Use code formatters and linters:
-
flutter format .ordart fix --applyfor Dart code. -
ktlintordetektfor Kotlin code.
Consistent code is easier to review, debug, and onboard new contributors.
7. Prepare for Scalability
If you plan to sync notes across devices:
-
Add a remote data source (e.g., Firebase Firestore or REST API).
-
Use the same repository pattern to merge local and remote data.
-
Handle offline mode and conflict resolution gracefully.
8. Continuous Integration (CI)
Set up CI/CD pipelines using GitHub Actions or Bitrise:
-
Run automated tests on every pull request.
-
Build and deploy APK or IPA files automatically.
This ensures early detection of bugs and consistent build quality.
9. Documentation and Comments
Document key parts of your architecture:
-
Purpose of each layer and dependency.
-
How data flows between Flutter and Kotlin.
-
Instructions for environment setup and testing.
Good documentation helps others (and your future self) understand and maintain the project easily.
Final Thoughts
By combining Flutter’s expressive UI and Kotlin’s robust architecture, you’ve built a modular, testable, and scalable Notes App.
This hybrid approach is not only a great portfolio project but also a practical pattern for real-world apps that need cross-platform UI with native performance and clean backend logic.
Next Steps
-
Add cloud synchronization using Firebase or Supabase.
-
Implement local notifications for reminders.
-
Explore platform channels to share logic between Dart and Kotlin.
-
Package the core Kotlin logic as a reusable library.
You can find the full source code on our GitHub.
That's just the basics. If you need more deep learning about Flutter and Dart, you can take the following cheap course:
- Flutter & Dart - The Complete Guide [2025 Edition]
- The Complete Flutter Development Bootcamp with Dart
- Complete Flutter Guide 2025: Build Android, iOS and Web apps
- Advanced Flutter: Build Enterprise-Ready Apps.
- Flutter , Nodejs, Express , MongoDB: Build Multi-Store App
- Flutter & Dart Essentials-Build Mobile Apps like a Pro
- Master Flutter with ML: Object Detection, OCR & Beyond
- Full-Stack Mobile Development: Flutter, Figma, and Firebase
- Dart & Flutter - Zero to Mastery [2025] + Clean Architecture
- Master Flame Game Engine with Flutter: Build 2D Mobile Games
Thanks!
