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.
| Circle | Flutter equivalent | Example class | What it may import |
|---|---|---|---|
| Entities | Domain model | `Product`, `Order`, `Money` | Only `dart:core` and other domain models |
| Use Cases | Use case / interactor (an optional layer) | `PlaceOrder`, `LoadCatalog` | Domain models and repository interfaces |
| Interface Adapters | Repository implementation, DTO, mapper, notifier | `ProductRepositoryRemote`, `ProductDto` | Domain + service interfaces + `json_serializable` |
| Frameworks and Drivers | Widgets, `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.
// ╔══ 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.