Loading Light/Dark Toggle
 All publications

<h1>

Scalable Flutter Apps  Part 2 - Domain Layer Deep Dive 

</h1>

Updated on Jul 15, 2025 . 11 min read

<article>

Domain Layer (Clean Architecture)

Picture this: You started building a Flutter app six months ago. It was supposed to be “simple” — just a few screens, some basic functionality. You threw everything into your lib folder, widgets calling APIs directly, business logic scattered across StatefulWidgets, and database calls wherever you needed them.

It worked! Your app was functional, users were happy, and you shipped on time. But then…ß

When “Simple” Becomes a Nightmare

Month 2: “Can we add user authentication?” You realize you need to modify 12 different files because authentication logic is everywhere.

Month 4: “The API response format changed slightly.” Breaking changes cascade through your entire app because widgets are directly coupled to API responses.

Month 6: “We need offline support.” You discover that adding a local database requires rewriting most of your app because data access is hardcoded everywhere.

Sound familiar? You’re not alone.

Clean Architecture: Your App’s Insurance Policy

Think of Clean Architecture like insurance for your codebase. You don’t buy insurance hoping for disasters — you buy it so that when disasters happen, you’re protected. Clean Architecture isn’t about over-engineering or showing off. It’s about building apps that can survive their own success. Clean Architecture isn’t just about organizing files into folders. It’s about organizing dependencies and responsibilities so that:

  • 🎯 Single Responsibility
  • 🔌 Loose Coupling
  • 🧪 High Testability
  • 📈 Easy Extensibility

Your Flashcards App Journey

Throughout this article series, we’re building a flashcards app using Clean Architecture. Not because flashcards are complex (they’re not), but because they demonstrate real-world scenarios:

  • CRUD operations (Create, Read, Update, Delete cards)
  • Business logic (spaced repetition, difficulty scoring)
  • Local persistence (Isar database)
  • User preferences (themes, languages)

You’ll see how Clean Architecture handles each of these challenges elegantly. Instead of technical debt, you’ll have technical assets. Let’s start building apps the right way.

Call Flow: How Data Moves Through Layers

Let’s trace how “Create a new flashcard” flows through the architecture:

📱 Step 1: User Interaction (Presentation)

User taps "Save" button on flashcard creation form
    ↓
Widget calls Cubit method
    ↓
Cubit validates UI inputs
    ↓
Cubit calls CreateFlashcardUseCase

🧠 Step 2: Business Logic (Domain)

CreateFlashcardUseCase receives request
    ↓
Validates business rules (front/back not empty)
    ↓
Creates Flashcard entity with business logic
    ↓
Calls FlashcardRepository.create() method

💾 Step 3: Data Persistence (Data)

FlashcardRepositoryImpl receives entity
    ↓
Converts Flashcard entity to FlashcardModel
    ↓
Calls IsarDataSource.create() method
    ↓
Saves to Isar database
    ↓
Returns success/failure result

🔄 Step 4: Response Flow (Back Up)

Database operation result
    ↓
Repository returns Either<Failure, Success>
    ↓
Use case returns result to Cubit
    ↓
Cubit updates UI state
    ↓
Widget rebuilds with new state

SOLID Principles in Practice

SOLID principles aren’t academic theory — they’re practical guidelines that solve real problems you’ll encounter when building apps. Let’s see how each principle protects your flashcards app from common disasters.

S — Single Responsibility Principle (SRP)

A class should have only one reason to change.

Each piece of code should have one job and one reason to be modified. If you find yourself saying “this class handles user authentication AND manages flashcard data AND formats dates,” you’ve violated SRP.

❌ Violating SRP — The “God Class”

// BAD: This class has multiple responsibilities class FlashcardManager { // Responsibility 1: Database operations Future<void> saveToDatabase(Flashcard card) { /*...*/ } Future<List<Flashcard>> loadFromDatabase() { /*...*/ } // Responsibility 2: Business logic double calculateDifficulty(int correctAnswers, int totalAnswers) { /*...*/ } bool shouldShowCard(Flashcard card) { /*...*/ } // Responsibility 3: UI formatting String formatCardText(String text) { /*...*/ } Color getCardColor(double difficulty) { /*...*/ } // Responsibility 4: Network operations Future<void> syncWithServer() { /*...*/ } // Responsibility 5: User preferences void saveThemePreference(ThemeMode theme) { /*...*/ } }

✅ Following SRP — Focused Classes

// GOOD: Each class has a single responsibility // Responsibility 1: Business logic only class Flashcard { final String front; final String back; final double difficulty; bool shouldReview() { // Pure business logic return lastReviewDate.isBefore( DateTime.now().subtract(Duration(days: getDaysUntilReview())) ); } } // Responsibility 2: Database operations only class FlashcardLocalDataSource { Future<void> save(FlashcardModel model) { /*...*/ } Future<List<FlashcardModel>> getAll() { /*...*/ } } // Responsibility 3: Business orchestration only class CreateFlashcardUseCase { Future<Either<Failure, void>> call(CreateFlashcardParams params) { // Validate and coordinate business operation } } // Responsibility 4: UI state management only class FlashcardListCubit extends Cubit<FlashcardListState> { void loadFlashcards() { /*...*/ } void selectCard(String id) { /*...*/ } }

O — Open/Closed Principle (OCP)

Software entities should be open for extension, but closed for modification.

You should be able to add new features without changing existing code. Your core classes should be stable foundations that new functionality builds upon.

❌ Violating OCP — Modification Nightmare

// BAD: Adding new features requires modifying existing code class FlashcardService { Future<void> save(Flashcard card, String storageType) { if (storageType == 'local') { // Save to local database await _localDb.save(card); } else if (storageType == 'firebase') { // Save to Firebase await _firebaseDb.save(card); } else if (storageType == 'sqlite') { // New requirement! // Now we have to modify this class await _sqliteDb.save(card); } // Every new storage type requires modifying this method! } }

✅ Following OCP — Extension Without Modification

// GOOD: Use abstractions to enable extension // Stable abstraction (closed for modification) abstract class FlashcardRepository { Future<Either<Failure, void>> save(Flashcard card); Future<Either<Failure, List<Flashcard>>> getAll(); } // Extensions (open for extension) class IsarFlashcardRepository implements FlashcardRepository { Future<Either<Failure, void>> save(Flashcard card) { // Isar implementation } } class FirebaseFlashcardRepository implements FlashcardRepository { Future<Either<Failure, void>> save(Flashcard card) { // Firebase implementation } } class SqliteFlashcardRepository implements FlashcardRepository { Future<Either<Failure, void>> save(Flashcard card) { // SQLite implementation - NO existing code modified! } } // Business logic remains unchanged class CreateFlashcardUseCase { final FlashcardRepository _repository; // Works with any implementation! Future<Either<Failure, void>> call(Flashcard card) { return _repository.save(card); // Same code, different behaviors } }

L — Liskov Substitution Principle (LSP)

Objects of a supertype should be replaceable with objects of their subtypes without altering the correctness of the program.

Any implementation of an interface should work exactly like any other implementation from the perspective of the code using it. No surprises, no special cases.

❌ Violating LSP — Unexpected Behavior

// BAD: Implementations have different contracts abstract class FlashcardRepository { Future<List<Flashcard>> getAll(); } class LocalFlashcardRepository implements FlashcardRepository { Future<List<Flashcard>> getAll() async { // Returns all flashcards from local database return await _localDb.getAllFlashcards(); } } class CloudFlashcardRepository implements FlashcardRepository { Future<List<Flashcard>> getAll() async { // VIOLATION: Throws exception if no internet! if (!await _hasInternet()) { throw NetworkException('No internet connection'); } return await _cloudApi.getFlashcards(); } } // This code will break with CloudFlashcardRepository! class FlashcardListCubit { void loadFlashcards() { // Expects getAll() to always return a list, never throw final cards = await _repository.getAll(); emit(FlashcardListLoaded(cards)); } }

✅ Following LSP — Consistent Contracts

// GOOD: All implementations follow the same contract abstract class FlashcardRepository { Future<Either<Failure, List<Flashcard>>> getAll(); } class LocalFlashcardRepository implements FlashcardRepository { Future<Either<Failure, List<Flashcard>>> getAll() async { try { final cards = await _localDb.getAllFlashcards(); return right(cards); } catch (e) { return left(DatabaseFailure(e.toString())); } } } class CloudFlashcardRepository implements FlashcardRepository { Future<Either<Failure, List<Flashcard>>> getAll() async { try { if (!await _hasInternet()) { return left(NetworkFailure('No internet connection')); } final cards = await _cloudApi.getFlashcards(); return right(cards); } catch (e) { return left(NetworkFailure(e.toString())); } } } // This code works with ANY repository implementation! class FlashcardListCubit { void loadFlashcards() { final result = await _repository.getAll(); result.fold( (failure) => emit(FlashcardListError(failure.message)), (cards) => emit(FlashcardListLoaded(cards)), ); } }

I — Interface Segregation Principle (ISP)

No client should be forced to depend on methods it does not use.

Create small, focused interfaces rather than large, monolithic ones. Classes should only implement methods they actually need.

❌ Violating ISP — The “God Interface”

// BAD: Massive interface forces unnecessary dependencies abstract class FlashcardDataManager { // Local database operations Future<void> saveToLocal(Flashcard card); Future<List<Flashcard>> getFromLocal(); // Cloud operations Future<void> syncToCloud(); Future<void> downloadFromCloud(); // Analytics operations Future<void> trackCardCreation(); Future<void> trackStudySession(); // User preferences Future<void> saveTheme(ThemeMode theme); Future<ThemeMode> getTheme(); // Export operations Future<void> exportToCsv(); Future<void> exportToPdf(); // Backup operations Future<void> createBackup(); Future<void> restoreBackup(); } // PROBLEM: This class only needs local storage but must implement everything! class SimpleLocalRepository implements FlashcardDataManager { Future<void> saveToLocal(Flashcard card) { /* Actually needed */ } Future<List<Flashcard>> getFromLocal() { /* Actually needed */ } // Forced to implement methods it doesn't need Future<void> syncToCloud() => throw UnimplementedError('No cloud support'); Future<void> trackCardCreation() => throw UnimplementedError('No analytics'); Future<void> exportToCsv() => throw UnimplementedError('No export'); // ... 6 more unimplemented methods! }

✅ Following ISP — Focused Interfaces

// GOOD: Small, focused interfaces abstract class FlashcardRepository { Future<Either<Failure, void>> save(Flashcard card); Future<Either<Failure, List<Flashcard>>> getAll(); Future<Either<Failure, Flashcard>> getById(String id); } abstract class CloudSyncService { Future<Either<Failure, void>> syncToCloud(); Future<Either<Failure, void>> downloadFromCloud(); } abstract class AnalyticsService { Future<void> trackCardCreation(); Future<void> trackStudySession(); } abstract class ExportService { Future<Either<Failure, void>> exportToCsv(); Future<Either<Failure, void>> exportToPdf(); } // Clean implementations that only implement what they need class IsarFlashcardRepository implements FlashcardRepository { // Only implements repository methods - clean and focused } class FirebaseCloudSyncService implements CloudSyncService { // Only implements cloud sync - no unrelated methods } class GoogleAnalyticsService implements AnalyticsService { // Only implements analytics - simple and focused }

D — Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions.

Your business logic shouldn’t care about implementation details. Instead of depending on concrete classes, depend on interfaces. This is the foundation of Clean Architecture.

❌ Violating DIP — Concrete Dependencies

// BAD: High-level business logic depends on low-level details class CreateFlashcardUseCase { // Direct dependency on concrete implementations final IsarDatabase _database; final FirebaseAuth _auth; final GoogleAnalytics _analytics; CreateFlashcardUseCase(this._database, this._auth, this._analytics); Future<void> call(String front, String back) async { // Business logic tightly coupled to implementation details final user = await _auth.getCurrentUser(); final card = Flashcard(front: front, back: back, userId: user.id); await _database.flashcards.insert(card.toIsarModel()); await _analytics.track('flashcard_created'); } }

✅ Following DIP — Abstract Dependencies

// GOOD: Depend on abstractions, not concretions // High-level business logic class CreateFlashcardUseCase { // Dependencies on abstractions only final FlashcardRepository _repository; final AuthService _authService; final AnalyticsService _analytics; CreateFlashcardUseCase(this._repository, this._authService, this._analytics); Future<Either<Failure, void>> call(CreateFlashcardParams params) async { // Pure business logic, no implementation details final userResult = await _authService.getCurrentUser(); return userResult.fold( (failure) => left(failure), (user) async { final card = Flashcard( front: params.front, back: params.back, userId: user.id, createdAt: DateTime.now(), ); final saveResult = await _repository.save(card); if (saveResult.isRight()) { await _analytics.trackCardCreation(); } return saveResult; }, ); } } // Low-level implementations depend on the same abstractions class IsarFlashcardRepository implements FlashcardRepository { // Implements the abstraction using Isar } class FirebaseAuthService implements AuthService { // Implements the abstraction using Firebase }

SOLID Principles Working Together

SOLID principles aren’t isolated rules — they work together to create robust architecture:

// S: Single Responsibility - Each class has one job class CreateFlashcardUseCase { // Only handles flashcard creation logic // D: Dependency Inversion - Depends on abstractions final FlashcardRepository _repository; CreateFlashcardUseCase(this._repository); Future<Either<Failure, void>> call(CreateFlashcardParams params) { // Business logic here } } // I: Interface Segregation - Small, focused interface abstract class FlashcardRepository { Future<Either<Failure, void>> save(Flashcard card); Future<Either<Failure, List<Flashcard>>> getAll(); } // L: Liskov Substitution - All implementations work the same way // O: Open/Closed - Add new repositories without changing use cases class IsarFlashcardRepository implements FlashcardRepository { /*...*/ } class FirebaseFlashcardRepository implements FlashcardRepository { /*...*/ } class MockFlashcardRepository implements FlashcardRepository { /*...*/ }

Remember: SOLID principles aren’t restrictions — they’re guidelines that free you from the chaos of poorly structured code.

Building Our Feature: “Create a Flashcard”

Let’s walk through implementing “Create a Flashcard” to see how domain layer concepts work together.

📋 Business Requirements

As a user, I want to create a flashcard so I can study later.

Business Rules:

  • ✅ Front text is required and cannot be empty
  • ✅ Back text is required and cannot be empty
  • ✅ Card should have a unique ID
  • ✅ Card should track creation date
  • ✅ New cards start with default difficulty (0.5)
  • ✅ Card must belong to a deck
  • ✅ Front and back text should be trimmed of whitespace

Now let’s implement this step by step…

Step 1: Entities — Your Business Objects

Entities are business objects that represent the core concepts of your domain. They contain:

  • Data that defines the object
  • Business rules that govern the object
  • Behaviors the object can perform

Think of entities as smart data containers that know their own rules.

import 'package:equatable/equatable.dart'; class Flashcard extends Equatable { final String id; final String front; final String back; final DateTime createdAt; final DateTime? lastReviewedAt; final int reviewCount; final double difficulty; // 0.0 (easy) to 1.0 (hard) final String deckId; ... }

Step 2: Error Handling — Predictable Failures

Instead of throwing exceptions that can crash your app, we use Either types for predictable error handling.

// core/error/failure.dart abstract class Failure { const Failure([List properties = const <dynamic>[]]); } class ServerFailure extends Failure { final String? message; const ServerFailure(this.message); bool operator ==(Object other) => other is ServerFailure && other.message == message; int get hashCode => message.hashCode; } ...

Step 3: Repository Contracts — Defining Data Needs

Repository contracts define WHAT data operations your business logic needs, without specifying HOW they’re implemented.

// features/cards/domain/repositories/flashcard_repository.dart import 'package:fpdart/fpdart.dart'; import 'package:flutter_flashcarte_app/core/error/failure.dart'; import 'package:flutter_flashcarte_app/features/cards/domain/entities/flashcard_entity.dart'; abstract interface class FlashcardRepository { Future<Either<Failure, String>> create(Flashcard data); Future<Either<Failure, List<Flashcard>>> getAll(); Future<Either<Failure, Flashcard>> getById(int id); Future<Either<Failure, Unit>> delete(int id); Future<Either<Failure, Flashcard>> update(Flashcard data); }

Step 4: Use Cases — Orchestrating Business Operations

Use Cases (also called Interactors) represent specific business operations. They:

  • Coordinate between entities and repositories
  • Validate business rules
  • Handle complex workflows
  • Return results in a consistent format
... // features/cards/domain/usecases/usecases.dart class CreateFlashcard implements UseCase<String, CreateFlashcardParams> { final FlashcardRepository _repo; const CreateFlashcard(this._repo); Future<Either<Failure, String>> call(CreateFlashcardParams params) async { // Step 1: Validate input final validationResult = _validateInput(params); if (validationResult != null) { return left(validationResult); } // Step 2: Create entity with business logic final flashcard = Flashcard( id: _generateId(), front: params.front.trim(), back: params.back.trim(), createdAt: DateTime.now(), deckId: params.deckId, // difficulty defaults to 0.5 in entity constructor ); // Step 3: Persist through repository return await _repo.create(flashcard); } ... }

Step 5: Data Source Contracts — Defining Raw Data Access

While repositories define business-oriented data operations, data sources define raw data access patterns. They represent the lowest level of data interaction without business logic.

Think of it this way:

  • Repository: “Get flashcards for study session” (business context)
  • Data Source: “Get flashcard records from storage” (technical operation)
// features/cards/domain/datasource/flashcard_datasource.dart import 'package:fpdart/fpdart.dart'; import 'package:flutter_flashcarte_app/core/error/failure.dart'; import 'package:flutter_flashcarte_app/features/cards/domain/entities/flashcard_entity.dart'; abstract interface class FlashcardDataSource { Future<Either<Failure, String>> create(Flashcard data); Future<Either<Failure, List<Flashcard>>> getAll(); Future<Either<Failure, Flashcard>> getById(int id); Future<Either<Failure, Unit>> delete(int id); Future<Either<Failure, Flashcard>> update(Flashcard data); }

Remember the chaos we talked about in the introduction? The “simple” app that became a nightmare to maintain? With your domain layer in place, you’ve broken that cycle:

  • New business rules? Add them to entities or use cases
  • Different validation requirements? Modify the use case logic
  • Complex business operations? Create new use cases that orchestrate existing contracts
  • Edge cases discovered? Handle them with new failure types

Your domain layer is your insurance policy against the unpredictable nature of software requirements.

🚀 Ready for the Next Challenge?

You have a beautiful domain layer, but it’s like having a perfect recipe without a kitchen. Your entities know what they should do, your use cases know how to orchestrate operations, but you still need to make it work with real data. In our next article, we’ll bring your domain layer to life by:

  • 🗄️ Implementing Your Repository Contracts
  • 🔄 Models, Mappers, and Data Sources
  • 💾 Isar Database Integration

The best part? Your domain layer won’t change at all. That’s the power of clean architecture — layers work independently while collaborating seamlessly.

If you have questions or want specific concepts covered in more depth, drop a comment or message me so we can refine this series together.

You can find the initial code in this repository. If you think this repository has been useful to you, do not hesitate to leave your star!.


You can find this and other posts on my Medium profile some of my projects on my Github or on my LinkedIn profile.

¡Thank you for reading this article!

If you want to ask me any questions, don't hesitate! My inbox will always be open. Whether you have a question or just want to say hello, I will do my best to answer you!

</article>