- 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
Your First Safe Change
Know what is real and what is mocked, then change the codebase yourself without breaking any boundary.
Lesson video is on the way
While you wait, read the full text version below: complete theory, code samples and the hands-on checklist.
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:
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:
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
QuestionRepositorycontract 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:
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:
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:
return SampleQuestions
.filter { it.difficulty == difficulty }
.shuffled()
.take(count)
Then open domain/.../config/QuizGameConfig.kt:
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:
./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 5and... count is 3 then returns 3 questions, changequestion.id in 1..5toquestion.id in 1..5 || question.id == 16. - In the
... count exceeds available ...test, changeassertEquals(5, result.size)toassertEquals(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:
single<AIHintService> {
AIHintServiceImpl(
remoteConfigManager = get(),
primaryProvider = get(AIHintProviderType.GEMINI),
fallbackProvider = get(AIHintProviderType.LOCAL)
)
}
Swap the last two lines:
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:
./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:
theme/src/commonMain/kotlin/com/creative/kta/theme/QzdsSpacingTokens.kt
Find:
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:
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:
./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:
./gradlew installDebug
Review exactly what you changed:
git diff
And undo it when needed:
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.