KMP Data Layer Architecture
Created: 2025-10-21 | Agent: Agent 2 (AAM) Tags: architecture kmp data-layer kotlin-multiplatform
Overview
The KMP (Kotlin Multiplatform) Data Layer Architecture for Archery Apprentice defines how data flows from the UI through repositories, data sources, and database layers in a cross-platform manner. This architecture enables sharing business logic and data access code across Android, iOS, and future platforms while maintaining platform-specific implementations where necessary.
Core Principles
1. Offline-First Architecture
- Primary Goal: App works fully offline with local Room database
- Sync Strategy: Background sync to Firebase when online
- Conflict Resolution: Smart merging with local-first precedence
- User Experience: No loading states, instant data access
2. Repository Pattern
- Single Source of Truth: Repositories coordinate local + remote data
- Abstraction Layer: ViewModels/Presenters never touch DAOs directly
- Testability: Repositories are interface-based for easy mocking
- Hybrid Strategy: Combines offline (Room) + online (Firebase) seamlessly
3. Platform Abstraction
- expect/actual Pattern: Platform-specific implementations (Context, file paths, timestamps)
- Shared Logic: Business rules, validation, data transformations in
commonMain - Platform Optimizations: Native implementations where performance matters
Module Structure
shared/
├── common/ # Foundation utilities
│ ├── expect/actual declarations
│ ├── Platform abstractions
│ └── Common extensions
│
├── domain/ # Business logic & interfaces
│ ├── models/ # Data classes (Round, Tournament, Equipment)
│ ├── repository/ # Repository interfaces
│ └── usecases/ # Business use cases (optional)
│
├── data/ # Repository implementations
│ ├── repository/ # Repository impls (RoundRepositoryImpl, etc.)
│ ├── datasources/ # Data source abstractions
│ │ ├── local/ # Local data source interfaces
│ │ └── remote/ # Remote data source interfaces
│ └── cache/ # Caching strategies (LRU, etc.)
│
├── database/ # Room KMP database
│ ├── entities/ # Database entities (Room annotations)
│ ├── dao/ # Data Access Objects
│ ├── converters/ # Type converters
│ └── migrations/ # Database migrations
│
├── presentation/ # Presentation logic
│ ├── Presenters (shared ViewModels)
│ ├── Screen state classes
│ └── UI events
│
└── di/ # Dependency injection
├── SharedModuleFactory
├── AndroidSharedModuleFactory
└── iOSSharedModuleFactory (future)
Data Flow: UI → Repository → DAO → Database
Layer 1: Presentation (UI)
Platform-Specific:
- Android: Compose UI + AndroidViewModel wrappers
- iOS: SwiftUI + ObservableObject wrappers
Shared:
- Presenters (business logic for UI)
- State classes (UI state representations)
- UI events (user interactions)
Example Flow:
// Android Compose UI
@Composable
fun RoundDetailsScreen(viewModel: RoundViewModel) {
val round by viewModel.currentRound.collectAsState()
// UI renders round data
}
// AndroidViewModel wrapper (thin layer)
class RoundViewModel(private val presenter: RoundPresenter) : ViewModel() {
val currentRound: StateFlow<Round?> = presenter.currentRound
fun loadRound(id: Int) = presenter.loadRound(id)
}
// Shared Presenter (business logic)
class RoundPresenter(private val repository: RoundRepository) {
private val _currentRound = MutableStateFlow<Round?>(null)
val currentRound: StateFlow<Round?> = _currentRound.asStateFlow()
suspend fun loadRound(id: Int) {
_currentRound.value = repository.getRoundById(id)
}
}Layer 2: Repository (Business Logic Coordination)
Purpose: Coordinate data access from multiple sources
Pattern: Interface-based with hybrid implementations
Example:
// Interface (shared/domain)
interface RoundRepository {
suspend fun getRoundById(id: Int): Round?
fun getAllRounds(): Flow<List<Round>>
suspend fun insertRound(round: Round): Long
suspend fun updateRound(round: Round)
suspend fun deleteRound(round: Round)
}
// Implementation (shared/data)
class RoundRepositoryImpl(
private val roundDao: RoundDao,
private val bowSetupRepository: BowSetupRepository
) : RoundRepository {
override suspend fun getRoundById(id: Int): Round? {
return roundDao.getRoundById(id)
}
override fun getAllRounds(): Flow<List<Round>> {
return roundDao.getAllRounds()
}
override suspend fun insertRound(round: Round): Long {
return roundDao.insertRound(round)
}
override suspend fun updateRound(round: Round) {
roundDao.updateRound(round)
}
override suspend fun deleteRound(round: Round) {
// Business logic: cleanup related data
bowSetupRepository.cleanupGuestSetupsForRound(round.id)
roundDao.deleteRound(round)
}
}Hybrid Repository Pattern:
// Tournament repositories combine local + remote
class TournamentCrudRepositoryImpl(
private val localDao: TournamentDao,
private val remoteDataSource: RemoteTournamentDataSource,
private val networkMonitor: NetworkMonitor
) : TournamentCrudRepository {
override suspend fun createTournament(tournament: Tournament): Result<TournamentId> {
// 1. Save locally first (offline-first)
val localId = localDao.insertTournament(tournament.toEntity())
// 2. Sync to Firebase if online
if (networkMonitor.isOnline()) {
remoteDataSource.createTournament(tournament)
.onSuccess { firebaseId ->
// Map local ID → Firebase ID
idMappingDao.insert(localId, firebaseId)
}
.onFailure { error ->
// Mark for retry sync
syncQueueDao.enqueue(localId, SyncAction.CREATE)
}
}
return Result.success(localId)
}
}Layer 3: Data Sources (Platform Abstractions)
Purpose: Abstract local vs remote data access
Local Data Sources:
// Interface (shared/data)
interface LocalRoundDataSource {
suspend fun getRound(id: Int): Round?
fun observeRounds(): Flow<List<Round>>
suspend fun saveRound(round: Round): Long
}
// Implementation uses DAO (shared/data)
class LocalRoundDataSourceImpl(
private val roundDao: RoundDao
) : LocalRoundDataSource {
override suspend fun getRound(id: Int): Round? = roundDao.getRoundById(id)
override fun observeRounds(): Flow<List<Round>> = roundDao.getAllRounds()
override suspend fun saveRound(round: Round): Long = roundDao.insertRound(round)
}Remote Data Sources:
// Interface (shared/data)
interface RemoteTournamentDataSource {
suspend fun getTournament(id: TournamentId): Result<Tournament?>
fun observeTournament(id: TournamentId): Flow<Tournament?>
suspend fun createTournament(tournament: Tournament): Result<TournamentId>
}
// Android implementation (Firebase)
class FirebaseTournamentDataSource : RemoteTournamentDataSource {
private val firestore = Firebase.firestore
override suspend fun getTournament(id: TournamentId): Result<Tournament?> {
return try {
val doc = firestore.collection("tournaments").document(id).get().await()
Result.success(doc.toTournament())
} catch (e: Exception) {
Result.failure(e)
}
}
}
// iOS implementation (future - same interface, different platform SDK)
class IOSFirebaseTournamentDataSource : RemoteTournamentDataSource {
// Uses iOS Firebase SDK
}Layer 4: DAO (Data Access Objects)
Purpose: Room database queries (SQL abstraction)
Platform: Shared (commonMain) - Room KMP supports DAOs in shared code
Example:
// shared/database/src/commonMain/kotlin/dao/RoundDao.kt
@Dao
interface RoundDao {
@Insert
suspend fun insertRound(round: Round): Long
@Query("SELECT * FROM rounds WHERE id = :roundId")
suspend fun getRoundById(roundId: Int): Round?
@Query("SELECT * FROM rounds ORDER BY createdAt DESC")
fun getAllRounds(): Flow<List<Round>>
@Update
suspend fun updateRound(round: Round)
@Delete
suspend fun deleteRound(round: Round)
@Query("DELETE FROM rounds WHERE id = :roundId")
suspend fun deleteRoundById(roundId: Int)
// Complex query with joins
@Transaction
@Query("""
SELECT rounds.*,
COUNT(end_scores.id) as completedEnds
FROM rounds
LEFT JOIN end_scores ON rounds.id = end_scores.roundId
WHERE rounds.id = :roundId
GROUP BY rounds.id
""")
suspend fun getRoundWithStats(roundId: Int): RoundWithDetails?
}Layer 5: Database (Room KMP)
Purpose: SQLite database abstraction with Room annotations
Platform Abstraction: expect/actual for database instantiation
Common Database Definition:
// shared/database/src/commonMain/kotlin/database/ArcheryDatabase.kt
@Database(
entities = [
Round::class,
EndScore::class,
ArrowScore::class,
BowSetup::class,
Tournament::class,
// ... 18 more entities
],
version = 35,
exportSchema = true
)
@ConstructedBy(ArcheryDatabaseConstructor::class)
abstract class ArcheryDatabase : RoomDatabase() {
abstract fun roundDao(): RoundDao
abstract fun tournamentDao(): TournamentDao
abstract fun bowSetupDao(): BowSetupDao
// ... 11 more DAOs
}Platform-Specific Initialization:
Android:
// shared/database/src/androidMain/kotlin/database/DatabaseConstructor.kt
actual object ArcheryDatabaseConstructor : RoomDatabaseConstructor<ArcheryDatabase> {
private lateinit var applicationContext: Context
fun initialize(context: Context) {
applicationContext = context.applicationContext
}
override fun initialize(): ArcheryDatabase {
val dbFile = applicationContext.getDatabasePath("archery.db")
return Room.databaseBuilder<ArcheryDatabase>(
context = applicationContext,
name = dbFile.absolutePath
)
.addMigrations(*getAllMigrations())
.build()
}
}iOS (Future):
// shared/database/src/iosMain/kotlin/database/DatabaseConstructor.kt
actual object ArcheryDatabaseConstructor : RoomDatabaseConstructor<ArcheryDatabase> {
override fun initialize(): ArcheryDatabase {
val dbFile = NSHomeDirectory() + "/archery.db"
return Room.databaseBuilder<ArcheryDatabase>(
name = dbFile,
factory = { ArcheryDatabase::class.instantiateImpl() }
)
.setDriver(BundledSQLiteDriver())
.addMigrations(*getAllMigrations())
.build()
}
}expect/actual Patterns for Platform-Specific Code
When to Use expect/actual
Use for:
- Database instantiation (Context on Android, file path on iOS)
- SQLite driver initialization
- Timestamp functions (
System.currentTimeMillisvsNSDate) - File I/O paths
- Network connectivity checks
- Logging implementations
Don’t use for:
- Entities (pure Kotlin data classes)
- DAOs (pure interfaces with SQL queries)
- TypeConverters (can often stay in commonMain)
- Business logic (should be platform-agnostic)
Example: Timestamp Function
Common Declaration:
// shared/common/src/commonMain/kotlin/platform/Time.kt
expect fun getCurrentTimeMillis(): LongAndroid Implementation:
// shared/common/src/androidMain/kotlin/platform/Time.kt
actual fun getCurrentTimeMillis(): Long = System.currentTimeMillis()iOS Implementation:
// shared/common/src/iosMain/kotlin/platform/Time.kt
import platform.Foundation.NSDate
actual fun getCurrentTimeMillis(): Long =
(NSDate().timeIntervalSince1970 * 1000).toLong()Usage in Entities:
// shared/domain/src/commonMain/kotlin/models/Round.kt
data class Round(
val id: Int = 0,
val roundName: String,
val createdAt: Long = getCurrentTimeMillis(), // ✅ Platform-agnostic
// ... other fields
)Dependency Injection Strategy
Manual DI Pattern (SharedModuleFactory)
Why Manual DI?
- Consistent with existing Android app architecture (RepositoryFactory)
- No third-party framework learning curve
- Simple, explicit, debuggable
- Can add Koin later if needed
Pattern:
// shared/di/src/commonMain/kotlin/SharedModuleFactory.kt
abstract class SharedModuleFactory {
// Database
abstract fun provideArcheryDatabase(): ArcheryDatabase
// Repositories
abstract fun provideRoundRepository(): RoundRepository
abstract fun provideTournamentRepository(): TournamentRepository
abstract fun provideBowSetupRepository(): BowSetupRepository
// ... other repositories
}Android Implementation:
// shared/di/src/androidMain/kotlin/AndroidSharedModuleFactory.kt
class AndroidSharedModuleFactory : SharedModuleFactory() {
private val database: ArcheryDatabase by lazy {
ArcheryDatabaseConstructor.initialize()
}
override fun provideArcheryDatabase(): ArcheryDatabase = database
override fun provideRoundRepository(): RoundRepository {
return RoundRepositoryImpl(
roundDao = database.roundDao(),
bowSetupRepository = provideBowSetupRepository()
)
}
override fun provideTournamentRepository(): TournamentRepository {
return HybridTournamentRepository(
localDao = database.tournamentDao(),
remoteDataSource = FirebaseTournamentDataSource(),
networkMonitor = PlatformProvider.networkMonitor
)
}
// ... other repositories
}Integration with App:
// app/src/main/java/RepositoryFactory.kt
class RepositoryFactory(
private val database: ArcheryDatabase,
private val platformProvider: PlatformProvider
) {
private val sharedFactory = AndroidSharedModuleFactory()
fun createRoundRepository(): RoundRepository {
return sharedFactory.provideRoundRepository()
}
fun createTournamentRepository(): TournamentRepository {
return sharedFactory.provideTournamentRepository()
}
}Data Flow Example: Loading a Round
Complete Flow from UI to Database and Back
1. UI Request (Compose):
// Android Compose UI
@Composable
fun RoundDetailsScreen(roundId: Int, viewModel: RoundViewModel) {
val round by viewModel.currentRound.collectAsState()
LaunchedEffect(roundId) {
viewModel.loadRound(roundId) // Trigger data load
}
round?.let { roundData ->
Text("Round: ${roundData.roundName}")
Text("Score: ${roundData.totalScore} / ${roundData.maxPossibleScore}")
}
}2. ViewModel Wrapper (Android):
// app/src/main/java/viewmodels/RoundViewModel.kt
class RoundViewModel(
private val presenter: RoundPresenter
) : AndroidViewModel() {
val currentRound: StateFlow<Round?> = presenter.currentRound
fun loadRound(id: Int) {
viewModelScope.launch {
presenter.loadRound(id)
}
}
}3. Presenter (Shared Business Logic):
// shared/presentation/src/commonMain/kotlin/presenters/RoundPresenter.kt
class RoundPresenter(
private val roundRepository: RoundRepository
) {
private val _currentRound = MutableStateFlow<Round?>(null)
val currentRound: StateFlow<Round?> = _currentRound.asStateFlow()
suspend fun loadRound(id: Int) {
_currentRound.value = roundRepository.getRoundById(id)
}
}4. Repository (Coordination):
// shared/data/src/commonMain/kotlin/repository/RoundRepositoryImpl.kt
class RoundRepositoryImpl(
private val roundDao: RoundDao,
private val bowSetupRepository: BowSetupRepository
) : RoundRepository {
override suspend fun getRoundById(id: Int): Round? {
return roundDao.getRoundById(id)
}
}5. DAO (SQL Query):
// shared/database/src/commonMain/kotlin/dao/RoundDao.kt
@Dao
interface RoundDao {
@Query("SELECT * FROM rounds WHERE id = :roundId")
suspend fun getRoundById(roundId: Int): Round?
}6. Database (Room):
- Room KMP generates SQL query implementation
- SQLite executes query
- Room maps result to
Rounddata class
7. Data Flows Back:
- DAO returns
Round?to Repository - Repository returns
Round?to Presenter - Presenter updates
StateFlow<Round?> - ViewModel exposes
StateFlowto Compose UI - Compose UI recomposes with new data
Total Layers: 6 (UI → ViewModel → Presenter → Repository → DAO → Database)
Hybrid Online/Offline Pattern
Tournament Repository Example
Scenario: User creates tournament while offline, syncs when online
Flow:
class TournamentCrudRepositoryImpl(
private val localDao: TournamentDao,
private val remoteDataSource: RemoteTournamentDataSource,
private val networkMonitor: NetworkMonitor,
private val syncQueue: SyncQueueDao
) : TournamentCrudRepository {
override suspend fun createTournament(tournament: Tournament): Result<TournamentId> {
// STEP 1: Save to local database immediately (offline-first)
val localId = localDao.insertTournament(tournament.toEntity())
// STEP 2: Attempt Firebase sync if online
if (networkMonitor.isOnline()) {
remoteDataSource.createTournament(tournament)
.onSuccess { firebaseId ->
// Success: Map local ID to Firebase ID
idMappingDao.insert(localId, firebaseId)
localDao.updateSyncStatus(localId, SyncStatus.SYNCED)
}
.onFailure { error ->
// Failure: Queue for retry
syncQueue.enqueue(localId, SyncAction.CREATE)
localDao.updateSyncStatus(localId, SyncStatus.FAILED)
}
} else {
// Offline: Queue for later sync
syncQueue.enqueue(localId, SyncAction.CREATE)
localDao.updateSyncStatus(localId, SyncStatus.PENDING)
}
// STEP 3: Return local ID immediately (user sees instant success)
return Result.success(localId)
}
override fun observeTournament(id: TournamentId): Flow<Tournament?> {
return combine(
localDao.observeTournament(id), // Local database
remoteDataSource.observeTournament(id) // Firebase real-time
) { local, remote ->
// Merge strategy: Prefer local if modified, otherwise use remote
when {
local == null -> remote
remote == null -> local
local.updatedAt > remote.updatedAt -> local
else -> remote
}
}
}
}Key Benefits:
- User never sees loading states (instant local response)
- Data syncs transparently in background
- Conflicts resolved with smart merging
- Works fully offline
Migration Strategy
Phase 1: Domain Models (Agent 1) ✅
- Move entities to
shared/domain - Remove Android-specific code (java.util.Date, System.currentTimeMillis)
- Keep Room annotations
Phase 2: Repository Interfaces (Agent 2 - Week 2)
- Move repository interfaces to
shared/domain - Extract interfaces from class-based repositories
Phase 3: Repository Implementations (Agent 2 - Week 2)
- Move implementations to
shared/data - Update imports to use shared/domain models
Phase 4: Room Database (Agent 2 - Week 2)
- Move DAOs to
shared/database - Configure Room KMP with expect/actual
- Test migrations on Android
Phase 5: Presentation Layer (Week 3-4)
- Extract Presenters from ViewModels
- Move business logic to
shared/presentation - ViewModels become thin wrappers
Phase 6: iOS Integration (Week 5+)
- Implement iOS data sources (Firebase iOS SDK)
- Create iOS database initialization
- Wire up shared modules to SwiftUI
Testing Strategy
Unit Tests (Common Code)
// shared/data/src/commonTest/kotlin/repository/RoundRepositoryTest.kt
class RoundRepositoryTest {
private lateinit var mockDao: RoundDao
private lateinit var repository: RoundRepositoryImpl
@BeforeTest
fun setup() {
mockDao = mockk()
repository = RoundRepositoryImpl(mockDao, mockk())
}
@Test
fun getRoundById_returnsRound() = runTest {
val expected = Round(id = 1, roundName = "Test")
coEvery { mockDao.getRoundById(1) } returns expected
val result = repository.getRoundById(1)
assertEquals(expected, result)
}
}Integration Tests (Android)
// app/src/androidTest/kotlin/data/RoundRepositoryIntegrationTest.kt
@RunWith(AndroidJUnit4::class)
class RoundRepositoryIntegrationTest {
private lateinit var database: ArcheryDatabase
private lateinit var repository: RoundRepository
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(context, ArcheryDatabase::class.java)
.build()
repository = RoundRepositoryImpl(database.roundDao(), mockk())
}
@Test
fun insertAndRetrieveRound() = runTest {
val round = Round(roundName = "Test Round", numEnds = 6, numArrows = 6)
val id = repository.insertRound(round)
val retrieved = repository.getRoundById(id.toInt())
assertNotNull(retrieved)
assertEquals("Test Round", retrieved?.roundName)
}
}Key Architectural Decisions
1. Offline-First
Decision: Local database is source of truth, Firebase syncs in background Rationale: Users score rounds offline (field, competition venues with poor connectivity)
2. Repository Pattern
Decision: All data access goes through repositories Rationale: Abstracts data sources, testable, swappable implementations
3. Manual DI
Decision: SharedModuleFactory pattern, no Hilt/Koin Rationale: Matches existing app architecture, simple, explicit
4. Room KMP
Decision: Use Room 2.8.1 for cross-platform database Rationale: Keep existing Room code, official Google/JetBrains support
5. Hybrid Repositories
Decision: Combine local + remote in single repository Rationale: Simplifies ViewModels, encapsulates sync complexity
Performance Considerations
Database Indexes
- All foreign keys indexed (prevents full table scans)
- Composite indices for common query patterns
- Query optimization: Use
@Transactionfor complex queries
Caching Strategy
- LRU Cache for tournament scores (reduces Firebase reads)
- In-memory cache for frequently accessed data
- Cache invalidation on data updates
N+1 Query Prevention
- Use
@Transactionqueries to fetch related data in one query - Flow.combine() to merge multiple data sources efficiently
Related Documentation
Code Repository:
Obsidian Vault:
Last Updated: 2025-10-21 Status: Conceptual overview complete, implementation in progress (Week 2) Next Steps: Repository interface migration after Agent 1 completes domain models