- Giải thích dependency rule: lớp ngoài biết lớp trong, lớp trong không biết lớp ngoài
- Map bốn lớp UI, ViewModel, Domain, Data vào module thật của repo
- Lần theo một lượt bắt đầu game từ GameRoute tới MockQuestionRepository và ngược lại
Clean Architecture Và Mô Hình 4 Lớp
Dependency rule, bốn lớp UI, ViewModel, Domain, Data map vào module thật của repo, và một lượt bắt đầu game đi qua cả bốn lớp.
Video bài học sắp lên sóng
Trong lúc chờ, bạn hãy đọc bản text của bài học bên dưới: đầy đủ lý thuyết, code mẫu và checklist thực hành.
Mục Tiêu Bài Học
Sau bài này, bạn giải thích được Clean Architecture giải quyết vấn đề gì, đọc được dependency rule, và chỉ ra bốn lớp UI, ViewModel, Domain, Data nằm ở module nào trong repo kotlin-accelerator-ai.
Mục tiêu cuối cùng: nhìn một class bất kỳ, bạn biết nó thuộc lớp nào và được phép import những gì.
Phần 1: Vấn Đề Clean Architecture Giải Quyết
Hãy bắt đầu bằng một ViewModel tự gọi database:
// Ví dụ minh họa, không có trong repo
class GameViewModel(private val supabase: SupabaseClient) : ViewModel() {
fun loadQuestions() = viewModelScope.launch {
val rows = supabase.from("questions").select().decodeList<QuestionRow>()
// map, lọc, xử lý lỗi... tất cả ở đây
}
}
Code này chạy được, nhưng có ba cái giá:
- Muốn test ViewModel, bạn cần một Supabase thật hoặc một mock rất phức tạp.
- Đổi nguồn dữ liệu (ví dụ từ Supabase sang file JSON) nghĩa là sửa ViewModel.
- Logic nghiệp vụ (chọn câu hỏi, tính tiền thưởng) bị trộn với chi tiết mạng.
Clean Architecture tách các mối quan tâm đó ra thành lớp, và đặt một luật duy nhất cho hướng phụ thuộc giữa chúng.
Phần 2: Dependency Rule
Luật gốc trong bài viết của Robert C. Martin: source code dependency chỉ được trỏ vào trong, về phía policy cấp cao. Lớp bên trong không biết tên bất cứ thứ gì ở lớp bên ngoài.
Áp vào app mobile:
| Lớp | Biết về | Không được biết về |
|---|---|---|
| UI | ViewModel, UI state | Repository, SDK mạng, database |
| ViewModel | Use case, model của domain | Supabase, DataStore, Firebase |
| Domain | Chính nó (model, interface, rule) | Compose, Android, Koin, Ktor, Supabase |
| Data | Interface của domain để implement | UI, ViewModel |
Điểm quan trọng nhất nằm ở hàng Data. Data phụ thuộc vào Domain, không phải ngược lại. Domain khai báo interface QuestionRepository, còn Data cung cấp class implement nó. Kỹ thuật đảo chiều phụ thuộc này gọi là Dependency Inversion.
Phần 3: Bốn Lớp Trong Repo
Repo có 14 Gradle module (khai báo trong settings.gradle.kts). Bốn lớp map vào chúng như sau:
| Lớp | Nằm ở đâu | Ví dụ thật |
|---|---|---|
| UI | Package screen trong :feature:game, :feature:menu, :feature:paywall, cộng :ui-components và :theme | GameRoute, GameScreenCompose |
| ViewModel | Package viewmodel trong các feature module | GameViewModel, MenuViewModel |
| Domain | Module :domain | GameStateMachine, UseCase, interface QuestionRepository |
| Data | Module :data | MockQuestionRepository, LeaderboardRepositoryImpl, DataModule |
Ngoài bốn lớp còn hai module làm nhiệm vụ lắp ráp: :shared-app (Koin wiring dùng chung cho Android và iOS) và :app (composition root của Android). Chúng là nơi duy nhất được nhìn thấy cả Domain lẫn Data cùng lúc.
Phần 4: Module Graph Giữ Luật Cho Bạn
Trong KMP, dependency rule không chỉ là quy ước. Nó nằm trong file Gradle. Đây là KDoc ở đầu domain/build.gradle.kts:
/**
* Pure Kotlin. Coroutines, the DRE reducer, and persistent collections.
*
* No Compose, navigation, Compose resources, Koin, Ktor, or Supabase.
* Koin wiring for these types lives in `:shared-app`.
*/
commonMain của :domain chỉ khai báo ba dependency: kotlinx-coroutines-core, :dre-core và kotlinx-collections-immutable. Không có androidMain, không có iosMain. Vì vậy code domain chạy giống hệt nhau trên cả hai platform và test được bằng commonTest.
Phía feature, feature/game/build.gradle.kts khai báo :core, :domain, :kernel, :theme, :ui-components. Không có :data. Nếu bạn thử import MockQuestionRepository trong GameViewModel, build sẽ báo unresolved reference ngay. Compiler đang enforce dependency rule thay cho code review.
Phần 5: Theo Một Lượt Chơi Qua Bốn Lớp
Hãy theo hành động bắt đầu game, từ UI xuống Data rồi quay lên.
UI: GameRoute gọi gameViewModel.ensureGameStarted(), sau đó collect gameScreenUiState và truyền vào GameScreenCompose. Composable không biết câu hỏi đến từ đâu.
ViewModel: GameViewModel dispatch GameAction.StartGame. Constructor của nó nhận use case, không nhận repository:
class GameViewModel(
reducer: GameStateMachine,
// ...
logAnalytics: LogAnalyticsEventUseCase,
loadGameQuestions: LoadGameQuestionsUseCase,
lifelineHelper: LifelineHelper,
recordGameStats: RecordGameStatsUseCase,
dispatchers: QzDispatchers,
// ...
) : DreStoreViewModel<GameState, GameAction, GameSideEffect, GameAsyncOp>(
reducer = reducer,
dispatchContext = dispatchers.mainImmediate,
), IGameViewModel
Domain: GameStateMachine là reducer thuần. Hàm handleStartGame trả về state mới kèm GameAsyncOp.LoadGame. Reducer không làm I/O; nó chỉ mô tả việc cần làm.
ViewModel thực thi async op qua GameAsyncOpExecutor.loadGame(), hàm này gọi LoadGameQuestionsUseCase:
class LoadGameQuestionsUseCase(
private val questionRepository: QuestionRepository,
) : UseCase<Unit, List<QuizData>>() {
override suspend fun invoke(input: Unit): List<QuizData> = questionRepository.newGameQuestions()
}
Data: DataModule bind interface vào implementation:
single<QuestionRepository> { MockQuestionRepository() }
MockQuestionRepository lấy câu hỏi từ SampleQuestions theo phân bổ độ khó trong QuizGameConfig.QUESTION_DIFFICULTIES.
Đi lên: executor tạo CurrentGameSession (id bằng Uuid.random() của Kotlin stdlib, chạy được trên cả Android và iOS), ViewModel dispatch GameAction.GameLoaded, reducer cập nhật state, và gameScreenUiState phát giá trị mới cho UI.
Khi đổi MockQuestionRepository sang một implementation đọc Supabase, chỉ dòng bind trong DataModule thay đổi. GameViewModel, GameStateMachine và LoadGameQuestionsUseCase không cần sửa.
Bài Tập Thực Hành
Nhiệm Vụ
Map tính năng Leaderboard vào bốn lớp. Với mỗi lớp, ghi tên class thật và lý do nó thuộc lớp đó.
Yêu Cầu
- Tìm class ở mỗi lớp: UI, ViewModel, Domain, Data.
- Chỉ ra interface nào nằm ở Domain và class nào implement nó.
- Trả lời: thứ hạng (rank) được tính ở lớp nào?
- Trả lời: vì sao
LeaderboardViewModelkhông import đượcLeaderboardRepositoryImpl?
| Lớp | Class | Lý do |
|---|---|---|
| UI | LeaderboardPopup, LeaderBoardList trong :feature:menu | Chỉ render LeaderBoardListUiState |
| ViewModel | LeaderboardViewModel | Gọi FetchLeaderboardUseCase, map LeaderboardEntry sang UserProfileUiState |
| Domain | FetchLeaderboardUseCase, interface LeaderboardRepository, model LeaderboardEntry | Không import SDK nào |
| Data | LeaderboardRepositoryImpl, LeaderboardRow | Gọi Supabase, decode row, trả DomainResult |
Interface LeaderboardRepository nằm trong :domain, LeaderboardRepositoryImpl trong :data implement nó, và DataModule bind hai thứ lại với nhau.
Rank được tính ở database. View leaderboard trong scripts/sql/setup.sql dùng ROW_NUMBER() OVER (ORDER BY total_money_won DESC, level DESC, id). Repository chỉ yêu cầu sắp xếp theo cột rank và giới hạn số dòng. ViewModel dùng giá trị rank có sẵn để chọn tier GOLD, SILVER, BRONZE.
LeaderboardViewModel nằm trong :feature:menu, và module này không khai báo dependency vào :data. Import đó sẽ không compile.
Lộ Trình Đọc Code
- settings.gradle.kts: danh sách 14 module.
- domain/build.gradle.kts: KDoc nói rõ domain không được chứa gì.
- feature/game/build.gradle.kts: feature không phụ thuộc
:data. - GameViewModel: constructor nhận use case.
- GameStateMachine:
handleStartGamevàhandleSubmitAnswer. - DataModule: interface nào bind vào class nào.
Dấu Hiệu Cần Tránh
| Smell | Hướng tốt hơn |
|---|---|
ViewModel nhận SupabaseClient hoặc repository implementation | Nhận use case; implementation được bind ở composition root |
Domain import androidx.*, Compose hoặc Koin | Giữ domain ở commonMain với Kotlin thuần |
Feature module thêm dependency :data cho tiện | Thêm interface vào :domain, implement ở :data |
| Composable tự kiểm tra đáp án đúng sai | Phát intent cho ViewModel; reducer quyết định |
| Thêm lớp chỉ để có đủ bốn lớp | Mỗi lớp phải tách được một lý do thay đổi |
Tham Khảo
- The Clean Architecture (Robert C. Martin)
- Guide to app architecture (Android Developers)
- Domain layer (Android Developers)
Ý Chính
Clean Architecture là một luật về hướng phụ thuộc: lớp ngoài biết lớp trong, lớp trong không biết lớp ngoài.
Trong repo này, luật đó được viết vào Gradle. :domain chỉ có Kotlin thuần, feature module không thấy :data, và chỉ :shared-app cùng :app được nhìn thấy cả hai để lắp ráp.
Khi bạn phân vân một class thuộc đâu, hãy hỏi: nếu đổi SDK hoặc đổi UI, class này có phải sửa không? Nếu có, nó không thuộc domain.