Flutter's official architecture: UI and data layers
The Flutter team's official architecture guide defines two main layers and treats a third as optional.
UI layer
- View — a composition of widgets. Only display logic stays here.
- View model — turns repository data into state for one screen, keeps that state across view rebuilds, and exposes callbacks (commands) to the view.
Data layer
- Repository — the single source of truth for model data: caching, retries, error handling, and converting raw data into domain models.
- Service — wraps one external API (REST, platform, file system, database) and holds no state.
Domain layer (optional) — use cases/interactors. The guide recommends it only once view models get too complex.
The guide equates this structure with MVVM. An important nuance: no particular state management library is required — the principles work equally well with Provider, Riverpod and BLoC.
| Official recommendation | Priority | What it means in practice |
|---|---|---|
| Use clearly defined data and UI layers | Highest | The foundation of the list — separation of concerns is called the most important principle |
| Use the repository pattern in the data layer | Highest | One repository per kind of data; the UI never reaches the API directly |
| Use view models and views in the UI layer (MVVM) | Highest | The widget stays "dumb"; UI logic is tested in a separate class |
| Do not put logic in widgets | Highest | Allowed exceptions: show/hide conditions, animation, device-based layout, simple routing |
| Use unidirectional data flow | Highest | Data flows data → UI; user interactions travel back the other way as events |
| Use immutable data models | Highest | The UI layer cannot mutate data by accident; changes happen only where they should |
| Use dependency injection | Highest | Avoid globally accessible objects; the guide points at the `provider` package |
| Use abstract repository classes | Highest | Different implementations for different environments (remote, local, fake) |
| Make fakes for testing | Highest | A fake focuses on inputs and outputs and forces a simple interface |
| Use commands for user interaction | Middle | Standardises events from the UI, reducing double taps and rendering errors |
| Use `freezed` or `built_value` | Middle | Immutability, `copyWith`, `==` and JSON via code generation; build times grow in large apps |
| Use a domain layer | Conditional | Only when complex logic crowds the view models; in most apps it is overhead |
| Separate API and domain models | Conditional | Recommended in large apps; in small ones it just adds verbosity |
The official guide's samples are written with ChangeNotifier and package:provider. If your stack is Riverpod, the mapping is one-to-one — only the syntax differs:
- View model →
Notifier/AsyncNotifier(with code generation,@riverpod class ... extends _$...). - Dependency injection → the providers themselves. Repositories and services are plain Dart classes; the providers that return them form the dependency graph.
- Command → a method on the notifier (
refresh(),submit()), with the view only calling it. - Loading / error state →
AsyncValue(sealedin Riverpod 3, so aswitchover it is checked for exhaustiveness).
One meaningful difference: in the official guide the view model is located through BuildContext, in Riverpod through ref. The practical upshot is that a notifier can be tested without raising a widget.
// ══ 1. data/services/product_api_client.dart ══ (state saxlamır)
class ProductApiClient {
ProductApiClient({required http.Client client}) : _client = client;
final http.Client _client;
Future<List<ProductDto>> getActiveProducts() async {
final response = await _client.get(
Uri.parse('https://api.example.com/v1/products?active=true'),
);
if (response.statusCode != 200) {
throw HttpException('status ${response.statusCode}');
}
final list = jsonDecode(response.body)['data'] as List<dynamic>;
return list
.map((e) => ProductDto.fromJson(e as Map<String, dynamic>))
.toList();
}
}
// ══ 2. data/repositories/product_repository_remote.dart ══ (həqiqətin mənbəyi)
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(); // DTO → domain
}
}
// ══ 3. presentation/products/product_list_notifier.dart ══ (view model)
part 'product_list_notifier.g.dart';
@riverpod
ProductApiClient productApiClient(Ref ref) =>
ProductApiClient(client: http.Client());
@riverpod
ProductRepository productRepository(Ref ref) =>
ProductRepositoryRemote(apiClient: ref.watch(productApiClientProvider));
@riverpod
class ProductList extends _$ProductList {
@override
Future<List<Product>> build() =>
ref.watch(productRepositoryProvider).fetchActive();
// "Command": view yalnız bunu çağırır, necə işlədiyini bilmir.
Future<void> refresh() => ref.refresh(productListProvider.future);
}
// ══ 4. presentation/products/products_page.dart ══ (view)
class ProductsPage extends ConsumerWidget {
const ProductsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(productListProvider);
// AsyncValue Riverpod 3-də sealed-dir: switch tam yoxlanılır.
return switch (state) {
AsyncData(:final value) => RefreshIndicator(
onRefresh: () =>
ref.read(productListProvider.notifier).refresh(),
child: ListView.builder(
itemCount: value.length,
itemBuilder: (_, i) => ProductTile(product: value[i]),
),
),
AsyncError(:final error) => ErrorView(error: error),
_ => const Center(child: CircularProgressIndicator()),
};
}
}One feature, four files: service → repository → notifier → view. Each file does the work of exactly one layer.
Practice. Build those four files for real, in your own project or a fresh flutter create app, against a real public REST endpoint. Write ProductDto by hand for now (fromJson); freezed comes in a later stage.
Done means: the screen shows data, the file products_page.dart contains no http, no jsonDecode and no JSON keys at all, and pull-to-refresh works.
📚 Sources and documentation
- Guide to app architectureofficialdocs.flutter.dev
The official description of view, view model, repository, service and the optional domain layer.
- Architecture recommendationsofficialdocs.flutter.dev
The source of the table above — each recommendation with its priority.
- Case study: the UI layerofficialdocs.flutter.dev
How view and view model look in real code, with a `ChangeNotifier` example.
- Riverpod: getting startedofficialriverpod.dev
Installing `flutter_riverpod`, `riverpod_annotation`, `riverpod_generator`, and the `@riverpod` syntax.