# 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.

- URL: https://dantech.academy/courses/kotlin-accelerator/engineer-builder/06-clean-architecture/lesson-01-clean-architecture-overview
- Khóa học: [Mobile Action Pro](https://dantech.academy/courses/kotlin-accelerator.md)
- Module: Clean Architecture Chuyên Sâu
- Thời lượng: 25 phút
- Độ khó: intermediate
- Truy cập: Miễn phí, đọc toàn bài
- English: https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/06-clean-architecture/lesson-01-clean-architecture-overview.md

## Mục tiêu

- 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

---

## 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:

```kotlin
// 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

```mermaid
%%{init: {"theme": "base", "themeVariables": {"background": "transparent", "primaryColor": "#1e293b", "primaryTextColor": "#e2e8f0", "primaryBorderColor": "#475569", "secondaryColor": "#1e293b", "tertiaryColor": "#0f172a", "lineColor": "#64748b", "textColor": "#e2e8f0", "clusterBkg": "#0f172a", "clusterBorder": "#475569", "titleColor": "#cbd5e1", "edgeLabelBackground": "#334155", "fontSize": "15px"}, "flowchart": {"wrappingWidth": 420}}}%%
flowchart TB
    classDef ui fill:#1e293b,stroke:#3b82f6,stroke-width:2px,color:#e2e8f0
    classDef vm fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#e2e8f0
    classDef dom fill:#1e293b,stroke:#f59e0b,stroke-width:2px,color:#e2e8f0
    classDef data fill:#1e293b,stroke:#8b5cf6,stroke-width:2px,color:#e2e8f0
    classDef root fill:#1e293b,stroke:#94a3b8,stroke-width:2px,color:#e2e8f0
    classDef bad fill:#1e293b,stroke:#ef4444,stroke-width:2px,color:#e2e8f0
    classDef good fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#e2e8f0
    classDef note fill:#0f172a,stroke:#475569,stroke-dasharray:4 3,color:#cbd5e1
    accTitle: Bốn lớp Clean Architecture trong repo
    accDescr: Mũi tên nghĩa là phụ thuộc vào. Feature module phụ thuộc domain. Data cũng phụ thuộc domain vì nó implement interface của domain. Domain không phụ thuộc lớp nào khác. Shared-app và app là nơi lắp ráp, thấy được cả domain lẫn data.

    subgraph ROOT["Lắp ráp"]
        direction TB
        APP[":app<br/>composition root Android"]:::root --> SHARED[":shared-app<br/>Koin wiring cho Android và iOS"]:::root
    end

    subgraph FEAT["Feature: game, menu, paywall"]
        direction TB
        UI["<b>UI</b><br/>package screen, cộng :ui-components, :theme<br/>GameRoute, GameScreenCompose"]:::ui
        VM["<b>ViewModel</b><br/>package viewmodel<br/>GameViewModel, MenuViewModel"]:::vm
        UI -->|"quan sát state, gọi hàm"| VM
    end

    DOMAIN["<b>Domain</b>: :domain<br/>GameStateMachine, UseCase<br/>interface QuestionRepository<br/>chỉ phụ thuộc :dre-core"]:::dom
    DATA["<b>Data</b>: :data<br/>MockQuestionRepository<br/>LeaderboardRepositoryImpl"]:::data

    SHARED --> FEAT
    SHARED --> DATA
    VM -->|"dùng use case"| DOMAIN
    DATA -->|"implement interface"| DOMAIN
    FEAT ~~~ NOTE["Mũi tên = phụ thuộc vào.<br/>Feature không phụ thuộc :data."]:::note
```

Repo có 14 Gradle module (khai báo trong [settings.gradle.kts](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/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](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/domain/build.gradle.kts):

```kotlin
/**
 * 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](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/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](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/feature/game/src/commonMain/kotlin/com/creative/kta/feature/game/viewmodel/GameViewModel.kt) dispatch `GameAction.StartGame`. Constructor của nó nhận use case, không nhận repository:

```kotlin
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](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/domain/src/commonMain/kotlin/com/creative/kta/domain/game/GameStateMachine.kt) 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](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/domain/src/commonMain/kotlin/com/creative/kta/domain/usecase/game/GameUseCases.kt):

```kotlin
class LoadGameQuestionsUseCase(
    private val questionRepository: QuestionRepository,
) : UseCase<Unit, List<QuizData>>() {
    override suspend fun invoke(input: Unit): List<QuizData> = questionRepository.newGameQuestions()
}
```

Data: [DataModule](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/data/src/commonMain/kotlin/com/creative/kta/data/di/DataModule.kt) bind interface vào implementation:

```kotlin
single<QuestionRepository> { MockQuestionRepository() }
```

[MockQuestionRepository](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/data/src/commonMain/kotlin/com/creative/kta/data/repo/question/MockQuestionRepository.kt) 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

1. Tìm class ở mỗi lớp: UI, ViewModel, Domain, Data.
2. Chỉ ra interface nào nằm ở Domain và class nào implement nó.
3. Trả lời: thứ hạng (rank) được tính ở lớp nào?
4. Trả lời: vì sao `LeaderboardViewModel` không import được `LeaderboardRepositoryImpl`?

<Solution>

| 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.

</Solution>

## Lộ Trình Đọc Code

1. [settings.gradle.kts](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/settings.gradle.kts): danh sách 14 module.
2. [domain/build.gradle.kts](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/domain/build.gradle.kts): KDoc nói rõ domain không được chứa gì.
3. [feature/game/build.gradle.kts](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/feature/game/build.gradle.kts): feature không phụ thuộc `:data`.
4. [GameViewModel](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/feature/game/src/commonMain/kotlin/com/creative/kta/feature/game/viewmodel/GameViewModel.kt): constructor nhận use case.
5. [GameStateMachine](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/domain/src/commonMain/kotlin/com/creative/kta/domain/game/GameStateMachine.kt): `handleStartGame` và `handleSubmitAnswer`.
6. [DataModule](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/data/src/commonMain/kotlin/com/creative/kta/data/di/DataModule.kt): 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)](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
- [Guide to app architecture (Android Developers)](https://developer.android.com/topic/architecture)
- [Domain layer (Android Developers)](https://developer.android.com/topic/architecture/domain-layer)

## Ý 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.

## Checklist thực hành

- [ ] Đọc ví dụ ViewModel truy cập Supabase trực tiếp
- [ ] Xác định vấn đề của tight coupling
- [ ] Định nghĩa interface UserRepository
- [ ] Cài đặt UserRepositoryImpl bọc AppDatabase
- [ ] Refactor UserViewModel phụ thuộc interface
- [ ] Viết FakeUserRepository cho test
- [ ] Viết test loadUser cập nhật state

---

- Bài trước: [Chạy Release Readiness Check](https://dantech.academy/courses/kotlin-accelerator/engineer-builder/05-testing-security-release/lesson-05-run-release-readiness-check.md)
- Bài tiếp theo: [Cấu trúc Gradle Multi-Module](https://dantech.academy/courses/kotlin-accelerator/engineer-builder/06-clean-architecture/lesson-03-multi-module-structure.md)
