junsuk5/survival-flutter-skills

flutter-data-layer

Flutter 프로젝트의 Data 레이어 패턴 — DataSource 인터페이스·구현, Repository, DTO와 도메인 모델 매핑, freezed 모델의 `fromJson`, 그리고 `rxdart`의 `BehaviorSubject`로 만드는 반응형 저장소. "DataSource 만들기", "Repository 구현", "리포지토리", "로컬/원격 데이터 소스", "BehaviorSubject", "스트림 저장소", "DTO 매핑", "fromJson" 같은 표현에 트리거합니다.

First seen May 11, 2026

Installation

$ npx skills add junsuk5/survival-flutter-skills --skill flutter-data-layer

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from junsuk5/survival-flutter-skills.

npx skills add junsuk5/survival-flutter-skills

Browse all from junsuk5/survival-flutter-skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 23
Default branch master
Open issues 0
Status Active

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 9,230 B
  • docs SUMMARY.md 461 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 41 installs

SKILL.md

Flutter Data 레이어

에러 처리 연계

Data 레이어가 반환하는 타입은 flutter-error-handling 스킬에서 정의한 Result<D, E extends Error> 를 기반으로 한다. 플랫폼 예외는 Data 레이어에서 catch 해 typed error 로 변환한다. 자세한 규칙은 해당 스킬을 참고.


DataSource vs Repository

  • DataSource: 단일 소스에 접근. 원격 API, 로컬 DB, 파일시스템, 클립보드 같은 단일 채널 하나만 다룬다. Data 레이어 대부분의 클래스가 여기 해당한다.
  • Repository: 여러 DataSource를 조합해 도메인 관점으로 묶는다. 단일 소스만 쓰는데 굳이 "Repository" 이름을 붙이지 않는다.

이 프로젝트 기준:

// 단일 소스 → 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);
}

Repository 인터페이스는 도메인 타입(Recipe) 을 주고받지만, DataSource 인터페이스는 원시 타입/DTO(Map) 를 주고받는다는 점이 핵심이다. 매핑은 Repository 구현체가 담당한다.


도메인 계약 (lib/domain)

  • lib/domain/ 은 순수 Dart 레이어다. package:flutter/... import 금지.
  • 포함: 도메인 모델(freezed), Repository 인터페이스, 에러 타입, UseCase.
  • ViewModel이 쓰는 모든 Repository는 이 레이어에 인터페이스가 있어야 한다 — Presentation 이 Data를 직접 참조하지 못하도록 보장하기 위함.
// lib/domain/repository/recipe_repository.dart
abstract interface class RecipeRepository {
  Future<List<Recipe>> getRecipes();
  Future<Recipe?> getRecipe(int id);
}

도메인 모델 (freezed)

모델은 항상 freezed + json_serializable 조합으로 만든다.

// 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);
}

파일 수정 후 반드시:

dart run build_runner build --delete-conflicting-outputs

DataSource 구현

원격

// 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']!;
  }
}

로컬

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

명명 규칙 (이 프로젝트 기준):

  • 인터페이스: RecipeDataSource, LocalStorage — "무엇"을 나타내는 이름
  • 구현: RemoteRecipeDataSourceImpl, DefaultLocalStorage — "어디/어떻게"를 나타내는 이름 + Impl 또는 기술명 접두어

Android 가이드라인과 다르게 이 프로젝트는 구현체에 Impl 접미어를 일관되게 사용하므로, 기존 컨벤션을 따른다.


Repository 구현

Repository 구현체는 DataSource 결과를 도메인 모델로 매핑한다. Map<String, dynamic> 같은 raw 타입은 여기서 끝나야 한다.

// 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;
  }
}

핵심:

  • 생성자에서 DataSource 를 주입받는다. get_it 이 이걸 해결한다.
  • Repository 인터페이스는 domain 에 있고, 구현은 data 에 있어 의존성 방향이 유지된다.
  • 매핑은 Recipe.fromJson 같은 freezed 생성 팩토리를 이용한다. 별도 mapper 파일이 필요 없을 때가 많다.

반응형 저장소 — BehaviorSubject 패턴

이 프로젝트는 북마크처럼 여러 화면이 같은 상태를 관찰해야 할 때 rxdart 의 BehaviorSubject 를 쓴다. 최근 값이 있는 브로드캐스트 스트림이므로 새 구독자가 즉시 현재 상태를 받을 수 있다.

// 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);
  }
}

언제 쓰나

  • 저장/해제 같은 변이가 생긴 직후 다른 화면이 즉시 최신 상태를 봐야 할 때.
  • 여러 feature가 동일한 데이터(북마크, 장바구니, 로그인 상태 등)를 공유해야 할 때.

언제 쓰지 말아야 하나

  • 한 화면에서만 쓰고 재진입 시 다시 불러오면 충분한 데이터 → 그냥 Future 반환.

UseCase 에서 BehaviorSubject 스트림과 일회성 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();
  }
}

이 패턴이 깔끔하다: Repository는 원자적 데이터(전체 목록, id 집합)만 책임지고, 유즈케이스가 그걸 화면 목적에 맞게 합성한다.


UseCase — Data와 Presentation 사이

UseCase는 "비즈니스 동작 하나"를 나타낸다. 이 프로젝트의 관례는:

  • 위치: lib/domain/usecase/<verb><noun>usecase.dart
  • 단일 진입점 execute(...) 하나만 공개
  • 상태 없음 (field 는 주입받은 의존성뿐)
  • 여러 Repository 를 조합하거나, 도메인 규칙(필터링/정렬)을 적용
  • 반환 타입: Future<T>, Future<Result<D, E>>, 또는 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* { ... }
}

ViewModel 은 Repository 를 직접 호출해도 되지만, 여러 소스를 섞거나 도메인 규칙이 끼어드는 순간 UseCase 로 분리한다.


체크리스트 — 새 DataSource / Repository 추가

  • lib/domain/model/<name>.dart — freezed 모델
  • lib/domain/repository/<name>_repository.dart — abstract interface class
  • lib/data/datasource/{remote|local}/<name>datasourceimpl.dart — 실제 소스 접근
  • lib/data/repository/<name>repositoryimpl.dart — DataSource 주입, 매핑, Repository 계약 구현
  • 필요하면 BehaviorSubject 로 스트림 노출
  • diSetup() 에 인터페이스 타입으로 등록
  • dart run build_runner build --delete-conflicting-outputs

안티 패턴

  • ❌ Repository 구현체가 Map<String, dynamic> 을 호출자에게 노출 → 매핑은 Data 레이어 안에서 끝나야 한다.
  • ❌ domain/ 파일에 package:flutter/material.dart import → 순수성이 깨진다.
  • ❌ 구현 클래스 타입을 ViewModel/UseCase 에서 참조 → 인터페이스로 참조하라.
  • ❌ UseCase 안에서 Repository 가 해야 할 캐싱/저장을 대신 처리 → 책임이 흐려진다.