Dan Tech Academy
After this lesson you will
  • 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
Duration10 min
DifficultyBeginner

Your First Safe Change

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

Video in production

Lesson video is on the way

While you wait, read the full text version below: complete theory, code samples and the hands-on checklist.

Recording is in post-production

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:

AreaContractCurrent implementationReal or mock
Quiz questionsQuestionRepositoryMockQuestionRepositoryMock, read from a Kotlin file
Sign-inAuthServiceAuthServiceSupabaseImplReal, calls Supabase
User profileUserServiceUserServiceSupabaseImplReal, calls Supabase
LeaderboardLeaderboardRepositoryLeaderboardRepositoryImplReal, calls Supabase
AI hintAIHintServiceAIHintServiceImpl plus 2 providersReal, with an offline fallback
Play historyGameHistoryRepositoryLocalGameHistoryRepositoryReal, stored on the device
AudioAudioManagerAndroid: SoundPoolAudioManager. iOS: AvAudioManagerReal, each platform's SDK
Remote configRemoteConfigManagerAndroid: FirebaseRemoteConfigManager. iOS: DefaultRemoteConfigManagerReal 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.

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

UnchangedWhy
AIHintService in :domainThe contract didn't change, only the implementation
DefaultLifelineHelperIt injects the AIHintService contract and doesn't know which provider sits behind it
GameViewModelIt only receives LifelineHelper through Koin and never touches a provider
GameStateMachineThe reducer knows nothing about providers
GameScreenComposeThe UI only renders the result
Any file in :featureNo 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:

IdeaModules touchedLevel
Rewrite every question in Vietnamese:dataLow
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:gameHigh
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.

Sign in