Core Principles
- Single package, layer separation: Unlike Android/KMP, it does not use Gradle multi-modules; instead, it is separated into layers under
lib/. - Clean Architecture dependency direction:
presentation→domain←data.domainis a pure Dart layer that depends on nothing. - Feature는
presentationIt is represented as folders under Code shared by multiple screens goes up tocore/, and code used by only a single feature is placed inside that feature's folder. - No direct references between Features. Cross-feature shared logic goes up to
core(shared widgets/utilities) ordomain(models/use cases).
Directory Layout
This project follows the structure below (based onlib/ ):
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/ ← 색상/타이포 등 디자인 토큰
Real example: lib/presentation/ingredient/, lib/presentation/saved_recipes/, lib/presentation/home/.
Each feature consists of 2 files:
<feature>_view_model.dart Internal composition of
// ① 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 Internal composition of
// ① 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) { ... }
}
What goes where
| Code nature | Location | Real example |
|---|---|---|
| Domain model (freezed) | lib/domain/model/ |
recipe.dart, ingredient.dart |
| Repository interface | lib/domain/repository/ |
recipe_repository.dart |
| UseCase | lib/domain/use_case/ |
get_saved_recipes_use_case.dart |
| DataSource interface | lib/data/data_source/ |
recipe_data_source.dart |
| DataSource implementation | lib/data/data_source/{local,remote}/ |
remote_recipe_data_source_impl.dart |
| Repository implementation | lib/data/repository/ |
mock_recipe_repository_impl.dart |
| State + Action + ViewModel | lib/presentation/<feature>/<feature>_view_model.dart |
ingredient_view_model.dart |
| Root + Screen widgets | lib/presentation/<feature>/<feature>_screen.dart |
ingredient_screen.dart |
| Common widgets | lib/core/presentation/components/ |
big_button.dart |
| Route definitions | lib/core/routing/router.dart |
— |
| DI registration | lib/core/di/di_setup.dart |
— |
| Shared error types | lib/core/domain/error/ |
result.dart, network_error.dart |
| Feature-specific errors | lib/domain/error/ |
bookmark_error.dart, new_recipe_error.dart |
Dependency rules
| Layer | May depend on |
|---|---|
presentation |
domain, core |
data |
domain, core |
domain |
Other domain files, core/domain (pure Dart only). Absolutely data, presentation, flutter/material forbidden |
core/presentation |
flutter/material, core/domain |
core/domain |
Pure Dart only |
domain/ In package:flutter/... files, an import must not appear. Keep it pure Dart to ensure testability and reusability.
When adding a feature (checklist)
새 feature(예: reviews):
-
lib/domain/model/review.dart— define freezed model -
lib/domain/repository/review_repository.dart—abstract interface classDefine the interface with -
lib/domain/error/review_error.dart— feature-specific error enum if needed (implements Error) -
lib/domain/use_case/get_reviews_use_case.dart— singleexecute()UseCase with -
lib/data/data_source/remote/remote_review_data_source_impl.dart— implementation -
lib/data/repository/mock_review_repository_impl.dartor real implementation -
lib/presentation/reviews/reviews_view_model.dart— at the top of the fileReviewsState,ReviewsAction, at the bottomReviewsViewModel -
lib/presentation/reviews/reviews_screen.dart— at the top of the fileReviewsRoot, at the bottomReviewsScreen -
lib/core/di/di_setup.dartRegister DataSource, Repository, UseCase, ViewModel in -
lib/core/routing/route_paths.dartPath constants in ,router.dart에GoRouteAdd
By following this order, each layer only knows about the layers below it, so dependencies do not get inverted.
coreDecide whether to put it in or keep it in the feature
When placed inside the feature folder
- State, Action, ViewModel, UI model, and screen-specific widgets used only by that feature.
- 예:
ingredient_state.dart는lib/presentation/ingredient/It is below and is not used by other features.
coreWhen moving up to
- Widgets/utilities/errors actually used by 2 or more features.
- 예:
BigButton,SearchInputField,Result,NetworkError. - However, do not move it up just because "it seems like it might be shared someday." Move it when the actual second usage appears.
Core libraries
| Concerns | Libraries | Purpose |
|---|---|---|
| DI | get_it |
GetIt.instance -based singleton/factory registration |
| Routing | go_router |
Declarative routing, StatefulShellRoute |
| Model/State/Action | freezed + freezed_annotation |
sealed/immutable data classes |
| JSON | json_serializable + json_annotation |
fromJson/toJson generation |
| Streams | rxdart |
BehaviorSubjectas a reactive repository |
| Testing | flutter_test |
Widget/unit tests |
All files that require code generation (*.freezed.dart, *.g.dart)은 dart run build_runner build --delete-conflicting-outputs is made with.