# Thay Đổi An Toàn Đầu Tiên

> Biết cái gì là thật, cái gì là mock, rồi tự tay sửa codebase mà không phá vỡ boundary nào.

- URL: https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-production-app-map/lesson-05-make-your-first-change
- Khóa học: [Mobile Action Pro](https://dantech.academy/courses/kotlin-accelerator.md)
- Module: Kiến trúc Production mobile app
- Thời lượng: 10 phút
- Độ khó: beginner
- Truy cập: Miễn phí, đọc toàn bài
- English: https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-production-app-map/lesson-05-make-your-first-change.md

## Mục tiêu

- Phân biệt phần nào của app chạy bằng dữ liệu thật và phần nào chạy bằng mock
- Đánh giá blast radius của một thay đổi bằng 4 câu hỏi trước khi sửa code
- Hoàn tất ba thay đổi thật ở ba mức blast khác nhau, verify và commit được

---

## Mục Tiêu Bài Học

Bài này bạn trực tiếp sửa code.

Bạn sẽ thực hiện ba thay đổi thật trong codebase, mỗi thay đổi ở một mức blast radius khác nhau, rồi verify. Cả ba đều nằm trong `commonMain`, nên mỗi thay đổi có hiệu lực trên cả Android lẫn iOS.

Sau bài này bạn có một quy trình lặp lại được cho mọi thay đổi về sau, và đủ tự tin để thử ý tưởng của riêng mình trên codebase này.

## Phần 1: Cái Gì Là Thật, Cái Gì Là Mock

Biết điều này trước khi sửa bất cứ thứ gì. Nhiều người mới mất cả buổi tìm lỗi của một service không hề tồn tại.

Mở:

```text
data/src/commonMain/kotlin/com/creative/kta/data/di/DataModule.kt
data/src/androidMain/kotlin/com/creative/kta/data/di/DataModule.android.kt
data/src/iosMain/kotlin/com/creative/kta/data/di/DataModule.ios.kt
```

Ba file này là bản kê khai đầy đủ. Mỗi dòng `single<Contract> { Implementation() }` cho bạn biết contract nào đang nối vào implementation nào. `DataModule.kt` dùng chung, hai file còn lại là phần riêng của từng nền tảng.

Đây là bức tranh tổng quát:

| Vùng             | Contract                | Implementation hiện tại             | Thật hay mock                |
| ---------------- | ----------------------- | ----------------------------------- | ---------------------------- |
| Câu hỏi quiz     | `QuestionRepository`    | `MockQuestionRepository`            | Mock, đọc từ file Kotlin     |
| Đăng nhập        | `AuthService`           | `AuthServiceSupabaseImpl`           | Thật, gọi Supabase           |
| Hồ sơ người dùng | `UserService`           | `UserServiceSupabaseImpl`           | Thật, gọi Supabase           |
| Bảng xếp hạng    | `LeaderboardRepository` | `LeaderboardRepositoryImpl`         | Thật, gọi Supabase           |
| Gợi ý AI         | `AIHintService`         | `AIHintServiceImpl` cộng 2 provider | Thật, có fallback offline    |
| Lịch sử chơi     | `GameHistoryRepository` | `LocalGameHistoryRepository`        | Thật, lưu trên máy           |
| Âm thanh         | `AudioManager`          | Android: `SoundPoolAudioManager`. iOS: `AvAudioManager` | Thật, SDK của từng nền tảng |
| Cấu hình từ xa   | `RemoteConfigManager`   | Android: `FirebaseRemoteConfigManager`. iOS: `DefaultRemoteConfigManager` | Android thật. iOS là placeholder trả về config rỗng |

Dòng quan trọng nhất với bạn lúc này:

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

Toàn bộ câu hỏi của game đến từ một file Kotlin. Không có API nào để gọi, không có database nào để query.

Ở phạm vi khóa học, đây là quyết định hợp lý: sau này bạn thay nội dung bằng backend thật mà chỉ `:data` phải sửa, vì `QuestionRepository` là contract nằm ở `:domain` từ đầu.

> Đây là nguyên lý Dependency Inversion, chữ D trong SOLID: game chỉ phụ thuộc vào contract `QuestionRepository` ở `:domain`, còn implementation nằm sau nó trong `:data`.

## Phần 2: Bốn Câu Hỏi Trước Khi Sửa

Trước mỗi thay đổi, dừng lại 60 giây và trả lời bốn câu này. Bạn sẽ dùng quy trình này ở mọi codebase, không riêng khóa học.

| Câu hỏi                              | Vì sao quan trọng                             |
| ------------------------------------ | --------------------------------------------- |
| 1. File này thuộc module và source set nào? | Quyết định ai được phép depend vào nó, và nó chạy trên nền tảng nào |
| 2. Còn ai đang dùng nó?              | Chính là blast radius của bạn                 |
| 3. Có test nào đang bảo vệ nó không? | Nếu có, đó là lưới an toàn miễn phí           |
| 4. Verify bằng lệnh gì?              | Nếu không trả lời được, bạn chưa nên sửa ngay |

Câu 2 hay bị bỏ qua nhất. Trong Android Studio, dùng `Find Usages` trên tên class. Một thay đổi tưởng nhỏ có thể chạm nhiều nơi hơn bạn nghĩ.

## Phần 3: Drill 1, Thêm Câu Hỏi SampleQuestions

Blast radius: một file code, cộng test đang bảo vệ nó.

Mở:

```text
data/src/commonMain/kotlin/com/creative/kta/data/repo/question/SampleQuestions.kt
```

Dán entry dưới đây ngay sau dòng comment `// Easy questions (1-5)`. Dấu phẩy cuối entry là bắt buộc vì phía sau còn các câu khác:

```kotlin
    QuizData(
        id = 16,
        question = "Which Kotlin keyword declares a read-only variable?",
        options = listOf("val", "var", "const", "let"),
        correctOption = "val",
        category = "Programming",
        explanation = QuestionExplanation(
            explainForTrueAnswer = "val declares a read-only reference. Once assigned, it cannot point to another value.",
            explainForWrongAnswers = mapOf(
                "var" to "var declares a mutable variable that can be reassigned.",
                "const" to "const is a modifier for compile-time constants, used together with val.",
                "let" to "let is a scope function in Kotlin, not a variable declaration keyword."
            ),
            aiHintMessage = "Think of the keyword that is short for value."
        ),
        language = "en",
        difficulty = QuestionDifficulty.EASY
    ),
```

Mọi câu hỏi mẫu đều bằng tiếng Anh với `language = "en"`, và test kiểm tra điều đó, nên câu mới cũng viết bằng tiếng Anh.

Build và chạy app, vào một ván mới.

### Điều Bất Ngờ Đang Chờ Bạn

Câu hỏi mới có thể không xuất hiện trong ván vừa chơi. Đừng vội nghĩ mình làm sai.

Mở `MockQuestionRepository.kt` và đọc:

```kotlin
return SampleQuestions
    .filter { it.difficulty == difficulty }
    .shuffled()
    .take(count)
```

Rồi mở `domain/.../config/QuizGameConfig.kt`:

```kotlin
val QUESTION_DIFFICULTIES: ImmutableMap<QuestionDifficulty, Int> = persistentMapOf(
    QuestionDifficulty.EASY to 5,
    QuestionDifficulty.MEDIUM to 5,
    QuestionDifficulty.HARD to 5
)
```

Trước khi bạn sửa, pool EASY có đúng 5 câu và game lấy 5 câu. Nghĩa là mọi câu đều xuất hiện, chỉ khác thứ tự.

Sau khi bạn thêm câu thứ 6, game vẫn chỉ lấy 5. Câu của bạn có khoảng 5 trên 6 cơ hội được chọn mỗi ván, tức cứ khoảng 6 ván sẽ có một ván vắng nó.

> Đọc code trước khi kết luận về một hành vi. Rất nhiều giờ debug bị đốt vì bỏ qua bước này.

Verify:

```bash
./gradlew :data:testAndroidHostTest --tests "*MockQuestionRepositoryTest*"
```

`:data` là KMP module, nên test của nó chạy bằng `testAndroidHostTest`, không phải `testDebugUnitTest` (task `testDebugUnitTest` chỉ có ở module Android thuần như `:app`; KMP module dùng `androidHostTest` cho test trên JVM).

Lệnh này sẽ fail. `MockQuestionRepositoryTest` nằm ở `data/src/commonTest/`. Test `getQuestionsByDifficulty when EASY and count exceeds available then returns all available` vẫn chờ đúng 5 câu EASY nên chắc chắn fail. Hai test EASY khác chỉ chấp nhận id từ 1 đến 5, nên fail mỗi khi lần chọn ngẫu nhiên rơi vào câu id 16. Test đang báo cho bạn biết pool EASY đã đổi. Sửa test cho khớp dữ liệu mới:

- Trong hai test `... returns questions with IDs 1 to 5` và `... count is 3 then returns 3 questions` của EASY, đổi `question.id in 1..5` thành `question.id in 1..5 || question.id == 16`.
- Trong test `... count exceeds available ...`, đổi `assertEquals(5, result.size)` thành `assertEquals(6, result.size)` và sửa comment phía trên cho đúng.

Chạy lại vài lần cho tới khi pass ổn định. Test có yếu tố ngẫu nhiên, nên một lần pass chưa đủ chứng minh.

## Phần 4: Drill 2, Đảo Provider Của AI Hint

Blast radius: một file, nhưng đổi hành vi runtime của cả một tính năng.

Mở `DataModule.kt` và tìm khối AI Hint Service:

```kotlin
single<AIHintService> {
    AIHintServiceImpl(
        remoteConfigManager = get(),
        primaryProvider = get(AIHintProviderType.GEMINI),
        fallbackProvider = get(AIHintProviderType.LOCAL)
    )
}
```

Đảo hai dòng cuối:

```kotlin
        primaryProvider = get(AIHintProviderType.LOCAL),
        fallbackProvider = get(AIHintProviderType.GEMINI)
```

Chạy app trên Android, vào game, dùng lifeline AI Assistant. Nếu Firebase Remote Config của bạn đã có Gemini API key, nội dung gợi ý sẽ đổi: giờ nó đến từ `LocalHintProvider` thay vì gọi Gemini. Nếu chưa có key, `GeminiHintProvider.isAvailable()` trả về `false`, nên cả trước và sau khi đảo bạn đều nhận gợi ý từ `LocalHintProvider`.

Trên iOS bạn sẽ không thấy khác biệt nào. `DataModule.ios.kt` bind `RemoteConfigManager` vào `DefaultRemoteConfigManager`, trả về Gemini config với API key rỗng, nên iOS luôn dùng gợi ý local. Cùng một dòng code chung, hành vi phụ thuộc vào binding của từng nền tảng. Test ở cuối phần này là cách kiểm chứng đáng tin.

### Bây Giờ Đếm Xem Bạn Đã Sửa Bao Nhiêu File

Một file. Hai dòng đổi chỗ cho nhau trong `DataModule.kt` của `:data`.

Những thứ không phải sửa:

| Không đổi                        | Vì sao                                               |
| -------------------------------- | ---------------------------------------------------- |
| `AIHintService` trong `:domain`  | Contract không đổi, chỉ implementation đổi           |
| `DefaultLifelineHelper`          | Nó inject contract `AIHintService`, không biết provider nào phía sau |
| `GameViewModel`                  | Nó chỉ nhận `LifelineHelper` qua Koin, không chạm vào provider |
| `GameStateMachine`               | Reducer không biết gì về provider                    |
| `GameScreenCompose`              | UI chỉ render kết quả                                |
| Bất kỳ file nào trong `:feature` | Không feature nào import provider class              |

Tách contract khỏi implementation dễ nghe như lý thuyết. Giờ bạn vừa tự tay kiểm chứng nó.

Thử tưởng tượng phiên bản ngược lại: nếu `GameViewModel` tự khởi tạo Gemini client, thay đổi này sẽ chạm vào feature module, có thể chạm cả UI, và mọi test của game phải sửa theo.

Verify:

```bash
./gradlew :data:testAndroidHostTest --tests "*AIHintServiceImplTest*"
./gradlew :shared-app:testAndroidHostTest --tests "*AppGraphTest*"
```

Lệnh thứ hai dựng lại toàn bộ Koin graph để chắc rằng đổi binding không làm gãy dependency nào.

## Phần 5: Drill 3, Đổi Một Design Token

Blast radius: nhiều màn hình cùng lúc, trên cả hai nền tảng.

Mở:

```text
theme/src/commonMain/kotlin/com/creative/kta/theme/QzdsSpacingTokens.kt
```

Tìm:

```kotlin
val defaultQzdsSpacing = QzdsSpacingTokens(
    // ...
    screenPadding = QzdsPrimitiveSpacing.Md,
    // ...
)
```

Đổi `Md` thành `Lg` (từ 16dp lên 24dp). Chạy app và đi qua menu, settings, leaderboard, game.

Mọi màn hình dùng `screenPadding` đều rộng ra cùng lúc. Nếu bạn có Mac, chạy lại trên iOS Simulator: thay đổi y hệt, vì `:theme` nằm trong `commonMain`.

Bây giờ mở `QzdsPrimitiveSpacing.kt` và để ý dòng này:

```kotlin
val Sm = 10.dp       // project convention (not 8dp)
```

Đây là một quyết định thiết kế ghi ngay trong code: nhóm phát triển cố ý đặt `Sm` là 10dp thay vì 8dp quen thuộc. Mọi chỗ dùng `Sm` đều nhận giá trị này, nên sửa dòng đó là đổi nhịp khoảng cách của toàn bộ sản phẩm.

> Một số file nhỏ có blast radius rất lớn.

## Phần 6: Verify Và Rollback

Đừng bao giờ kết thúc một thay đổi mà không có bước này.

Chạy test hẹp trước, rộng sau:

```bash
./gradlew :data:testAndroidHostTest
./gradlew :feature:game:testAndroidHostTest
./gradlew testDebugUnitTest testAndroidHostTest
```

Lệnh cuối là lệnh CI chạy trên mỗi pull request. Trên Mac, thêm `./gradlew :data:allTests` để chạy `commonTest` của `:data` trên iOS Simulator.

Cài lên máy:

```bash
./gradlew installDebug
```

Xem lại chính xác mình đã đổi gì:

```bash
git diff
```

Và hoàn tác khi cần:

```bash
git reset --hard
```

`git reset --hard` xoá mọi thay đổi chưa commit trong các file Git đang theo dõi. Đọc `git diff` trước khi chạy.

## Phần 7: Từ Drill Đến Ý Tưởng Của Riêng Bạn

Ba drill trên xếp theo mức lan toả tăng dần: một file, một hành vi, toàn bộ giao diện. Phần lớn ý tưởng của bạn sẽ rơi vào một trong ba nhóm này.

Vài hướng đi tiếp, kèm module sẽ cần update:

| Ý tưởng                                  | Module bị chạm                        | Mức độ     |
| ---------------------------------------- | ------------------------------------- | ---------- |
| Viết lại toàn bộ câu hỏi bằng tiếng Việt | `:data`                               | Thấp       |
| Đổi thời gian mỗi câu hỏi                | `:domain` (`QuizGameConfig.TIME_PER_QUESTION`) | Thấp |
| Đổi thang tiền thưởng                    | `:domain` (`QuizGameConfig.MONEY_LADDER`) | Trung bình |
| Thêm một lifeline mới                    | `:domain`, `:feature:game`            | Cao        |
| Thay câu hỏi mock bằng backend thật      | `:data` (chỉ thêm một implementation) | Trung bình |

Chú ý hàng cuối. Thay toàn bộ nguồn câu hỏi bằng backend thật chỉ cần một implementation mới trong `:data` và một dòng đổi trong `DataModule.kt`. Không feature nào phải sửa, trên cả hai nền tảng.

## Checkpoint

Hoàn tất cả ba drill, sau đó rollback sạch bằng `git reset --hard`.

Bạn hoàn tất bài này khi:

- Giải thích được vì sao câu hỏi mới của bạn không xuất hiện ở mọi ván chơi,
- Kể được ít nhất bốn thứ không phải sửa khi bạn đảo AI hint provider,
- Giải thích được vì sao đảo provider không đổi gì trên iOS,
- Chỉ ra được file có blast radius lớn nhất trong ba drill và giải thích vì sao,
- Tự đề xuất được thay đổi thứ tư của riêng bạn.

Nếu bạn làm được điều cuối cùng, bạn đã đọc được project này đủ sâu để tự lên kế hoạch cho một thay đổi.

---

- Bài trước: [Trách Nhiệm Từng Layer Và Cách Đọc Tên File](https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-production-app-map/lesson-04-spot-layer-responsibilities.md)
- Bài tiếp theo: [Giải Phẫu Một Project KMP](https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-01-kmp-project-anatomy.md)
