Refactor Kotlin Profile Persistence
Refactor Kotlin profiles with safe MongoDB and Hazelcast persistence boundaries.
Introduction
30 Second Summary
A player's coins, rank, and online status can change many times during one game. When live changes share the same object as saved data, a routine update can alter the stored profile before anyone chooses to save it.
In this project, you will build a runnable Kotlin architecture lab that separates mutable gameplay state from immutable saved snapshots. You will compile MongoDB and Hazelcast adapters behind one repository boundary.
What You'll Build
You will run a profile demo that exposes an early storage leak before finishing with BOUNDARY SAFE: stored state changed only after save and MAPSTORE READY: profiles uses write-behind in your terminal.
By the end of this project, you'll have:
- A repeatable demonstration of the shared-reference bug that changes stored profile data before an explicit save.
- A safe flow where an immutable profile document becomes a separate mutable player session for gameplay changes.
- A compiled persistence boundary that supports MongoDB operations plus a synchronous Hazelcast MapStore adapter without requiring a running database server.
- Secret Mission: Add dirty tracking so unchanged player sessions skip repository writes while changed sessions save successfully.
Are there any prerequisites?
You need your existing Skyland Kotlin codebase plus a Linux shell with curl, zip, and unzip. The project includes setup guidance for the JDK and Gradle when needed.
Before We Start
This first checkpoint locks in the Skyland profile mutation you most want to make safe. Your goal is to test an explicit save boundary independently of the existing codebase before any refactoring begins.
Set Up a Version-Pinned Refactor Lab
Your existing Skyland server may use dependency versions that this refactor has not verified. Changing them before proving the boundary could turn an architecture exercise into a production upgrade.
An isolated lab gives the new design a controlled Gradle toolchain. You can validate current Kotlin APIs beside the server without changing its production dependencies.
In this step, get ready to:
- Verify the version-pinned Java toolchain.
- Create the isolated Gradle lab with pinned dependencies.
- Build the empty source layout through the Gradle Wrapper.
Prepare the pinned toolchain
The lab needs JDK 25 plus a system Gradle installation that can generate the wrapper. SDKMAN! manages both tools without changing the files in your existing Skyland codebase.
The Java candidate comes from Eclipse Temurin. The system Gradle installation is used once before the project switches to its pinned wrapper.
- Check the active Java version plus the system Gradle version by running these commands:
java --version
gradle --version
What do these checks show?
- The java command reports the active JVM.
- The gradle command reports the system Gradle installation.
✔️ I see Java 25 and Gradle 9.7.1
Your system toolchain has the required versions. Continue to the shared confirmation below.
ⓧ I see an older version
An older JVM or Gradle installation could produce results that do not match this lab. SDKMAN! gives the shell the required candidates.
- Install SDKMAN! plus the required candidates by running these commands:
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
sdk install java -tem
sdk install gradle
What does this setup do?
- The first command installs SDKMAN! for your Linux account.
- The second command initializes SDKMAN! in the current shell.
- The Java installation selects the Eclipse Temurin candidate.
- The Gradle installation selects the current Gradle candidate.
Toolchain installation stopped?
- Check that the shell has access to curl plus the required archive tools.
- Initialize SDKMAN! again if the sdk command is unavailable after installation.
Get targeted help with a failed SDKMAN! installation.
ⓧ Command not found
The missing command means the required toolchain is unavailable in this shell. SDKMAN! installs the Java candidate plus Gradle for your account.
- Install SDKMAN! plus the required candidates by running these commands:
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
sdk install java -tem
sdk install gradle
What does this setup do?
- The first command installs SDKMAN! for your Linux account.
- The second command initializes SDKMAN! in the current shell.
- The Java installation selects the Eclipse Temurin candidate.
- The Gradle installation selects the current Gradle candidate.
Commands still unavailable?
- Initialize SDKMAN! again in the current shell.
- Restart the Linux shell if the new commands remain unavailable.
Get targeted help with a missing Java or Gradle command.
- Confirm the selected Java candidate plus the Gradle installation by running the version checks again:
java --version
gradle --version
What confirms the toolchain?
The Java output identifies JDK 25. The Gradle output identifies version 9.7.1.
Your system toolchain is ready to generate the lab's project-specific wrapper.
Create the pinned Gradle project
The project files pin every build input that affects this architecture lab. The wrapper then makes the same Gradle release available whenever you return to the project.
- Move to your Desktop by running this command:
cd ~/Desktop
Why start on the Desktop?
This location keeps the lab beside your existing work. It also gives the new folder a predictable path.
- Create profile-refactor-lab plus enter it by running these commands:
mkdir profile-refactor-lab
cd profile-refactor-lab
What do these commands do?
- The first command creates the isolated profile-refactor-lab directory.
- The second command makes that directory the shell's current location.
- Confirm the shell location by running this command:
pwd
What should the path confirm?
The printed path should end with Desktop/profile-refactor-lab. This keeps every generated file inside the isolated lab.
- Create settings.gradle.kts inside profile-refactor-lab by pasting this code into a new editor file:
rootProject.name = "profile-refactor-lab"
What does this setting do?
The root project name gives Gradle a stable identity for the lab. It matches the directory that contains the build.
- Save settings.gradle.kts.
- Confirm the editor tab is named settings.gradle.kts.
Wrong settings filename?
- Check that the file sits directly inside profile-refactor-lab.
- Check that the filename ends with .gradle.kts.
Get help with placing the Gradle settings file.
- Create build.gradle.kts inside profile-refactor-lab by pasting this code into a new editor file:
plugins {
kotlin("jvm") version "2.4.20"
application
}
repositories {
mavenCentral()
}
dependencies {
implementation("com.hazelcast:hazelcast:5.7.0")
implementation("org.mongodb:mongodb-driver-kotlin-coroutine:5.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
}
kotlin {
jvmToolchain(25)
}
application {
mainClass.set("me.ilovejerry1.skyland.refactor.ProfileRefactorDemoKt")
}
What does this configuration do?
- The plugins configure Kotlin JVM 2.4.20 plus an executable application.
- The repository block resolves dependencies from Maven Central.
- The dependency block pins Hazelcast 5.7.0.
- The same block pins the MongoDB Kotlin Coroutine Driver at 5.12.0.
- The coroutine dependency pins kotlinx.coroutines at 1.11.0.
- The toolchain block compiles the lab with Java 25.
- The application block identifies ProfileRefactorDemoKt as the future entry point.
- Save build.gradle.kts.
- Confirm the editor tab is named build.gradle.kts.
Build file looks incomplete?
- Check that all three dependency declarations remain inside the dependencies block.
- Check that every opening brace has a matching closing brace.
Get help with checking the Gradle Kotlin DSL file.
✔️ Awesome, I've got everything!
Great. Double check that both Gradle files are saved inside profile-refactor-lab.
ⓧ I'd like to double check the full code
rootProject.name = "profile-refactor-lab"
What should match here?
Your complete settings.gradle.kts file contains this single project-name declaration.
plugins {
kotlin("jvm") version "2.4.20"
application
}
repositories {
mavenCentral()
}
dependencies {
implementation("com.hazelcast:hazelcast:5.7.0")
implementation("org.mongodb:mongodb-driver-kotlin-coroutine:5.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
}
kotlin {
jvmToolchain(25)
}
application {
mainClass.set("me.ilovejerry1.skyland.refactor.ProfileRefactorDemoKt")
}
What should match here?
Your complete build.gradle.kts file contains the pinned plugins plus dependencies. It also contains the Java toolchain plus application entry point.
- Generate the Gradle 9.7.1 Wrapper by running this command:
gradle :wrapper --gradle-version 9.7.1 --distribution-type all
What does the wrapper command do?
The :wrapper task creates project-local launchers plus wrapper configuration. The all distribution pins Gradle 9.7.1 for future commands.
- Confirm the wrapper configuration exists by running this command:
ls gradle/wrapper
What confirms the wrapper exists?
The output should include gradle-wrapper.properties. That file points the project at the pinned Gradle distribution.
Wrapper files missing?
- Run pwd to confirm the shell is inside profile-refactor-lab.
- Check that both Gradle files are saved before generating the wrapper.
Get help with a failed Gradle Wrapper task.
Build the lab with the wrapper
The source package reserves the namespace used by every repository plus adapter in the later steps. Building now proves the pinned dependencies can resolve before application code enters the picture.
- Create the Kotlin source package by running this command:
mkdir -p src/main/kotlin/me/ilovejerry1/skyland/refactor
What does this directory hold?
The nested path matches the package used by the refactor classes. The parent-directory option creates every missing directory in one command.
- Confirm the refactor directory exists by running this command:
ls src/main/kotlin/me/ilovejerry1/skyland
What confirms the source layout?
The output should include refactor. The lab now has the package directory required by the next step.
The first build downloads the pinned dependencies. Allow a few minutes for this initial run.
Before you run this, do you expect an empty source package to stop the configured build?
- Build the isolated lab through its wrapper by running this command:
./gradlew build
What does this build prove?
- The wrapper runs the project with Gradle 9.7.1.
- The build resolves every pinned library from the configured repository.
- The Java toolchain validates the JVM target before later code is compiled.
You should see BUILD SUCCESSFUL near the end of the terminal output. That is the toolchain locked down.
Build did not succeed?
- Check the reported filename for a typo in either Gradle Kotlin DSL file.
- Check that Java 25 is active in the current shell.
- Check that the shell has network access for the first dependency download.
Get help with diagnosing the failed Gradle build.
Before the final check, which Gradle version plus JVM major version should the wrapper report?
- Inspect the wrapper version plus its JVM by running this command:
./gradlew --version
What does the final check prove?
The project-local launcher reads the generated wrapper configuration. Its report identifies the Gradle release plus the JVM running the build.
You should see Gradle 9.7.1 plus JVM 25 in the output. Your isolated refactor lab now builds with the intended toolchain.
Your refactor lab is isolated from the production server. Next, you will load a Skyland profile through its first repository boundary.
Extract a Profile Repository Boundary
Your isolated Kotlin lab already builds through its Gradle Wrapper. Skyland profile callers still lack one vocabulary for loading stored profile state.
The repository pattern gives callers that vocabulary. Dependency inversion keeps profile code focused on player state while an adapter handles storage.
In this step, get ready to:
- Define a profile record with a persistence contract.
- Store profile records behind an in-memory repository.
- Load a seeded player through the repository boundary.
Define the profile record and repository contract
A persistence boundary starts with the shape of the stored data. The contract then describes the operations available to every storage adapter.
- Create ProfileDocument.kt inside src/main/kotlin/me/ilovejerry1/skyland/refactor by pasting the code below into a new editor file:
package me.ilovejerry1.skyland.refactor
data class ProfileDocument(
val uuid: String,
val username: String,
val rankId: String = "default",
val online: Boolean = false,
val coins: Int = 0,
)
What does this record represent?
- ProfileDocument gives persisted player state a dedicated type.
- uuid identifies the player record.
- username, rankId, online, and coins capture the profile values that storage adapters handle.
- Save ProfileDocument.kt.
- Confirm ProfileDocument.kt appears in your editor's file list.
Cannot find the profile record?
- Check that ProfileDocument.kt is inside the existing src/main/kotlin/me/ilovejerry1/skyland/refactor directory.
- Check that the filename uses the same capital letters as ProfileDocument.
Help me check my ProfileDocument file location and Kotlin package declaration.
The repository contract exposes storage operations without choosing a database. Its suspending functions also leave room for adapters that perform asynchronous work.
- Create ProfileRepository.kt inside src/main/kotlin/me/ilovejerry1/skyland/refactor by pasting the code below into a new editor file:
package me.ilovejerry1.skyland.refactor
interface ProfileRepository {
suspend fun load(uuid: String): ProfileDocument?
suspend fun save(document: ProfileDocument)
suspend fun delete(uuid: String)
}
What does this contract provide?
- load() requests a profile by player identifier.
- save() passes a complete profile record to the active adapter.
- delete() removes the profile associated with an identifier.
- suspend allows implementations to wait for storage operations without placing storage code inside the profile type.
- Save ProfileRepository.kt.
- Confirm ProfileRepository.kt appears beside ProfileDocument.kt in your editor's file list.
Does the contract show unresolved types?
- Check that both files begin with package me.ilovejerry1.skyland.refactor.
- Check that the return type includes the question mark in ProfileDocument?.
Help me resolve the ProfileDocument reference in ProfileRepository.
Implement the in-memory repository
A ConcurrentHashMap gives the lab a local storage adapter that needs no database server. Callers still depend only on ProfileRepository.
- Create InMemoryProfileRepository.kt inside src/main/kotlin/me/ilovejerry1/skyland/refactor by pasting the code below into a new editor file:
package me.ilovejerry1.skyland.refactor
import java.util.concurrent.ConcurrentHashMap
class InMemoryProfileRepository(
initialDocuments: Iterable<ProfileDocument> = emptyList(),
) : ProfileRepository {
private val documents = ConcurrentHashMap(
initialDocuments.associateBy(ProfileDocument::uuid),
)
override suspend fun load(uuid: String): ProfileDocument? =
documents[uuid]?.copy()
override suspend fun save(document: ProfileDocument) {
documents[document.uuid] = document.copy()
}
override suspend fun delete(uuid: String) {
documents.remove(uuid)
}
}
How does the adapter work?
- initialDocuments lets the demo seed profiles when the repository is created.
- documents indexes each profile by its uuid.
- load(), save(), and delete() implement the same vocabulary exposed by ProfileRepository.
- Save InMemoryProfileRepository.kt.
- Confirm InMemoryProfileRepository.kt appears beside the two boundary files.
Why start in memory?
The in-memory adapter makes the boundary runnable without credentials or a database process. Later adapters can implement the same contract while the caller stays unchanged.
Does the adapter fail to compile in your editor?
- Check that the import is exactly java.util.concurrent.ConcurrentHashMap.
- Check that InMemoryProfileRepository implements ProfileRepository.
- Check that each overridden function keeps the suspend modifier.
Help me diagnose my InMemoryProfileRepository implementation.
Load a seeded profile through the boundary
The entry point now needs one observable path through the boundary. A coroutine block lets the regular application entry point call the suspending repository operation.
- Create ProfileRefactorDemo.kt inside src/main/kotlin/me/ilovejerry1/skyland/refactor by pasting the code below into a new editor file:
package me.ilovejerry1.skyland.refactor
import kotlinx.coroutines.runBlocking
fun main() {
val initial = ProfileDocument(
uuid = "player-42",
username = "Jerry",
rankId = "vip",
online = false,
coins = 10,
)
val repository = InMemoryProfileRepository(listOf(initial))
runBlocking {
val firstLoad = checkNotNull(repository.load(initial.uuid))
println("Loaded snapshot: $firstLoad")
}
}
How does the demo reach storage?
- initial holds the seeded player-42 profile.
- repository stores the seed behind the repository interface.
- runBlocking provides the coroutine context needed by load().
- checkNotNull() stops the demo if the repository cannot return the seed.
- println() makes the loaded record visible in the terminal.
- Save ProfileRefactorDemo.kt.
- Confirm ProfileRefactorDemo.kt appears beside the repository files.
Before you run the demo, do you expect the entry point to reach player-42 through the repository?
- Run the repository demo with the verified wrapper by running:
./gradlew run
What does this check?
The wrapper compiles the Kotlin sources before starting ProfileRefactorDemoKt. A printed profile proves that the entry point reached stored state through the repository implementation.
You will see a line beginning with Loaded snapshot: followed by the values for player-42.
That result is your first working persistence boundary. Profile callers can now load state without calling a database API.
Do you miss the loaded snapshot?
- Check that all four Kotlin files use the same package declaration.
- Check that ProfileRefactorDemo.kt contains fun main().
- Check that the seeded identifier remains player-42.
Help me troubleshoot why the repository demo does not print the loaded snapshot.
✔️ Awesome, I've got everything
Your four Kotlin files now form a runnable repository slice. The terminal output confirms that the seed crosses the new boundary.
ⓧ I'd like to double check the full code
ProfileDocument.kt
package me.ilovejerry1.skyland.refactor
data class ProfileDocument(
val uuid: String,
val username: String,
val rankId: String = "default",
val online: Boolean = false,
val coins: Int = 0,
)
What should match?
This file defines the complete profile record used by the repository.
ProfileRepository.kt
package me.ilovejerry1.skyland.refactor
interface ProfileRepository {
suspend fun load(uuid: String): ProfileDocument?
suspend fun save(document: ProfileDocument)
suspend fun delete(uuid: String)
}
What should match?
This file contains the complete suspending repository contract.
InMemoryProfileRepository.kt
package me.ilovejerry1.skyland.refactor
import java.util.concurrent.ConcurrentHashMap
class InMemoryProfileRepository(
initialDocuments: Iterable<ProfileDocument> = emptyList(),
) : ProfileRepository {
private val documents = ConcurrentHashMap(
initialDocuments.associateBy(ProfileDocument::uuid),
)
override suspend fun load(uuid: String): ProfileDocument? =
documents[uuid]?.copy()
override suspend fun save(document: ProfileDocument) {
documents[document.uuid] = document.copy()
}
override suspend fun delete(uuid: String) {
documents.remove(uuid)
}
}
What should match?
This file contains the complete in-memory implementation for the current step.
ProfileRefactorDemo.kt
package me.ilovejerry1.skyland.refactor
import kotlinx.coroutines.runBlocking
fun main() {
val initial = ProfileDocument(
uuid = "player-42",
username = "Jerry",
rankId = "vip",
online = false,
coins = 10,
)
val repository = InMemoryProfileRepository(listOf(initial))
runBlocking {
val firstLoad = checkNotNull(repository.load(initial.uuid))
println("Loaded snapshot: $firstLoad")
}
}
What should match?
This file contains the complete runnable demo for the current step.
Your profile now crosses one repository boundary. Next, you will stress-test what happens when gameplay code shares the same stored object.
Expose the Shared-Mutable-State Leak
Your repository boundary now loads player-42 through ProfileRepository. The result reaches the demo through one persistence vocabulary.
A mutable object stored by reference creates an aliasing bug. Changing the loaded object then changes stored state before save() runs.
This step deliberately exposes the shared reference. The visible leak gives the next refactor a concrete target.
In this step, get ready to:
- Return the stored mutable profile instance directly.
- Change the loaded coin value without calling save().
- Reload the profile to expose the shared-state leak.
Expose the repository's shared reference
The existing ProfileDocument.kt already gives coins mutable state. The repository now needs to return its stored instance directly.
- In the editor from the previous step, replace the contents of src/main/kotlin/me/ilovejerry1/skyland/refactor/InMemoryProfileRepository.kt with this temporary leak implementation:
package me.ilovejerry1.skyland.refactor
import java.util.concurrent.ConcurrentHashMap
class InMemoryProfileRepository(
initialDocuments: Iterable<ProfileDocument> = emptyList(),
) : ProfileRepository {
private val documents = ConcurrentHashMap(
initialDocuments.associateBy(ProfileDocument::uuid),
)
// Return the stored reference to expose the aliasing problem
override suspend fun load(uuid: String): ProfileDocument? =
documents[uuid]
// Keep the caller's instance instead of creating a snapshot
override suspend fun save(document: ProfileDocument) {
documents[document.uuid] = document
}
override suspend fun delete(uuid: String) {
documents.remove(uuid)
}
}
What does this code change?
- The ConcurrentHashMap stores each mutable ProfileDocument instance directly.
- The load() operation returns the same instance held by the map.
- The save() operation keeps the caller's instance without creating a copy.
- Save InMemoryProfileRepository.kt.
- Confirm the repository still runs by using the existing Gradle Wrapper:
./gradlew run
What does this run prove?
The terminal still prints a line beginning with Loaded snapshot:. This confirms the direct-reference repository compiles before the demo starts mutating its result.
Good progress. The repository still loads the seeded profile through the same boundary.
Did the demo stop compiling?
- Check that load() ends with documents[uuid].
- Check that save() assigns document directly to documents[document.uuid].
Help me fix this repository compilation problem.
✔️ Awesome, I've got everything!
Your temporary repository now stores mutable profile instances directly. Keep the file saved before moving to the demo.
ⓧ I'd like to double check the full code
package me.ilovejerry1.skyland.refactor
import java.util.concurrent.ConcurrentHashMap
class InMemoryProfileRepository(
initialDocuments: Iterable<ProfileDocument> = emptyList(),
) : ProfileRepository {
private val documents = ConcurrentHashMap(
initialDocuments.associateBy(ProfileDocument::uuid),
)
// Return the stored reference to expose the aliasing problem
override suspend fun load(uuid: String): ProfileDocument? =
documents[uuid]
// Keep the caller's instance instead of creating a snapshot
override suspend fun save(document: ProfileDocument) {
documents[document.uuid] = document
}
override suspend fun delete(uuid: String) {
documents.remove(uuid)
}
}
How to compare this file
Confirm that load() returns documents[uuid] directly. Confirm that save() stores document directly.
Trigger the leak without saving
The shared reference stays hidden until code mutates the loaded object and reads it again. The demo omits save() between those operations.
- Replace the contents of src/main/kotlin/me/ilovejerry1/skyland/refactor/ProfileRefactorDemo.kt with this temporary leak demo:
package me.ilovejerry1.skyland.refactor
import kotlinx.coroutines.runBlocking
fun main() {
val initial = ProfileDocument(
uuid = "player-42",
username = "Jerry",
rankId = "vip",
online = false,
coins = 10,
)
val repository = InMemoryProfileRepository(listOf(initial))
runBlocking {
// Load the mutable instance held by the repository
val firstLoad = checkNotNull(repository.load(initial.uuid))
println("Loaded snapshot: $firstLoad")
// Change gameplay state without crossing the save boundary
firstLoad.coins += 15
val secondLoad = checkNotNull(repository.load(initial.uuid))
check(secondLoad.coins == 25)
println("LEAK DETECTED")
}
}
What does this demo test?
- The first load() retrieves the mutable instance stored for player-42.
- The coin mutation adds 15 without calling save().
- The second load() checks whether the repository already contains 25 coins.
- The final message records the shared-reference failure for the next refactor.
- Save ProfileRefactorDemo.kt.
Before you run the demo, predict whether the second load still contains 10 coins.
- Expose the stored-state leak by running:
./gradlew run
What should you see?
- The first output line begins with Loaded snapshot:.
- The final output line reads LEAK DETECTED.
- The second load contains 25 coins even though no call to save() occurred.
You found the hidden failure. A gameplay mutation can now bypass the repository's explicit save operation.
Do you see a failed check?
- Confirm that ProfileDocument.kt declares coins as mutable.
- Confirm that InMemoryProfileRepository.kt returns documents[uuid] without calling copy().
- Confirm that the demo adds 15 coins before the second load() call.
Help me reproduce the mutable profile leak.
✔️ Awesome, I've got everything!
Your demo now reproduces the shared-reference leak. Keep this result as evidence for the document and session split.
ⓧ I'd like to double check the full code
package me.ilovejerry1.skyland.refactor
import kotlinx.coroutines.runBlocking
fun main() {
val initial = ProfileDocument(
uuid = "player-42",
username = "Jerry",
rankId = "vip",
online = false,
coins = 10,
)
val repository = InMemoryProfileRepository(listOf(initial))
runBlocking {
// Load the mutable instance held by the repository
val firstLoad = checkNotNull(repository.load(initial.uuid))
println("Loaded snapshot: $firstLoad")
// Change gameplay state without crossing the save boundary
firstLoad.coins += 15
val secondLoad = checkNotNull(repository.load(initial.uuid))
check(secondLoad.coins == 25)
println("LEAK DETECTED")
}
}
How to compare this file
Confirm that the demo mutates firstLoad.coins before loading the profile again. Confirm that no save() call appears between the two loads.
The leak is now reproducible. Next, you will separate immutable documents from mutable sessions so profile state changes only at an explicit save boundary.
Separate Documents from Sessions
The leak you reproduced came from one object playing two roles. A gameplay mutation changed the same instance held by the repository boundary.
This step gives persisted state an immutable persistence document. Gameplay changes move into a separate mutable domain session with an explicit save boundary.
In this step, get ready to:
- Build a mutable PlayerSession for gameplay changes.
- Replace the temporary mutable record with an immutable ProfileDocument.
- Prove that stored state changes only after an explicit save.
Build the mutable player session
A PlayerSession gives gameplay code a safe place to change profile values. Private setters ensure those changes pass through named methods.
- Create PlayerSession.kt inside src/main/kotlin/me/ilovejerry1/skyland/refactor using your code editor.
- Add the session constructor and mutable properties by pasting this code into the new file:
package me.ilovejerry1.skyland.refactor
class PlayerSession private constructor(
val uuid: String,
username: String,
rankId: String,
online: Boolean,
coins: Int,
) {
var username: String = username
private set
var rankId: String = rankId
private set
var online: Boolean = online
private set
var coins: Int = coins
private set
}
What does this class establish?
- The private constructor reserves session creation for the mapping function you add shortly.
- The public properties let gameplay code read the current profile state.
- The private setters stop callers from replacing values without using a session method.
- Save PlayerSession.kt.
- Compile the session shell by running this command:
./gradlew build
What does this build check prove?
The build compiles PlayerSession.kt alongside the existing leak demo. You should see BUILD SUCCESSFUL when the constructor and braces are valid.
Seeing a session compilation error?
- Confirm that PlayerSession.kt uses the same package declaration as the other refactor files.
- Check that each property remains inside the final class brace.
Ask for help with the class structure:
Named mutation methods keep gameplay rules beside the state they protect. They also prevent arbitrary property assignment from spreading through command handlers.
- In PlayerSession.kt place your cursor immediately above the final class brace.
- Add the gameplay mutation methods by pasting this code:
fun rename(newUsername: String) {
require(newUsername.isNotBlank())
username = newUsername
}
fun changeRank(newRankId: String) {
require(newRankId.isNotBlank())
rankId = newRankId
}
fun markOnline() {
online = true
}
fun markOffline() {
online = false
}
fun rewardCoins(amount: Int) {
require(amount > 0)
coins += amount
}
What do these methods protect?
- The rename and rank methods reject blank identifiers before changing the session.
- The online methods express presence changes without exposing a public setter.
- The coin method accepts only positive rewards before updating the balance.
- Save PlayerSession.kt.
- Compile the mutation methods by running this command:
./gradlew build
What does this build check prove?
The compiler now accepts each gameplay method with its private property setter. You should see BUILD SUCCESSFUL again.
Do the mutation methods fail to compile?
- Confirm that every method sits inside the PlayerSession class.
- Check that the closing brace for rewardCoins() appears before the class brace.
Ask for help locating a misplaced brace:
The session needs one mapping path in each direction. These mappings create a mutable session from a snapshot or produce a new snapshot for saving.
- In PlayerSession.kt place your cursor immediately above the final class brace.
- Add the document mapping methods by pasting this code:
fun toDocument(): ProfileDocument = ProfileDocument(
uuid = uuid,
username = username,
rankId = rankId,
online = online,
coins = coins,
)
companion object {
fun fromDocument(document: ProfileDocument): PlayerSession = PlayerSession(
uuid = document.uuid,
username = document.username,
rankId = document.rankId,
online = document.online,
coins = document.coins,
)
}
Where is the save boundary?
- The fromDocument() function copies snapshot values into a new session.
- The toDocument() function captures the current session values in a new document.
- The repository receives a document only when the caller explicitly converts the session.
- Save PlayerSession.kt.
- Compile the complete session by running this command:
./gradlew build
What does the completed build prove?
The session can now map values in both directions without depending on a database API. You should see BUILD SUCCESSFUL in the terminal.
Are the mapping functions unresolved?
- Confirm that toDocument() appears before the companion object.
- Confirm that fromDocument() remains inside the companion object.
- Check every mapped property against the spelling in ProfileDocument.
Ask for help with the document mappings:
Route gameplay changes through the session
The old demo changes the loaded persistence object directly. The new flow loads a snapshot before transferring its values into a separate session.
- In ProfileRefactorDemo.kt select the existing main() function from its declaration through its closing brace.
- Replace the selected function with this code:
fun main() {
val initial = ProfileDocument(
uuid = "player-42",
username = "Jerry",
rankId = "vip",
online = false,
coins = 10,
)
val repository = InMemoryProfileRepository(listOf(initial))
runBlocking {
val firstLoad = checkNotNull(repository.load(initial.uuid))
println("Loaded snapshot: $firstLoad")
val session = PlayerSession.fromDocument(firstLoad)
session.markOnline()
session.rewardCoins(15)
val beforeSave = checkNotNull(repository.load(initial.uuid))
check(beforeSave.coins == 10)
check(!beforeSave.online)
repository.save(session.toDocument())
val afterSave = checkNotNull(repository.load(initial.uuid))
check(afterSave.coins == 25)
check(afterSave.online)
println("BOUNDARY SAFE: stored state changed only after save")
}
}
How does the demo test the boundary?
- The first load becomes a separate PlayerSession.
- The session moves online before receiving 15 coins.
- The second load confirms that storage still holds 10 coins with an offline status.
- The explicit save stores a new document containing 25 coins with an online status.
- Save ProfileRefactorDemo.kt.
Before you run the demo, predict whether changing the session can alter the stored coin balance without a save.
- Exercise the session boundary by running this command:
./gradlew run
What should you see?
The terminal prints a line beginning with Loaded snapshot:. It then prints BOUNDARY SAFE: stored state changed only after save.
The checks pass because the session owns the mutation. The stored profile remains unchanged until repository.save(session.toDocument()) runs.
Does the boundary check stop early?
- Confirm that session.rewardCoins(15) appears before the first storage check.
- Confirm that repository.save(session.toDocument()) appears after the checks for 10 coins with an offline status.
- Check that the existing runBlocking import remains at the top of the file.
Ask for help tracing the failed check:
That is the central separation working. Gameplay can now stay mutable without silently changing the stored profile.
Harden the persistence boundary
The session already prevents direct gameplay mutations from reaching storage. Immutable document properties and defensive copies now make that guarantee part of the persistence implementation.
- In ProfileDocument.kt select the entire temporary mutable record.
- Replace it with this immutable document:
package me.ilovejerry1.skyland.refactor
data class ProfileDocument(
val uuid: String,
val username: String,
val rankId: String = "default",
val online: Boolean = false,
val coins: Int = 0,
)
Why is the document immutable?
Every persisted field now uses val. Code can create a new snapshot with copy() without changing the original object.
A loaded document therefore describes one moment in stored profile state. Session methods own every later gameplay change.
- Save ProfileDocument.kt.
- In InMemoryProfileRepository.kt select the entire temporary leak implementation.
- Replace it with this copy-based repository:
package me.ilovejerry1.skyland.refactor
import java.util.concurrent.ConcurrentHashMap
class InMemoryProfileRepository(
initialDocuments: Iterable<ProfileDocument> = emptyList(),
) : ProfileRepository {
private val documents = ConcurrentHashMap(
initialDocuments.associateBy(ProfileDocument::uuid),
)
override suspend fun load(uuid: String): ProfileDocument? =
documents[uuid]?.copy()
override suspend fun save(document: ProfileDocument) {
documents[document.uuid] = document.copy()
}
override suspend fun delete(uuid: String) {
documents.remove(uuid)
}
}
Why copy on load and save?
- A load returns a fresh document instance for the caller.
- A save stores a fresh document instance inside the repository.
- The repository boundary therefore avoids sharing its internal object references with callers.
- Save InMemoryProfileRepository.kt.
Use the reference below to compare every cumulative project file for this step.
✔️ Awesome, I've got everything!
Your document, session, repository, and demo now form one explicit persistence boundary.
ⓧ I'd like to double check the full code
rootProject.name = "profile-refactor-lab"
This file keeps the isolated lab name unchanged.
plugins {
kotlin("jvm") version "2.4.20"
application
}
repositories {
mavenCentral()
}
dependencies {
implementation("com.hazelcast:hazelcast:5.7.0")
implementation("org.mongodb:mongodb-driver-kotlin-coroutine:5.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
}
kotlin {
jvmToolchain(25)
}
application {
mainClass.set("me.ilovejerry1.skyland.refactor.ProfileRefactorDemoKt")
}
This file preserves the version-pinned build and demo entry point from the lab setup.
package me.ilovejerry1.skyland.refactor
data class ProfileDocument(
val uuid: String,
val username: String,
val rankId: String = "default",
val online: Boolean = false,
val coins: Int = 0,
)
This file defines the immutable profile snapshot.
package me.ilovejerry1.skyland.refactor
class PlayerSession private constructor(
val uuid: String,
username: String,
rankId: String,
online: Boolean,
coins: Int,
) {
var username: String = username
private set
var rankId: String = rankId
private set
var online: Boolean = online
private set
var coins: Int = coins
private set
fun rename(newUsername: String) {
require(newUsername.isNotBlank())
username = newUsername
}
fun changeRank(newRankId: String) {
require(newRankId.isNotBlank())
rankId = newRankId
}
fun markOnline() {
online = true
}
fun markOffline() {
online = false
}
fun rewardCoins(amount: Int) {
require(amount > 0)
coins += amount
}
fun toDocument(): ProfileDocument = ProfileDocument(
uuid = uuid,
username = username,
rankId = rankId,
online = online,
coins = coins,
)
companion object {
fun fromDocument(document: ProfileDocument): PlayerSession = PlayerSession(
uuid = document.uuid,
username = document.username,
rankId = document.rankId,
online = document.online,
coins = document.coins,
)
}
}
This file contains the complete mutable gameplay session and both document mappings.
package me.ilovejerry1.skyland.refactor
interface ProfileRepository {
suspend fun load(uuid: String): ProfileDocument?
suspend fun save(document: ProfileDocument)
suspend fun delete(uuid: String)
}
This file preserves the suspending persistence vocabulary from the earlier repository step.
package me.ilovejerry1.skyland.refactor
import java.util.concurrent.ConcurrentHashMap
class InMemoryProfileRepository(
initialDocuments: Iterable<ProfileDocument> = emptyList(),
) : ProfileRepository {
private val documents = ConcurrentHashMap(
initialDocuments.associateBy(ProfileDocument::uuid),
)
override suspend fun load(uuid: String): ProfileDocument? =
documents[uuid]?.copy()
override suspend fun save(document: ProfileDocument) {
documents[document.uuid] = document.copy()
}
override suspend fun delete(uuid: String) {
documents.remove(uuid)
}
}
This file stores defensive copies behind the repository boundary.
package me.ilovejerry1.skyland.refactor
import kotlinx.coroutines.runBlocking
fun main() {
val initial = ProfileDocument(
uuid = "player-42",
username = "Jerry",
rankId = "vip",
online = false,
coins = 10,
)
val repository = InMemoryProfileRepository(listOf(initial))
runBlocking {
val firstLoad = checkNotNull(repository.load(initial.uuid))
println("Loaded snapshot: $firstLoad")
val session = PlayerSession.fromDocument(firstLoad)
session.markOnline()
session.rewardCoins(15)
val beforeSave = checkNotNull(repository.load(initial.uuid))
check(beforeSave.coins == 10)
check(!beforeSave.online)
repository.save(session.toDocument())
val afterSave = checkNotNull(repository.load(initial.uuid))
check(afterSave.coins == 25)
check(afterSave.online)
println("BOUNDARY SAFE: stored state changed only after save")
}
}
This file exercises the complete document-to-session-to-document flow for this step.
Before the final run, predict which line proves that the stored snapshot remained unchanged until the explicit save.
- Run the completed boundary check with this command:
./gradlew run
What confirms success?
The terminal first prints a line beginning with Loaded snapshot:. The run finishes with BOUNDARY SAFE: stored state changed only after save.
The checks establish two distinct moments in storage. The repository holds 10 coins with an offline status before the save and 25 coins with an online status afterward.
Still seeing the old leak result?
- Confirm that ProfileDocument uses val for all five properties.
- Confirm that load() returns documents[uuid]?.copy().
- Confirm that save() stores document.copy().
Ask for help comparing the final boundary:
You have replaced the shared-reference leak with an explicit snapshot boundary. Next, you will connect the same repository vocabulary to MongoDB and Hazelcast adapters.
Bridge MongoDB and Hazelcast
Your safe repository boundary now keeps stored snapshots stable until an explicit save. The final piece connects that boundary to infrastructure without changing the domain model.
MongoDB repository operations are suspending. Hazelcast MapStore calls are synchronous. This step confines the bridge to infrastructure adapters so profile commands remain storage-agnostic.
In this step, get ready to:
- Compile a typed MongoDB repository behind ProfileRepository.
- Adapt the suspending repository to Hazelcast MapStore.
- Configure write-behind persistence and verify the complete boundary.
Add the MongoDB repository adapter
The domain already speaks through ProfileRepository. A typed MongoDB adapter can now translate that vocabulary into database operations without entering PlayerSession.
- Create MongoProfileRepository.kt inside src/main/kotlin/me/ilovejerry1/skyland/refactor using your code editor's file sidebar.
- Paste the typed repository implementation into MongoProfileRepository.kt using the code below:
package me.ilovejerry1.skyland.refactor
import com.mongodb.client.model.Filters.eq
import com.mongodb.client.model.ReplaceOptions
import com.mongodb.kotlin.client.coroutine.MongoCollection
import kotlinx.coroutines.flow.firstOrNull
class MongoProfileRepository(
private val collection: MongoCollection<ProfileDocument>,
) : ProfileRepository {
override suspend fun load(uuid: String): ProfileDocument? =
collection.find(eq(ProfileDocument::uuid.name, uuid)).firstOrNull()
override suspend fun save(document: ProfileDocument) {
collection.replaceOne(
eq(ProfileDocument::uuid.name, document.uuid),
document,
ReplaceOptions().upsert(true),
)
}
override suspend fun delete(uuid: String) {
collection.deleteOne(eq(ProfileDocument::uuid.name, uuid))
}
}
What Does This Adapter Do?
- The typed MongoCollection<ProfileDocument> keeps persistence operations focused on immutable snapshots.
- firstOrNull() turns the first matching result into the nullable value required by load.
- ReplaceOptions().upsert(true) lets save replace an existing profile or insert a missing one.
- deleteOne removes the snapshot selected by its uuid.
- Save MongoProfileRepository.kt.
- Compile the MongoDB adapter by running this command:
./gradlew build
What Does This Build Prove?
Gradle type-checks the MongoDB imports against the pinned coroutine driver. The adapter compiles without connecting to a MongoDB server.
You should see BUILD SUCCESSFUL near the end of the terminal output.
MongoDB Adapter Not Compiling?
- Check that MongoProfileRepository.kt is inside the same package folder as ProfileRepository.kt.
- Compare the import for MongoCollection with the code above if Gradle cannot resolve the collection type.
- Confirm that build.gradle.kts still contains the MongoDB Kotlin Coroutine Driver dependency from the lab setup.
Help me diagnose my MongoProfileRepository compilation problem.
✔️ Awesome, I've got everything!
Your MongoDB adapter is saved and compiles behind the repository boundary.
ⓧ I'd like to double check the full code
package me.ilovejerry1.skyland.refactor
import com.mongodb.client.model.Filters.eq
import com.mongodb.client.model.ReplaceOptions
import com.mongodb.kotlin.client.coroutine.MongoCollection
import kotlinx.coroutines.flow.firstOrNull
class MongoProfileRepository(
private val collection: MongoCollection<ProfileDocument>,
) : ProfileRepository {
override suspend fun load(uuid: String): ProfileDocument? =
collection.find(eq(ProfileDocument::uuid.name, uuid)).firstOrNull()
override suspend fun save(document: ProfileDocument) {
collection.replaceOne(
eq(ProfileDocument::uuid.name, document.uuid),
document,
ReplaceOptions().upsert(true),
)
}
override suspend fun delete(uuid: String) {
collection.deleteOne(eq(ProfileDocument::uuid.name, uuid))
}
}
Bridge Hazelcast to the repository
Hazelcast expects synchronous MapStore methods. runBlocking contains that mismatch at the adapter edge while ProfileRepository remains suspending.
Why a Custom MapStore Adapter?
A generic persistence path would hide how profile snapshots cross the cache boundary. This adapter makes every load operation explicit through ProfileRepository.
The domain keeps its mutable gameplay API. Infrastructure owns the synchronous-to-suspending conversion.
- Create ProfileMapStore.kt inside src/main/kotlin/me/ilovejerry1/skyland/refactor by pasting this first section into a new file:
package me.ilovejerry1.skyland.refactor
import com.hazelcast.map.MapStore
import kotlinx.coroutines.runBlocking
class ProfileMapStore(
private val repository: ProfileRepository,
) : MapStore<String, ProfileDocument> {
override fun store(key: String, value: ProfileDocument) = runBlocking {
require(key == value.uuid)
repository.save(value)
}
override fun storeAll(map: Map<String, ProfileDocument>) = runBlocking {
map.forEach { (key, value) ->
require(key == value.uuid)
repository.save(value)
}
}
override fun delete(key: String) = runBlocking {
repository.delete(key)
}
override fun deleteAll(keys: Collection<String>) = runBlocking {
keys.forEach { repository.delete(it) }
}
How Do Writes Cross the Boundary?
- store validates that the map key matches the document's uuid before saving.
- storeAll applies the same boundary check to every entry in a batch.
- delete operations delegate directly to the suspending repository inside runBlocking.
- Complete ProfileMapStore.kt by appending this second section directly below the first section:
override fun load(key: String): ProfileDocument? = runBlocking {
repository.load(key)
}
override fun loadAll(keys: Collection<String>): Map<String, ProfileDocument> = runBlocking {
keys.mapNotNull { key ->
repository.load(key)?.let { document -> key to document }
}.toMap()
}
override fun loadAllKeys(): Iterable<String> = emptyList()
}
How Do Reads Cross the Boundary?
- load returns the nullable snapshot supplied by the repository.
- loadAll keeps only keys that resolve to a stored document.
- loadAllKeys returns an empty collection because this lab loads explicit player keys.
- Save ProfileMapStore.kt.
- Compile the complete Hazelcast adapter by running this command:
./gradlew build
What Does This Build Confirm?
The class now satisfies every synchronous load operation required by MapStore. Each operation reaches the same suspending repository contract.
You should see BUILD SUCCESSFUL after Gradle compiles the adapter.
MapStore Methods Not Compiling?
- Confirm that the second code section sits inside the ProfileMapStore class before its final closing brace.
- Check every overridden method against the full-file reference below if Gradle reports an incomplete implementation.
- Verify that runBlocking is imported from the coroutine package used by the project.
Help me fix my ProfileMapStore implementation.
✔️ Awesome, I've got everything!
Your synchronous Hazelcast adapter now delegates through the suspending repository boundary.
ⓧ I'd like to double check the full code
package me.ilovejerry1.skyland.refactor
import com.hazelcast.map.MapStore
import kotlinx.coroutines.runBlocking
class ProfileMapStore(
private val repository: ProfileRepository,
) : MapStore<String, ProfileDocument> {
override fun store(key: String, value: ProfileDocument) = runBlocking {
require(key == value.uuid)
repository.save(value)
}
override fun storeAll(map: Map<String, ProfileDocument>) = runBlocking {
map.forEach { (key, value) ->
require(key == value.uuid)
repository.save(value)
}
}
override fun delete(key: String) = runBlocking {
repository.delete(key)
}
override fun deleteAll(keys: Collection<String>) = runBlocking {
keys.forEach { repository.delete(it) }
}
override fun load(key: String): ProfileDocument? = runBlocking {
repository.load(key)
}
override fun loadAll(keys: Collection<String>): Map<String, ProfileDocument> = runBlocking {
keys.mapNotNull { key ->
repository.load(key)?.let { document -> key to document }
}.toMap()
}
override fun loadAllKeys(): Iterable<String> = emptyList()
}
Configure and exercise write-behind persistence
The adapter now needs map-level persistence settings. A positive write delay selects write-behind behavior so cache updates can be queued before reaching the repository.
- Create ProfileMapConfiguration.kt inside src/main/kotlin/me/ilovejerry1/skyland/refactor using your code editor's file sidebar.
- Paste the map configuration into ProfileMapConfiguration.kt using the code below:
package me.ilovejerry1.skyland.refactor
import com.hazelcast.config.MapConfig
import com.hazelcast.config.MapStoreConfig
object ProfileMapConfiguration {
fun create(store: ProfileMapStore): MapConfig =
MapConfig("profiles").setMapStoreConfig(
MapStoreConfig()
.setImplementation(store)
.setWriteDelaySeconds(30)
.setWriteCoalescing(true)
.setInitialLoadMode(MapStoreConfig.InitialLoadMode.LAZY),
)
}
What Does This Configuration Control?
- MapConfig("profiles") gives the profile map its stable name.
- setWriteDelaySeconds(30) selects write-behind with a 30-second delay.
- setWriteCoalescing(true) allows queued updates for one key to be combined.
- InitialLoadMode.LAZY delays loading until the map requests data.
- Save ProfileMapConfiguration.kt.
- Compile the map configuration by running this command:
./gradlew build
What Does This Build Verify?
The configuration uses the pinned Hazelcast API successfully. Gradle verifies the map name plus each write-behind setting without starting a cluster.
You should see BUILD SUCCESSFUL in the terminal.
Configuration Not Compiling?
- Confirm that both configuration imports begin with the Hazelcast config package shown above.
- Check the closing parentheses around MapStoreConfig if Gradle reports a syntax problem.
- Compare every setter with the full-file reference before changing any values.
Help me fix my ProfileMapConfiguration compilation problem.
✔️ Awesome, I've got everything!
Your profile map now has a compiled write-behind configuration.
ⓧ I'd like to double check the full code
package me.ilovejerry1.skyland.refactor
import com.hazelcast.config.MapConfig
import com.hazelcast.config.MapStoreConfig
object ProfileMapConfiguration {
fun create(store: ProfileMapStore): MapConfig =
MapConfig("profiles").setMapStoreConfig(
MapStoreConfig()
.setImplementation(store)
.setWriteDelaySeconds(30)
.setWriteCoalescing(true)
.setInitialLoadMode(MapStoreConfig.InitialLoadMode.LAZY),
)
}
The in-memory repository can exercise this infrastructure path without MongoDB credentials or a running database. The final demo sends the same immutable snapshot through ProfileMapStore.
- Return to ProfileRefactorDemo.kt in your code editor.
- Find the line that prints BOUNDARY SAFE: stored state changed only after save.
- Move to the closing brace for the runBlocking block directly below that line.
- Insert the following adapter check after that brace and before the final closing brace of main:
val store = ProfileMapStore(repository)
val persisted = checkNotNull(store.load(initial.uuid))
store.store(initial.uuid, persisted.copy(coins = 30))
val throughMapStore = checkNotNull(store.load(initial.uuid))
check(throughMapStore.coins == 30)
val mapConfig = ProfileMapConfiguration.create(store)
check(mapConfig.name == "profiles")
check(mapConfig.mapStoreConfig.writeDelaySeconds == 30)
println("MAPSTORE READY: profiles uses write-behind")
What Does the Final Check Add?
- ProfileMapStore loads the saved player snapshot through the repository.
- store writes a copied snapshot containing 30 coins.
- ProfileMapConfiguration.create produces the profiles map with its 30-second delay.
- The final print confirms that the synchronous adapter and write-behind configuration passed their checks.
- Save ProfileRefactorDemo.kt.
✔️ Awesome, I've got everything!
Your demo now tests the session boundary plus the Hazelcast adapter path.
ⓧ I'd like to double check the full code
package me.ilovejerry1.skyland.refactor
import kotlinx.coroutines.runBlocking
fun main() {
val initial = ProfileDocument(
uuid = "player-42",
username = "Jerry",
rankId = "vip",
online = false,
coins = 10,
)
val repository = InMemoryProfileRepository(listOf(initial))
runBlocking {
val firstLoad = checkNotNull(repository.load(initial.uuid))
println("Loaded snapshot: $firstLoad")
val session = PlayerSession.fromDocument(firstLoad)
session.markOnline()
session.rewardCoins(15)
val beforeSave = checkNotNull(repository.load(initial.uuid))
check(beforeSave.coins == 10)
check(!beforeSave.online)
repository.save(session.toDocument())
val afterSave = checkNotNull(repository.load(initial.uuid))
check(afterSave.coins == 25)
check(afterSave.online)
println("BOUNDARY SAFE: stored state changed only after save")
}
val store = ProfileMapStore(repository)
val persisted = checkNotNull(store.load(initial.uuid))
store.store(initial.uuid, persisted.copy(coins = 30))
val throughMapStore = checkNotNull(store.load(initial.uuid))
check(throughMapStore.coins == 30)
val mapConfig = ProfileMapConfiguration.create(store)
check(mapConfig.name == "profiles")
check(mapConfig.mapStoreConfig.writeDelaySeconds == 30)
println("MAPSTORE READY: profiles uses write-behind")
}
Before you run the demo, predict whether the MapStore check reaches the same repository state as a direct repository call.
- Run the complete persistence demonstration with this command:
./gradlew run
What Does the Run Prove?
You should see BOUNDARY SAFE: stored state changed only after save before the adapter result. This confirms the immutable snapshot boundary still holds.
You should then see MAPSTORE READY: profiles uses write-behind. This proves the same repository works through the synchronous Hazelcast edge without a MongoDB server.
Final Demo Not Reaching Both Checks?
- Confirm that the adapter check sits after the runBlocking block while remaining inside main.
- Check that store.store uses initial.uuid as its key.
- Verify that the copied snapshot sets coins = 30 before the second load.
Help me diagnose why my final profile refactor demo does not print both boundary checks.
That completes the infrastructure edge. MongoDB and Hazelcast now depend on one repository contract while gameplay state stays separate from storage APIs.
Secret mission
Skip Clean Session Writes
A safe boundary can still waste writes when nothing changed. Add dirty tracking to PlayerSession so ProfileService persists only changed sessions. Prove both paths through the demo output.
Clean Up Your Resources
Clean Up Your Resources
Your refactor lab runs entirely on your Linux machine. It carries no ongoing financial cost.
No MongoDB server or Hazelcast cluster is running from this project. Decide whether to keep the lab, leave it ready for later, or delete it entirely.
Resources you used:
- The local profile-refactor-lab folder containing the complete refactor lab.
Keep everything running
No action is needed. Choose this option if you want to keep testing the persistence boundary.
- Keep the profile-refactor-lab folder in its current location.
- Retain the verified source files for comparison with your existing Skyland codebase.
Pause - I'll come back to this later
The demo exits after its checks finish. No background process remains to stop.
- Close your terminal session when you finish.
- Leave the profile-refactor-lab folder in place for your next session.
Delete - I don't want to use this again
Remove the isolated lab if you no longer need its source files or build output.
Deleting the lab is permanent. Your existing Skyland codebase stays untouched because this command targets only profile-refactor-lab.
- Delete the isolated lab by running these commands from your current profile-refactor-lab terminal:
cd ..
rm -rf profile-refactor-lab
The first line moves your terminal outside the lab folder. The second line removes the folder from your machine.
Everything inside that isolated folder is permanently deleted. Your original Skyland codebase remains available.
Nice Work!
Nice Work!
You made it! Your Skyland refactor lab now protects stored profile snapshots while gameplay sessions stay easy to mutate. Every storage interaction crosses an explicit persistence boundary.
You've learned how to:
- Reproduce a shared-reference mutation bug. Fix it with an immutable ProfileDocument snapshot plus a mutable PlayerSession.
- Put MongoDB CRUD behind the suspending ProfileRepository boundary. Adapt the same boundary to Hazelcast's synchronous MapStore interface.
- Configure write-behind persistence for profiles. Keep runBlocking confined to the infrastructure adapter.
- Secret Mission: Add dirty tracking through ProfileService.saveIfDirty() so clean sessions skip repository writes. Reset the dirty flag only after a successful save.
Ready to quiz yourself?