Sparround

The dependency rule and the four circles

The term "clean architecture" comes from Robert C. Martin's 2012 article. It describes four concentric circles — from the centre outwards:

1. Entities — rules that belong to the business itself. 2. Use Cases — rules that belong to the application (one scenario, one operation). 3. Interface Adapters — the converting layer: controllers, presenters, repository implementations. 4. Frameworks and Drivers — external details: the UI framework, the database, the network.

One rule holds the whole structure together — the dependency rule: source-code dependencies may point only inwards. An inner circle knows nothing about an outer one. Concretely: an entity does not import http, a use case does not import Widget, a domain model does not import sqflite.

For Flutter the practical consequence is very specific: your business logic does not depend on Flutter. You verify it with dart test, with no device and no emulator.

CircleFlutter equivalentExample classWhat it may import
EntitiesDomain model`Product`, `Order`, `Money`Only `dart:core` and other domain models
Use CasesUse case / interactor (an optional layer)`PlaceOrder`, `LoadCatalog`Domain models and repository interfaces
Interface AdaptersRepository implementation, DTO, mapper, notifier`ProductRepositoryRemote`, `ProductDto`Domain + service interfaces + `json_serializable`
Frameworks and DriversWidgets, `http`/`dio`, `sqflite`, platform channels`ProductsPage`, `ApiClient`, `DatabaseService`Anything — this is the outermost circle

A reasonable question arises here: a use case calls a repository, and the repository calls the network. Doesn't the dependency point outwards then?

It does not — because there is an abstraction in between. This is called dependency inversion, and in Dart it is a one-line technique:

  • The domain layer declares abstract class ProductRepository — method signatures only.
  • The data layer writes class ProductRepositoryRemote implements ProductRepository.
  • The domain does not import the data layer; the data layer imports the domain.

That is how the arrow flips: at compile time the dependency points inwards (data → domain), while at run time the call goes outwards (use case → the concrete implementation). Which concrete implementation it is gets decided by a third party — dependency injection.

dart
// ╔══ QAYDAYI POZUR ══════════════════════════════════════╗
// fayl: lib/domain/product.dart
import 'package:http/http.dart';          // ❌ domain şəbəkəni tanıyır
import 'package:flutter/material.dart';   // ❌ domain UI-nı tanıyır

class Product {
  Product(this.title, this.priceCents);
  final String title;
  final int priceCents;

  // ❌ domain modeli özü sorğu atır
  static Future<Product> fetch(String id) async { /* http.get(...) */ }

  // ❌ domain modeli rəng qaytarır — bu, UI qərarıdır
  Color get badgeColor => priceCents > 10000 ? Colors.red : Colors.green;
}


// ╔══ QAYDAYI SAXLAYIR ═══════════════════════════════════╗
// fayl: lib/domain/models/product.dart  — təmiz Dart, import yoxdur
class Product {
  const Product({required this.id, required this.title, required this.price});
  final String id;
  final String title;
  final double price;      // artıq manatla: çevirmə mapper-də olub

  bool get isExpensive => price > 100;   // biznes qaydası — UI deyil
}

// fayl: lib/domain/repositories/product_repository.dart
// Yalnız müqavilə. Domain "necə" olduğunu bilmir, "nə" olduğunu bilir.
abstract class ProductRepository {
  Future<List<Product>> fetchActive();
  Future<Product> fetchById(String id);
}

// fayl: lib/data/repositories/product_repository_remote.dart
import 'package:my_app/domain/models/product.dart';            // ✅ data → domain
import 'package:my_app/domain/repositories/product_repository.dart';
import 'package:my_app/data/services/product_api_client.dart';

class ProductRepositoryRemote implements ProductRepository {
  ProductRepositoryRemote({required ProductApiClient apiClient})
      : _apiClient = apiClient;

  final ProductApiClient _apiClient;

  @override
  Future<List<Product>> fetchActive() async {
    final dtos = await _apiClient.getActiveProducts();
    return dtos.map((dto) => dto.toDomain()).toList();
  }

  @override
  Future<Product> fetchById(String id) async =>
      (await _apiClient.getProduct(id)).toDomain();
}

The dependency rule shows up in the import lines. The left-hand version breaks it, the right-hand one keeps it.

The nice thing about the dependency rule is that it is mechanically checkable. No discussion required — the absence of flutter/, http, dio, sqflite and shared_preferences in lib/domain is one grep away, and it can live in CI. Such a check ends the "is this import allowed?" argument in a team once and for all.

Practice. In your project (or a fresh small sample), create a lib/domain folder and put one model and one abstract class repository in it. Then run:

grep -rE "package:(flutter|http|dio|sqflite)" lib/domain || echo "domain is clean"

Done means: the command prints "domain is clean" and the repository's implementation lives under lib/data.

📚 Sources and documentation

  • The Clean Architecture (Robert C. Martin)blog.cleancoder.com

    The original source of the term: the four circles and the dependency rule. Not Flutter-specific — it is the general principle, which is exactly why it is worth reading.

  • Dart: class modifiersofficialdart.dev

    `abstract`, `interface`, `sealed`, `final`, `base` — what each modifier gives you when inverting a dependency.

  • Architecture conceptsofficialdocs.flutter.dev

    The vocabulary Flutter uses for these principles — layers, separation of concerns, single source of truth.