SuperAPI AI Directory

flutter-data-layer

Coding & development

View repository

Updated 10-15 20:08

About this skill

Flutter Data layer

error handling integration

The types returned by the Data layer are flutter-error-handling defined in the skill, based on Result<D, E extends Error> . Platform exceptions are caught in the Data layer and converted to typed errors. For detailed rules, refer to the relevant skill.


DataSource vs Repository

  • DataSource: Accesses a single source. Handles only one single channel such as a remote API, local DB, filesystem, or clipboard. Most classes in the Data layer fall under this.
  • Repository: Combines multiple DataSources combineand groups them from a domain perspective. If only a single source is used, don't bother attaching the name "Repository".

Based on this project:

// 단일 소스 → DataSource
abstract interface class RecipeDataSource {
  Future<List<Map<String, dynamic>>> getRecipes();
}

// 도메인 관점의 접근 API → Repository
abstract interface class RecipeRepository {
  Future<List<Recipe>> getRecipes();
  Future<Recipe?> getRecipe(int id);
}

The Repository interface exchanges domain types (Recipe) , but the DataSource interface exchanges primitive types/DTOs (Map) . The key point is that mapping is handled by the Repository implementation.


Domain contract (lib/domain)

  • lib/domain/ is a pure Dart layer. package:flutter/... import prohibited.
  • Includes: domain models (freezed), Repository interfaces, error types, UseCase.
  • Every Repository used by a ViewModel must have an interface in this layer — to ensure Presentation cannot directly reference Data.
// lib/domain/repository/recipe_repository.dart
abstract interface class RecipeRepository {
  Future<List<Recipe>> getRecipes();
  Future<Recipe?> getRecipe(int id);
}

Domain model (freezed)

Models are always freezed + json_serializable created with the combination.

// lib/domain/model/recipe.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'recipe_ingredient.dart';

part 'recipe.freezed.dart';
part 'recipe.g.dart';

@freezed
class Recipe with _$Recipe {
  const factory Recipe({
    required String category,
    required int id,
    required String name,
    required String image,
    required String chef,
    required String time,
    required double rating,
    required List<RecipeIngredient> ingredients,
    @Default(false) bool isFavorite,
  }) = _Recipe;

  factory Recipe.fromJson(Map<String, Object?> json) => _$RecipeFromJson(json);
}

After modifying a file, always:

dart run build_runner build --delete-conflicting-outputs

DataSource implementation

remote

// lib/data/data_source/remote/remote_recipe_data_source_impl.dart
class RemoteRecipeDataSourceImpl implements RecipeDataSource {
  @override
  Future<List<Map<String, dynamic>>> getRecipes() async {
    // http 호출 또는 mock
    await Future.delayed(const Duration(microseconds: 500));
    return _mockData['recipes']!;
  }
}

local

// lib/data/data_source/local/default_local_storage.dart
class DefaultLocalStorage implements LocalStorage {
  // SharedPreferences, sqflite 등으로 구현
}

Naming convention (based on this project):

  • Interface: RecipeDataSource, LocalStorage — a name representing "what"
  • Implementation: RemoteRecipeDataSourceImpl, DefaultLocalStorage — a name representing "where/how" + Impl or a technology name prefix

Unlike the Android guidelines, this project consistently uses the Impl suffix for implementations, so follow the existing convention.


Repository implementation

The Repository implementation maps DataSource results to domain models mapping. Map<String, dynamic> Raw types like this must end here.

// lib/data/repository/mock_recipe_repository_impl.dart
class MockRecipeRepositoryImpl implements RecipeRepository {
  final RecipeDataSource _recipeDataSource;

  const MockRecipeRepositoryImpl({
    required RecipeDataSource recipeDataSource,
  }) : _recipeDataSource = recipeDataSource;

  @override
  Future<List<Recipe>> getRecipes() async {
    final recipes = await _recipeDataSource.getRecipes();
    return recipes.map(Recipe.fromJson).toList();
  }

  @override
  Future<Recipe?> getRecipe(int id) async {
    final recipes = await getRecipes();
    return recipes.where((e) => e.id == id).firstOrNull;
  }
}

Key:

  • The DataSource is injected in the constructor. get_it solves this.
  • Repository The interface is in domain , and the implementation is in data , so the dependency direction is maintained.
  • Mapping uses Recipe.fromJson freezed generation factories like this. Often no separate mapper file is needed.

Reactive store — BehaviorSubject pattern

This project, when multiple screens need to observe the same statelike bookmarks, rxdart 의 BehaviorSubject is used. Since it is a broadcast stream with the latest value, new subscribers can immediately receive the current state.

// lib/data/repository/mock_bookmark_repository_impl.dart
class MockBookmarkRepositoryImpl implements BookmarkRepository {
  final _ids = <int>{2, 3};
  final _controller = BehaviorSubject<Set<int>>();

  MockBookmarkRepositoryImpl() {
    _controller.add(_ids);
  }

  @override
  Stream<Set<int>> bookmarkIdsStream() => _controller.stream;

  @override
  Future<void> toggle(int id) async {
    if (_ids.contains(id)) {
      _ids.remove(id);
    } else {
      _ids.add(id);
    }
    _controller.add(_ids);
  }
}

When to use

  • When another screen needs to immediately see the latest state right after a mutation such as save/delete occurs.
  • When multiple features need to share the same data (bookmarks, cart, login state, etc.).

When not to use

  • Data that is used on only one screen and is sufficient to reload on re-entry → just Future return.

In UseCase, BehaviorSubject when composing a stream and a one-shot Future:

// lib/domain/use_case/get_saved_recipes_use_case.dart
Stream<List<Recipe>> execute() async* {
  final recipes = await _recipeRepository.getRecipes();

  await for (final ids in _bookmarkRepository.bookmarkIdsStream()) {
    yield recipes.where((e) => ids.contains(e.id)).toList();
  }
}

This pattern is clean: the Repository is responsible only for atomic data (the full list, a set of ids), and the UseCase composes it to fit the screen's purpose.


UseCase — Between Data and Presentation

A UseCase represents "one business action." The conventions in this project are:

  • Location: lib/domain/use_case/<verb>_<noun>_use_case.dart
  • Single entry point execute(...) expose only one
  • Stateless (fields are only injected dependencies)
  • Combine multiple Repositories, or apply domain rules (filtering/sorting)
  • Return type: Future<T>, Future<Result<D, E>>, or Stream<T>
class GetSavedRecipesUseCase {
  final RecipeRepository _recipeRepository;
  final BookmarkRepository _bookmarkRepository;

  const GetSavedRecipesUseCase({
    required RecipeRepository recipeRepository,
    required BookmarkRepository bookmarkRepository,
  })  : _recipeRepository = recipeRepository,
        _bookmarkRepository = bookmarkRepository;

  Stream<List<Recipe>> execute() async* { ... }
}

The ViewModel may call the Repository directly, but the moment multiple sources are mixed or domain rules come into play separate it into a UseCase.


Checklist — Adding a new DataSource / Repository

  • lib/domain/model/<name>.dart — freezed model
  • lib/domain/repository/<name>_repository.dart — abstract interface class
  • lib/data/data_source/{remote|local}/<name>_data_source_impl.dart — actual source access
  • lib/data/repository/<name>_repository_impl.dart — DataSource injection, mapping, Repository contract implementation
  • If needed, BehaviorSubject expose as a stream with
  • diSetup() register as an interface type in
  • dart run build_runner build --delete-conflicting-outputs

Anti-patterns

  • ❌ The Repository implementation exposes Map<String, dynamic> to the caller → mapping must end within the Data layer.
  • ❌ domain/ In the file, package:flutter/material.dart import → purity is broken.
  • ❌ Referencing the implementation class type from the ViewModel/UseCase → reference it through the interface.
  • ❌ Inside the UseCase, Repository handling caching/persistence that should be done by → responsibilities become blurred.