Without clear layers, Flutter apps turn into giant widgets that are impossible to test or change.
Flutter's official architecture guide recommends separating concerns into layers:
- UI layer — views (widgets) that display state and forward user events, and view models (often
ChangeNotifiers) that hold UI state, expose commands, and convert domain data to what the view needs. Widgets contain no business logic. - Data layer — repositories are the source of truth for each data type (users, orders): they combine services, cache, retry and convert raw data into domain models. Services wrap one external source: a REST API, local database, platform plugin. They're stateless.
- Optionally a domain layer of use cases when logic spans several repositories.
Data flows one way: services → repositories → view models → views. Events go the other way: the view calls a view-model method, which calls a repository, which updates state, which notifies the view.
Dependency injection: construct services and repositories once (at app start), and pass them into view models through constructors. Use provider (or Riverpod, get_it) to make them available to the widget tree. This makes every layer testable with fakes.
Other recommendations: immutable domain models (freezed helps), a Result type instead of throwing across layers, feature-based folders (lib/ui/cart/, lib/data/repositories/), and the command pattern for actions with running/error state.
Scale the structure to the app — a two-screen app doesn't need use cases.
Example
import 'package:flutter/foundation.dart';
class Product { const Product(this.id, this.name); final int id; final String name; }
abstract interface class ProductApi { Future<List<Map<String, dynamic>>> fetchAll(); }
class ProductRepository { // data layer
ProductRepository(this._api);
final ProductApi _api;
List<Product>? _cache;
Future<List<Product>> products() async => _cache ??= [
for (final json in await _api.fetchAll()) Product(json['id'] as int, json['name'] as String)
];
}
class ProductListViewModel extends ChangeNotifier { // UI layer
ProductListViewModel(this._repo);
final ProductRepository _repo;
List<Product> items = [];
bool loading = false;
Object? error;
Future<void> load() async {
loading = true; error = null; notifyListeners();
try { items = await _repo.products(); } catch (e) { error = e; }
loading = false; notifyListeners();
}
}
// main: final vm = ProductListViewModel(ProductRepository(HttpProductApi()));Remember
- Views + view models (UI) and repositories + services (data)
- Data flows one way; events flow back through view-model methods
- Inject dependencies through constructors so layers can be faked in tests
Watch out for
- Calling HTTP directly from widgets, which makes them untestable and duplicates logic.