Flutter Clean Architecture: Build Scalable Apps Step-by-Step

by Didin J. on Oct 26, 2025 Flutter Clean Architecture: Build Scalable Apps Step-by-Step

Learn best practices for building a Flutter + Kotlin Clean Architecture Notes App, covering clean code structure, testing, scalability, and optimization tips.

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 layersPresentation, 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:

  1. User Interaction (Presentation) — User triggers an action (e.g., tap a button).

  2. Business Logic (Domain) — Bloc/ViewModel calls a Use Case.

  3. Data Access (Data) — Use Case requests data from Repository → Data Source.

  4. 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., Either for 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_bloc with 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.

Flutter Clean Architecture: Build Scalable Apps Step-by-Step - flutter run

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 NetworkInfo to check connectivity.

  • usecases/ → Contains a base UseCase class 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 using dartz.

💾 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 model class separate from entity to 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:

  1. Entities

  2. Repositories (abstract contracts)

  3. 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:

  1. Data Models

  2. Data Sources (Remote & Local)

  3. 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:

  • NoteModel extends Note to 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 NoteModel instances.

💾 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 Note and NoteModel to 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:

  1. Unit testing the ViewModel

  2. 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

  1. Use Android Studio’s Layout Inspector
    Inspect Composables’ structure and recomposition count.

  2. Enable StrictMode (in debug builds)
    Helps catch slow IO operations on the main thread.

  3. Use Timber for structured logging
    Replace Log.d() with Timber.d() for readable logs.

  4. Flow debugging
    Use flow.onEach { println(it) }.launchIn(scope) to trace data streams.

  5. 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 . or dart fix --apply for Dart code.

  • ktlint or detekt for 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:

Thanks!