Skip to content
Taher FawazالعربيةContact
All articles

Clean Architecture in Flutter: separating layers without the overhead

Software Architecture6 min read

اقرأ بالعربية

Article cover: three nested circles, Domain at the centre, then Data, then UI, beside the title Clean Architecture in Flutter.

Many Flutter apps start clean and slowly turn into huge widget files that mix network calls, data mapping and business rules in one place. At that point every small change feels risky, and testing the logic without running the UI is close to impossible. Clean Architecture exists to solve exactly this problem, but it is often applied far more heavily than needed. This article takes the practical version: what to separate, why, and where to stop.

The problem we are actually solving

The goal is not to have many folders. The goal is that parts of the app can change for different reasons without breaking each other. The screen changes because the design changed. The JSON changes because the backend changed. A business rule changes because the product changed. If all of that lives in one widget, every change touches everything. Separating layers means each reason to change has one clear home.

The core rule: dependencies point inward

The one rule worth memorising: inner code (business rules) knows nothing about outer code (UI, network, database). The UI depends on the logic, and the concrete data implementation depends on a contract (an interface) that the logic defines, not the other way round. That is what lets you swap a data source, or test the logic with a simple fake, without running anything real.

Layers: three are enough for most apps

The official architecture guide in the Flutter documentation splits an app into a UI layer, a Data layer, and an optional Domain layer between them. We will use that split because it is practical and comes from the Flutter team itself.

UI layer: Views are the widgets that render state and forward user events; a ViewModel turns data into display-ready state and exposes commands the view calls. A view holds no business logic, only simple conditions for showing, hiding, layout and animation.

Data layer: a Repository is the single source of truth for one type of data. It handles caching, error handling and retries, and it returns domain models. Below it, a Service wraps exactly one data source, such as a REST API or a local file, and holds no state.

Domain layer (optional): plain business models and, when needed, Use Cases. Do not add it just because it appears in the diagrams; we will come back to this after the example.

Three columns: UI (ProductsScreen, ProductsViewModel), Domain (Product, ProductRepository interface) and Data (ProductRepositoryImpl, ProductApiService, ProductDto). UI uses Domain; Data implements Domain.
Dependency direction: the UI uses the Domain, and the Data layer implements the contract the Domain defines.

A practical example in Dart

The following example is illustrative and simplified (a product list), not code copied from a specific project. We start with the Domain: the model and the contract.

domain/product.dart
class Product {
  const Product({
    required this.id,
    required this.name,
    required this.priceInCents,
  });

  final String id;
  final String name;
  final int priceInCents;
}

// The contract: the Domain defines it, the Data layer implements it.
abstract interface class ProductRepository {
  Future<List<Product>> getProducts();
}

Notice that this file imports no package at all: no flutter, no http. That is the point. Now the Data layer: the DTO knows the JSON shape, the Service talks to the network, and the Repository connects them and returns a domain model.

data/product_repository_impl.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../domain/product.dart';

class ProductDto {
  const ProductDto({
    required this.id,
    required this.name,
    required this.priceInCents,
  });

  factory ProductDto.fromJson(Map<String, dynamic> json) => ProductDto(
        id: json['id'] as String,
        name: json['name'] as String,
        priceInCents: json['price_in_cents'] as int,
      );

  final String id;
  final String name;
  final int priceInCents;

  Product toDomain() =>
      Product(id: id, name: name, priceInCents: priceInCents);
}

class ProductApiService {
  ProductApiService(this._client, this._baseUrl);

  final http.Client _client;
  final String _baseUrl;

  Future<List<ProductDto>> fetchProducts() async {
    final response = await _client.get(Uri.parse('$_baseUrl/products'));
    if (response.statusCode != 200) {
      throw Exception('Failed to load products: ${response.statusCode}');
    }
    final list = jsonDecode(response.body) as List<dynamic>;
    return [
      for (final item in list)
        ProductDto.fromJson(item as Map<String, dynamic>),
    ];
  }
}

class ProductRepositoryImpl implements ProductRepository {
  ProductRepositoryImpl(this._service);

  final ProductApiService _service;

  @override
  Future<List<Product>> getProducts() async {
    final dtos = await _service.fetchProducts();
    return dtos.map((dto) => dto.toDomain()).toList();
  }
}

If the backend renames price_in_cents to something else, the change lives in the DTO file only. Neither the ViewModel nor the widget knows anything happened. Now the UI layer: the ViewModel depends on the contract, not on the implementation.

ui/products_view_model.dart
import 'package:flutter/foundation.dart';
import '../domain/product.dart';

class ProductsViewModel extends ChangeNotifier {
  ProductsViewModel(this._repository);

  final ProductRepository _repository;

  List<Product> products = const [];
  bool isLoading = false;
  Object? error;

  Future<void> load() async {
    isLoading = true;
    error = null;
    notifyListeners();
    try {
      products = await _repository.getProducts();
    } catch (e) {
      error = e;
    } finally {
      isLoading = false;
      notifyListeners();
    }
  }
}

The view listens to the ViewModel and only renders state; it knows nothing about http or JSON. At the app's entry point (the composition root) you create the objects and pass them along: Service, then Repository, then ViewModel, either by hand through constructors or with the dependency-injection tool you prefer. The tool is a detail; the direction of dependencies is what matters.

Left-to-right flow: View, ViewModel, Repository, Service. Solid arrows carry commands forward; dashed arrows carry data and state back.
Commands travel from the UI to the data source; data travels back as UI-ready state.

The immediate payoff is testing: instead of a real network call, you write a fake for the contract and test the ViewModel in seconds.

test/products_view_model_test.dart
class FakeProductRepository implements ProductRepository {
  FakeProductRepository({this.shouldFail = false});

  final bool shouldFail;

  @override
  Future<List<Product>> getProducts() async {
    if (shouldFail) throw Exception('network down');
    return const [Product(id: '1', name: 'Sample', priceInCents: 1000)];
  }
}

void main() {
  test('load exposes products', () async {
    final vm = ProductsViewModel(FakeProductRepository());
    await vm.load();
    expect(vm.products, hasLength(1));
    expect(vm.error, isNull);
  });

  test('load exposes error on failure', () async {
    final vm = ProductsViewModel(FakeProductRepository(shouldFail: true));
    await vm.load();
    expect(vm.products, isEmpty);
    expect(vm.error, isNotNull);
  });
}

When to add a Use Case, and when not to

The example above has no Use Case, on purpose. If a Use Case calls one repository method and returns its result unchanged, it is an extra layer with no value. The official documentation itself describes the Domain layer as optional, and suggests it when logic combines data from more than one repository, is very complex, or is reused by more than one ViewModel.

A simple decision rule: start without Use Cases. When you catch yourself copying the same logic between two ViewModels, or a ViewModel needs to combine two repositories, create a Use Case then. Also remember that repositories do not depend on each other; combining happens in the ViewModel or in a Use Case.

Common mistakes

First, passing the DTO straight to the UI. That ties the JSON shape to the screen and cancels the benefit of the separation. Always convert to a domain model at the repository boundary.

Second, putting business logic inside the widget, such as calculating a discount or validating an order inside build. It belongs in the ViewModel or the Domain.

Third, applying every layer to every feature equally. A simple settings screen does not need what a checkout flow needs. Choose the level of separation based on the feature's complexity and the cost of getting it wrong.

Fourth, mixed dependencies: a Domain file that imports flutter or http. If that happens, the core rule is broken, and it is the easiest violation to spot with a glance at the imports.

Folder structure

Folder layout is a secondary detail, but organising by feature (feature-first) usually makes maintenance easier because everything about a feature sits in one place. An illustrative example:

lib/
lib/
  features/
    products/
      domain/
        product.dart
      data/
        product_repository_impl.dart
      ui/
        products_view_model.dart
        products_screen.dart
  main.dart   # composition root: creates the objects and wires them together

Summary

Clean Architecture in Flutter is not a template to copy; it is one rule: logic does not depend on outer details. Let the view only render, the ViewModel shape state, the Repository own the data, and the Domain stay free of packages. Add a Use Case when a real reason appears, not before. If you want to go further, start with the official architecture guide in the Flutter documentation and adapt it to the size of your project.