# Your First Safe Change

> Know what is real and what is mocked, then change the codebase yourself without breaking any boundary.

- URL: https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-production-app-map/lesson-05-make-your-first-change
- Course: [Mobile Action Pro](https://dantech.academy/en/courses/kotlin-accelerator.md)
- Module: Production App Map Architecture
- Duration: 10 min
- Difficulty: beginner
- Access: Free, full lesson
- Tiếng Việt: https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-production-app-map/lesson-05-make-your-first-change.md

## Objectives

- Tell which parts of the app run on real data and which run on mocks
- Assess a change's blast radius with 4 questions before you edit code
- Complete three real changes at three different blast levels, verify them and be able to commit

---

## Lesson Goal

In this lesson you change the code yourself.

You will make three real changes to the codebase, each with a different blast radius, and then verify them. All three live in `commonMain`, so each change takes effect on both Android and iOS.

By the end you have a repeatable process for every change you make from here on, and enough confidence to try your own ideas on this codebase.

## Part 1: What Is Real And What Is Mocked

Know this before you change anything. Many beginners spend a whole afternoon chasing a bug in a service that doesn't even exist.

Open:

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

These three files are the full inventory. Each `single<Contract> { Implementation() }` line tells you which contract is wired to which implementation. `DataModule.kt` is shared; the other two are each platform's own part.

Here is the overall picture:

| Area             | Contract                | Current implementation              | Real or mock                 |
| ---------------- | ----------------------- | ----------------------------------- | ---------------------------- |
| Quiz questions   | `QuestionRepository`    | `MockQuestionRepository`            | Mock, read from a Kotlin file |
| Sign-in          | `AuthService`           | `AuthServiceSupabaseImpl`           | Real, calls Supabase         |
| User profile     | `UserService`           | `UserServiceSupabaseImpl`           | Real, calls Supabase         |
| Leaderboard      | `LeaderboardRepository` | `LeaderboardRepositoryImpl`         | Real, calls Supabase         |
| AI hint          | `AIHintService`         | `AIHintServiceImpl` plus 2 providers | Real, with an offline fallback |
| Play history     | `GameHistoryRepository` | `LocalGameHistoryRepository`        | Real, stored on the device   |
| Audio            | `AudioManager`          | Android: `SoundPoolAudioManager`. iOS: `AvAudioManager` | Real, each platform's SDK |
| Remote config    | `RemoteConfigManager`   | Android: `FirebaseRemoteConfigManager`. iOS: `DefaultRemoteConfigManager` | Real on Android. On iOS it's a placeholder that returns an empty config |

The most important line for you right now:

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

Every question in the game comes from a Kotlin file. There is no API to call and no database to query.

For the scope of the course, that's a sensible decision: later you can swap the content for a real backend and only `:data` has to change, because `QuestionRepository` has been a contract in `:domain` from the start.

> This is the Dependency Inversion principle, the D in SOLID: the game only depends on the `QuestionRepository` contract in `:domain`, and the implementation sits behind it in `:data`.

## Part 2: Four Questions Before You Change Anything

Before each change, stop for 60 seconds and answer these four questions. You will use this process in every codebase, not just in this course.

| Question                             | Why it matters                                |
| ------------------------------------ | --------------------------------------------- |
| 1. Which module and source set does this file belong to? | Decides who may depend on it, and which platform it runs on |
| 2. Who else uses it?                 | That is your blast radius                     |
| 3. Is there a test protecting it?    | If so, that's a free safety net               |
| 4. Which command verifies it?        | If you can't answer this, you shouldn't change it yet |

Question 2 is the one people skip most. In Android Studio, use `Find Usages` on the class name. A change that looks small can reach more places than you think.

## Part 3: Drill 1, Add A SampleQuestions Entry

Blast radius: one code file, plus the test that protects it.

Open:

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

Paste the entry below right after the `// Easy questions (1-5)` comment line. The trailing comma is required because more questions follow:

```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
    ),
```

Every sample question is in English with `language = "en"`, and a test checks that, so the new question is written in English too.

Build and run the app, and start a new round.

### A Surprise Is Waiting For You

The new question might not appear in the round you just played. Don't jump to the conclusion that you did something wrong.

Open `MockQuestionRepository.kt` and read:

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

Then open `domain/.../config/QuizGameConfig.kt`:

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

Before your change, the EASY pool had exactly 5 questions and the game took 5. So every question appeared, only in a different order.

After you add a 6th question, the game still takes only 5. Your question has about a 5 in 6 chance of being picked each round, so roughly one round in six won't include it.

> Read the code before you draw conclusions about a behavior. A lot of debugging hours get burned by skipping this step.

Verify:

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

`:data` is a KMP module, so its tests run with `testAndroidHostTest`, not `testDebugUnitTest` (the `testDebugUnitTest` task only exists in plain Android modules like `:app`; KMP modules use `androidHostTest` for tests on the JVM).

This command will fail. `MockQuestionRepositoryTest` lives in `data/src/commonTest/`. The test `getQuestionsByDifficulty when EASY and count exceeds available then returns all available` still expects exactly 5 EASY questions, so it fails for sure. Two other EASY tests only accept ids 1 to 5, so they fail whenever the random pick lands on question id 16. The tests are telling you the EASY pool changed. Update the tests to match the new data:

- In the two EASY tests `... returns questions with IDs 1 to 5` and `... count is 3 then returns 3 questions`, change `question.id in 1..5` to `question.id in 1..5 || question.id == 16`.
- In the `... count exceeds available ...` test, change `assertEquals(5, result.size)` to `assertEquals(6, result.size)` and fix the comment above it.

Run it a few more times until it passes consistently. The tests involve randomness, so one pass doesn't prove much.

## Part 4: Drill 2, Swap The AI Hint Providers

Blast radius: one file, but it changes the runtime behavior of a whole feature.

Open `DataModule.kt` and find the AI Hint Service block:

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

Swap the last two lines:

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

Run the app on Android, start a game and use the AI Assistant lifeline. If your Firebase Remote Config already has a Gemini API key, the hint text changes: it now comes from `LocalHintProvider` instead of calling Gemini. If there's no key yet, `GeminiHintProvider.isAvailable()` returns `false`, so both before and after the swap you get hints from `LocalHintProvider`.

On iOS you won't see any difference. `DataModule.ios.kt` binds `RemoteConfigManager` to `DefaultRemoteConfigManager`, which returns a Gemini config with an empty API key, so iOS always uses local hints. The same line of shared code behaves differently depending on each platform's bindings. The tests at the end of this part are the reliable way to check it.

### Now Count How Many Files You Changed

One. Two lines swapped in `:data`'s `DataModule.kt`.

What you didn't have to change:

| Unchanged                        | Why                                                  |
| -------------------------------- | ---------------------------------------------------- |
| `AIHintService` in `:domain`     | The contract didn't change, only the implementation  |
| `DefaultLifelineHelper`          | It injects the `AIHintService` contract and doesn't know which provider sits behind it |
| `GameViewModel`                  | It only receives `LifelineHelper` through Koin and never touches a provider |
| `GameStateMachine`               | The reducer knows nothing about providers            |
| `GameScreenCompose`              | The UI only renders the result                       |
| Any file in `:feature`           | No feature imports a provider class                  |

Separating the contract from the implementation can sound like theory. You have now checked it with your own hands.

Imagine the opposite: if `GameViewModel` created its own Gemini client, this change would touch the feature module, maybe the UI too, and every game test would have to change with it.

Verify:

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

The second command rebuilds the whole Koin graph to make sure the binding change didn't break any dependency.

## Part 5: Drill 3, Change A Design Token

Blast radius: many screens at once, on both platforms.

Open:

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

Find:

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

Change `Md` to `Lg` (from 16dp to 24dp). Run the app and go through the menu, settings, leaderboard and game.

Every screen that uses `screenPadding` gets wider at the same time. If you have a Mac, run it again on the iOS Simulator: the change is identical, because `:theme` lives in `commonMain`.

Now open `QzdsPrimitiveSpacing.kt` and notice this line:

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

This is a design decision written right into the code: the team deliberately set `Sm` to 10dp instead of the familiar 8dp. Every place that uses `Sm` gets this value, so changing that line changes the spacing rhythm of the whole product.

> Some small files have a very large blast radius.

## Part 6: Verify And Roll Back

Never finish a change without this step.

Run the narrow tests first, then the broad ones:

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

The last command is the CI command that runs on every pull request. On a Mac, add `./gradlew :data:allTests` to run `:data`'s `commonTest` on the iOS Simulator.

Install it on the device:

```bash
./gradlew installDebug
```

Review exactly what you changed:

```bash
git diff
```

And undo it when needed:

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

`git reset --hard` deletes every uncommitted change in the files Git tracks. Read `git diff` before you run it.

## Part 7: From Drills To Your Own Ideas

The three drills above are ordered by how far they spread: one file, one behavior, the whole interface. Most of your ideas will fall into one of these three groups.

A few directions to try next, with the modules they would touch:

| Idea                                     | Modules touched                       | Level      |
| ---------------------------------------- | ------------------------------------- | ---------- |
| Rewrite every question in Vietnamese     | `:data`                               | Low        |
| Change the time per question             | `:domain` (`QuizGameConfig.TIME_PER_QUESTION`) | Low |
| Change the prize money ladder            | `:domain` (`QuizGameConfig.MONEY_LADDER`) | Medium |
| Add a new lifeline                       | `:domain`, `:feature:game`            | High       |
| Replace the mock questions with a real backend | `:data` (just one new implementation) | Medium |

Look at the last row. Replacing the whole question source with a real backend only needs one new implementation in `:data` and one changed line in `DataModule.kt`. No feature has to change, on either platform.

## Checkpoint

Finish all three drills, then roll back cleanly with `git reset --hard`.

You are done with this lesson when you can:

- Explain why your new question doesn't show up in every round,
- Name at least four things you didn't have to change when you swapped the AI hint providers,
- Explain why swapping the providers changes nothing on iOS,
- Point to the file with the largest blast radius of the three drills and explain why,
- Propose a fourth change of your own.

If you can do the last one, you have read this project deeply enough to plan a change by yourself.

---

- Previous lesson: [Layer Responsibilities And How To Read File Names](https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-production-app-map/lesson-04-spot-layer-responsibilities.md)
- Next lesson: [Anatomy Of A KMP Project](https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-01-kmp-project-anatomy.md)
