flutter-project-structure について
単一パッケージの Clean Architecture を用いる Flutter プロジェクト向けの構成ガイドです。機能やレイヤーを整理する開発者に、依存ルールと機能追加のチェックリストを示します。
主な機能
- 01単一パッケージのレイヤー構成
- 02純粋な Dart ドメイン層の規則
- 03機能分離と共有コードの指針
- 04機能追加チェックリスト
- 05DI とルーティングの配置
活用シーン
- 01Flutter の lib/ 構成の計画
- 02機能ごとのモデルやユースケースの配置
- 03レイヤー間・機能間依存の確認
- 04ルーティングと DI 登録場所の統一
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ junsuk5/ survival-flutter-skills --skill flutter-project-structureインストール後、Claude Code で/flutter-project-structureを入力して読み込まれたか確認します。
詳細
Flutter プロジェクト構造(単一パッケージ/クリーンアーキテクチャ)
基本原則
- 単一パッケージ、レイヤー分離:Android/KMP のように Gradle マルチモジュールを使わず、
lib/の下でレイヤーごとに分離します。 - クリーンアーキテクチャの依存方向:
presentation→domain←data。domainは何にも依存しない純粋な Dart レイヤーです。 - Feature は
presentationの下にフォルダーとして配置します。 複数の画面で共有するコードはcore/に置き、単一の機能でのみ使うコードは、その feature フォルダー内に置きます。 - Feature 間の直接参照は禁止です。機能をまたいで共有するロジックは、
core(共有ウィジェット・ユーティリティ)またはdomain(モデル・ユースケース)に移します。
ディレクトリ構成
このプロジェクトは以下の構造に従います(lib/ を基準とします)。
lib/
├─ main.dart ← 앱 진입점, diSetup() 호출, MaterialApp.router
├─ core/ ← 전역 공유 인프라
│ ├─ di/di_setup.dart ← get_it 모듈 등록
│ ├─ routing/router.dart ← GoRouter 정의
│ ├─ routing/route_paths.dart ← 경로 상수
│ ├─ domain/error/ ← Error, Result, NetworkError
│ └─ presentation/
│ ├─ components/ ← 공용 위젯 (BigButton, SearchInputField 등)
│ └─ dialogs/ ← 공용 다이얼로그
├─ data/
│ ├─ data_source/ ← DataSource 인터페이스 + 구현 (local/, remote/)
│ ├─ repository/ ← Repository 구현체 (*Impl)
│ └─ clipboard/ ← 기타 플랫폼 서비스 구현
├─ domain/
│ ├─ model/ ← freezed 도메인 모델
│ ├─ repository/ ← Repository 인터페이스 (abstract interface class)
│ ├─ use_case/ ← UseCase 클래스 (execute() 단일 진입점)
│ ├─ clipboard/ ← 플랫폼 서비스 인터페이스
│ ├─ error/ ← 기능별 Error enum
│ └─ filter/ ← 도메인 값 객체
├─ presentation/
│ └─ <feature>/
│ ├─ <feature>_view_model.dart ← 파일 상단: freezed State·Action, 하단: ChangeNotifier VM
│ └─ <feature>_screen.dart ← 파일 상단: Root(VM 주입), 하단: Screen(순수 UI)
└─ ui/ ← 색상/타이포 등 디자인 토큰
実例:lib/presentation/ingredient/、lib/presentation/saved_recipes/、lib/presentation/home/。
各 feature は 2 つのファイルで構成されます。
<feature>_view_model.dart の構成
// ① State (freezed)
@freezed
class IngredientState with _$IngredientState {
const factory IngredientState({ ... }) = _IngredientState;
}
// ② Action (freezed sealed)
@freezed
sealed class IngredientAction with _$IngredientAction {
const factory IngredientAction.load() = Load;
const factory IngredientAction.delete(String id) = Delete;
}
// ③ ViewModel
class IngredientViewModel extends ChangeNotifier {
IngredientState _state = const IngredientState();
IngredientState get state => _state;
void onAction(IngredientAction action) { ... }
}
<feature>_screen.dart の構成
// ① Root — getIt으로 VM 주입, ListenableBuilder로 상태 감지
class IngredientRoot extends StatelessWidget {
@override
Widget build(BuildContext context) {
final vm = getIt<IngredientViewModel>();
return ListenableBuilder(
listenable: vm,
builder: (_, __) => IngredientScreen(
state: vm.state,
onAction: vm.onAction,
),
);
}
}
// ② Screen — 순수 UI, state·onAction만 인자로 받음
class IngredientScreen extends StatelessWidget {
const IngredientScreen({required this.state, required this.onAction});
final IngredientState state;
final void Function(IngredientAction) onAction;
@override
Widget build(BuildContext context) { ... }
}
配置場所
| コードの種類 | 配置場所 | 実例 |
|---|---|---|
| ドメインモデル(freezed) | lib/domain/model/ | recipe.dart、ingredient.dart |
| Repository インターフェース | lib/domain/repository/ | recipe_repository.dart |
| UseCase | lib/domain/use_case/ | get_saved_recipes_use_case.dart |
| DataSource インターフェース | lib/data/data_source/ | recipe_data_source.dart |
| DataSource 実装 | lib/data/data_source/{local,remote}/ | remote_recipe_data_source_impl.dart |
| Repository 実装 | lib/data/repository/ | mock_recipe_repository_impl.dart |
| State + Action + ViewModel | lib/presentation/<feature>/<feature>_view_model.dart | ingredient_view_model.dart |
| Root + Screen ウィジェット | lib/presentation/<feature>/<feature>_screen.dart | ingredient_screen.dart |
| 共通ウィジェット | lib/core/presentation/components/ | big_button.dart |
| ルート定義 | lib/core/routing/router.dart | — |
| DI 登録 | lib/core/di/di_setup.dart | — |
| 共有エラー型 | lib/core/domain/error/ | result.dart、network_error.dart |
| 機能別エラー | lib/domain/error/ | bookmark_error.dart、new_recipe_error.dart |
依存関係のルール
| レイヤー | 依存可能な対象 |
|---|---|
presentation | domain、core |
data | domain、core |
domain | 他の domain ファイル、core/domain(純粋な Dart のみ)。data、presentation、flutter/material は絶対に禁止 |
core/presentation | flutter/material、core/domain |
core/domain | 純粋な Dart のみ |
domain/ ファイルに package:flutter/... の import が含まれていてはいけません。テストと再利用性を確保するため、純粋な Dart に保ちます。
機能を追加するとき(チェックリスト)
新しい feature(例:reviews)を追加するとき:
-
lib/domain/model/review.dart— freezed モデルを定義 -
lib/domain/repository/review_repository.dart—abstract interface classでインターフェースを定義 -
lib/domain/error/review_error.dart— 必要に応じて機能別エラー enum(implements Error) -
lib/domain/use_case/get_reviews_use_case.dart— 単一のexecute()を持つ UseCase -
lib/data/data_source/remote/remote_review_data_source_impl.dart— 実装 -
lib/data/repository/mock_review_repository_impl.dartまたは実際の実装 -
lib/presentation/reviews/reviews_view_model.dart— ファイル上部にReviewsState、ReviewsAction、下部にReviewsViewModel -
lib/presentation/reviews/reviews_screen.dart— ファイル上部にReviewsRoot、下部にReviewsScreen -
lib/core/di/di_setup.dartに DataSource、Repository、UseCase、ViewModel を登録 -
lib/core/routing/route_paths.dartにパス定数を追加し、router.dartにGoRouteを追加
この順序を守ると、各レイヤーは下位レイヤーだけを認識するため、依存関係が逆転しません。
core に置くか feature に置くかの判断
feature フォルダー内に置く場合
- その feature でのみ使う State、Action、ViewModel、UI モデル、画面専用ウィジェット。
- 例:
ingredient_state.dartはlib/presentation/ingredient/の下にあり、他の feature では使いません。
core に置く場合
- 2 つ以上の feature で実際に使うウィジェット/ユーティリティ/エラー。
- 例:
BigButton、SearchInputField、Result、NetworkError。 - ただし、「いつか共有されそう」という理由だけで移動しないでください。2 つ目の実際の使用箇所ができた時点で移動します。
핵심 라이브러리
| 관심사 | 라이브러리 | 용도 |
|---|---|---|
| DI | get_it | GetIt.instance 기반 싱글톤/팩토리 등록 |
| 라우팅 | go_router | 선언적 라우팅, StatefulShellRoute |
| 모델/상태/액션 | freezed + freezed_annotation | sealed/immutable 데이터 클래스 |
| JSON | json_serializable + json_annotation | fromJson/toJson 생성 |
| 스트림 | rxdart | BehaviorSubject로 반응형 저장소 |
| 테스트 | flutter_test | 위젯/단위 테스트 |
모든 코드 생성이 필요한 파일(*.freezed.dart, *.g.dart)은 dart run build_runner build --delete-conflicting-outputs 로 만든다.
コメント
Markdown 対応