Sparround

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 recommendationPriorityWhat it means in practice
Use clearly defined data and UI layersHighestThe foundation of the list — separation of concerns is called the most important principle
Use the repository pattern in the data layerHighestOne repository per kind of data; the UI never reaches the API directly
Use view models and views in the UI layer (MVVM)HighestThe widget stays "dumb"; UI logic is tested in a separate class
Do not put logic in widgetsHighestAllowed exceptions: show/hide conditions, animation, device-based layout, simple routing
Use unidirectional data flowHighestData flows data → UI; user interactions travel back the other way as events
Use immutable data modelsHighestThe UI layer cannot mutate data by accident; changes happen only where they should
Use dependency injectionHighestAvoid globally accessible objects; the guide points at the `provider` package
Use abstract repository classesHighestDifferent implementations for different environments (remote, local, fake)
Make fakes for testingHighestA fake focuses on inputs and outputs and forces a simple interface
Use commands for user interactionMiddleStandardises events from the UI, reducing double taps and rendering errors
Use `freezed` or `built_value`MiddleImmutability, `copyWith`, `==` and JSON via code generation; build times grow in large apps
Use a domain layerConditionalOnly when complex logic crowds the view models; in most apps it is overhead
Separate API and domain modelsConditionalRecommended 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 modelNotifier / 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 stateAsyncValue (sealed in Riverpod 3, so a switch over 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.

dart
// ══ 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.