App Architecture & Design Patterns

Clean Architecture: Layers & Principles

16 min Lesson 2 of 12

Clean Architecture: Layers & Principles

Clean Architecture, introduced by Robert C. Martin (Uncle Bob), is a software design philosophy that separates an application into concentric layers of responsibility. In Flutter, this means structuring your codebase so that business logic is completely independent of the UI framework, databases, and external services. The result is code that is testable, maintainable, and adaptable to change.

Why Clean Architecture? When every feature is tangled with Flutter widgets, API calls, and database queries in a single file, any change ripples unpredictably through the app. Clean Architecture enforces hard boundaries so that swapping a REST API for GraphQL, or replacing a local database with a cloud one, requires touching only the outermost layer — leaving all business logic untouched.

The Three Concentric Layers

Clean Architecture in Flutter is typically organised into three layers arranged from innermost (most stable) to outermost (most volatile):

1. Domain Layer (Innermost)

The Domain layer is the heart of the application. It contains pure Dart code — no Flutter imports, no third-party packages, no network or database dependencies. It defines:

  • Entities — plain Dart classes that model your core business objects (e.g., User, Product, Order)
  • Repository interfaces (abstract classes) — contracts that describe what data operations are possible, without specifying how they are performed
  • Use cases (interactors) — single-responsibility classes that execute one business rule (e.g., GetUserByIdUseCase, PlaceOrderUseCase)

Because the Domain layer has no external dependencies, it can be unit-tested with plain dart test — no mocking of Flutter internals required.

2. Data Layer (Middle)

The Data layer implements the repository interfaces defined in the Domain layer. It is responsible for all data operations and knows about external systems such as REST APIs, local SQLite databases, Firebase, and device storage. It contains:

  • Repository implementations — concrete classes that fulfil the domain contracts
  • Data sources — remote (HTTP clients) and local (database helpers, shared preferences)
  • Data models (DTOs) — classes with fromJson/toJson that map raw API/database responses to domain entities

3. Presentation Layer (Outermost)

The Presentation layer is where Flutter lives. Widgets, pages, and state management (Bloc, Riverpod, Provider, etc.) belong here. It calls use cases from the Domain layer and renders their results. It contains:

  • Pages / Screens — Flutter Widget subclasses that build the UI
  • State management classes — Cubits, ViewModels, Notifiers, Providers
  • Shared UI widgets — reusable components specific to this app

The Dependency Rule

The single most important rule of Clean Architecture is the Dependency Rule:

Source code dependencies must point inward only. No inner layer may know anything about an outer layer.

Concretely:

  • The Domain layer imports nothing from Data or Presentation
  • The Data layer imports Domain (to implement interfaces and return entities), but never Presentation
  • The Presentation layer imports Domain (to call use cases), and may import Data only through dependency injection — never directly calling a repository implementation by name
Common Violation: Importing a UserModel (a Data-layer DTO) directly into a Cubit or widget is a dependency rule violation. The Presentation layer should only ever see User (the Domain entity). The mapping happens inside the repository implementation in the Data layer.

Recommended Flutter Folder Structure

A clean Flutter project organises code by feature, with each feature containing its own three sub-folders:

lib/
  features/
    auth/
      domain/
        entities/
          user.dart            // Pure Dart entity
        repositories/
          auth_repository.dart // Abstract interface
        usecases/
          login_usecase.dart
          logout_usecase.dart
      data/
        models/
          user_model.dart      // DTO with fromJson/toJson
        datasources/
          auth_remote_datasource.dart
          auth_local_datasource.dart
        repositories/
          auth_repository_impl.dart
      presentation/
        pages/
          login_page.dart
        widgets/
          login_form.dart
        bloc/
          auth_bloc.dart
          auth_event.dart
          auth_state.dart
  core/
    error/
      failures.dart
      exceptions.dart
    usecases/
      usecase.dart             // Base UseCase interface
    di/
      injection_container.dart // Dependency injection setup

Domain Layer Code Example

Here is how the Domain layer looks in pure Dart — no Flutter or third-party imports:

// lib/features/auth/domain/entities/user.dart
class User {
  final String id;
  final String name;
  final String email;

  const User({
    required this.id,
    required this.name,
    required this.email,
  });
}

// lib/features/auth/domain/repositories/auth_repository.dart
// Abstract contract — Data layer must implement this
abstract class AuthRepository {
  Future<User> login({required String email, required String password});
  Future<void> logout();
  Future<User?> getCurrentUser();
}

// lib/features/auth/domain/usecases/login_usecase.dart
class LoginUseCase {
  final AuthRepository repository;

  const LoginUseCase(this.repository);

  // Single public method — encapsulates one business action
  Future<User> call({required String email, required String password}) {
    return repository.login(email: email, password: password);
  }
}

Data Layer Code Example

The Data layer implements the contract and handles JSON mapping. Notice it imports the Domain entity but never imports the Presentation layer:

// lib/features/auth/data/models/user_model.dart
import 'package:myapp/features/auth/domain/entities/user.dart';

class UserModel extends User {
  const UserModel({
    required super.id,
    required super.name,
    required super.email,
  });

  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['id'] as String,
      name: json['name'] as String,
      email: json['email'] as String,
    );
  }

  Map<String, dynamic> toJson() => {
    'id': id,
    'name': name,
    'email': email,
  };
}

// lib/features/auth/data/repositories/auth_repository_impl.dart
import 'package:myapp/features/auth/domain/entities/user.dart';
import 'package:myapp/features/auth/domain/repositories/auth_repository.dart';
import 'package:myapp/features/auth/data/datasources/auth_remote_datasource.dart';

class AuthRepositoryImpl implements AuthRepository {
  final AuthRemoteDataSource remoteDataSource;

  const AuthRepositoryImpl(this.remoteDataSource);

  @override
  Future<User> login({required String email, required String password}) async {
    // Returns a UserModel (Data), but the caller only sees User (Domain)
    return remoteDataSource.login(email: email, password: password);
  }

  @override
  Future<void> logout() => remoteDataSource.logout();

  @override
  Future<User?> getCurrentUser() => remoteDataSource.getCurrentUser();
}

Summary

Clean Architecture divides a Flutter app into three layers: Domain (pure business logic and entities), Data (repositories and data sources), and Presentation (Flutter widgets and state management). The Dependency Rule requires that every dependency arrow points inward — outer layers depend on inner layers, never the reverse. Enforcing this boundary keeps the Domain and its use cases fully testable in isolation and makes the entire codebase resilient to change.

Tip: Start every new feature by writing the Domain layer first (entities, repository interface, use cases) — without any imports from Flutter or third-party packages. If you cannot write a file with only import 'dart:async'; or other Dart-core imports, it probably does not belong in the Domain layer.