Home > Technical Reference > Flows > System Flows > ---
Service Architecture Overview
This document details the service extraction architecture used to refactor god-class ViewModels, showing the before/after comparisons, dependency injection patterns, service responsibilities, and inter-service communication strategies.
Table of Contents
- Architecture Philosophy
- Extracted Services Overview
- Before/After Comparisons
- Service Responsibilities
- Dependency Injection Patterns
- Service Lifecycle Management
- Inter-Service Communication
- Testing Strategies
Architecture Philosophy
Problem: God Class Anti-Pattern
Original State:
LiveScoringViewModel.kt: ~3,279 lines before extractionRoundViewModel.kt: 2,177 lines (extraction in progress)- Monolithic ViewModels handling:
- Business logic
- Database operations
- Firebase synchronization
- Conflict resolution
- Statistics calculation
- Permission checking
Issues:
- Difficult to test individual components
- High coupling between unrelated concerns
- Poor code reusability across ViewModels
- Memory-heavy ViewModel instances
- Slow compilation times
- Hard to reason about data flow
Solution: Service Extraction Pattern
Extraction Strategy:
- Identify cohesive responsibilities (sync, validation, calculation)
- Extract to @Singleton services with clear boundaries
- Use dependency injection for testability
- Expose StateFlows for reactive state management
- Maintain service-level coroutine scopes for async operations
Benefits:
- ✅ ViewModels focus on UI state orchestration
- ✅ Services are unit-testable in isolation
- ✅ Code reuse across multiple ViewModels
- ✅ Clear separation of concerns
- ✅ Reduced ViewModel memory footprint
- ✅ Faster incremental compilation
Extracted Services Overview
1. EndCompletionService
Purpose: End completion logic with validation and database/Firebase submission
Extraction Metrics:
- Lines Extracted: ~400 lines from LiveScoringViewModel
- Reduction: 15% LOC reduction
- Priority: 🚨 HIGH - Critical scoring path
File: EndCompletionService.kt:38
Core Responsibilities:
- End finalization validation
- Multi-participant end completion
- Score recording with repository
- Tournament score submission with retry
- End completion event emission
Code Reference:
// EndCompletionService.kt:38
@Singleton
class EndCompletionService @Inject constructor(
private val application: Application,
private val roundRepository: RoundRepository,
private val repositoryFactory: RepositoryFactory?,
private val tournamentSyncService: TournamentSyncService,
private val serviceScope: CoroutineScope? = null
) {
private val scope = serviceScope ?: CoroutineScope(SupervisorJob())
// Event channels for UI coordination
private val _endCompleteEvent = Channel<EndCompletionResult>(Channel.BUFFERED)
val endCompleteEvent = _endCompleteEvent.receiveAsFlow()
// Public API: EndCompletionService.kt:90
fun finalizeEnd(
participantId: String,
endNumber: Int,
session: ScoringSessionState,
round: Round
): Boolean { /* ... */ }
// EndCompletionService.kt:173
suspend fun completeCurrentEnd(
session: ScoringSessionState,
round: Round,
onStatisticsCacheClear: () -> Unit
): Pair<Boolean, Int?>? { /* ... */ }
}Key Methods:
finalizeEnd()- EndCompletionService.kt:90 - Validates end can be finalizedcompleteCurrentEnd()- EndCompletionService.kt:173 - Main end completion flowrecordEndCompletion()- EndCompletionService.kt:226 - Repository submission with validationcheckAllParticipantsComplete()- EndCompletionService.kt:355 - Round completion check
2. TournamentSyncService
Purpose: Real-time Firebase synchronization with leaderboard, participants, and scores
Extraction Metrics:
- Lines Extracted: ~600 lines from LiveScoringViewModel
- Reduction: 21% LOC reduction
- Priority: 🚨 CRITICAL - Proof-of-concept for ViewModel refactoring
File: TournamentSyncService.kt:74
Core Responsibilities:
- Real-time Firebase listener setup (leaderboard, participants, scores)
- Tournament score submission with exponential backoff retry
- Sync status management (Idle, Syncing, Success, Error)
- Manual sync trigger for pull-to-refresh
- Tournament round state observation
- Listener lifecycle cleanup
Code Reference:
// TournamentSyncService.kt:74
@Singleton
class TournamentSyncService @Inject constructor(
private val context: Context,
private val repositoryFactory: RepositoryFactory,
private val serviceScope: CoroutineScope? = null
) {
private val scope = serviceScope ?: CoroutineScope(SupervisorJob())
// StateFlows for reactive UI updates
private val _syncStatus = MutableStateFlow<TournamentSyncStatus>(TournamentSyncStatus.Idle)
val syncStatus: StateFlow<TournamentSyncStatus> = _syncStatus.asStateFlow()
private val _liveLeaderboard = MutableStateFlow<List<LeaderboardEntry>>(emptyList())
val liveLeaderboard: StateFlow<List<LeaderboardEntry>> = _liveLeaderboard.asStateFlow()
// TournamentSyncService.kt:142 - Setup real-time listeners
fun setupTournamentSync(tournamentId: String, currentRoundNumber: Int = 1) {
scope.launch {
val tournamentRepository = getTournamentRepository()
// Concurrent listener setup
leaderboardListenerJob = launch {
tournamentRepository.getTournamentLeaderboard(tournamentId).collect { entries ->
_liveLeaderboard.value = entries
syncTournamentScores(entries)
}
}
participantsListenerJob = launch {
tournamentRepository.getTournamentParticipants(tournamentId).collect { participants ->
_tournamentParticipants.value = participants
}
}
}
}
// TournamentSyncService.kt:424 - Cleanup all listeners
fun cleanup() {
leaderboardListenerJob?.cancel()
participantsListenerJob?.cancel()
roundStateListenerJob?.cancel()
}
}Key Methods:
setupTournamentSync()- TournamentSyncService.kt:142 - Establishes 3 concurrent Firebase listenerssyncTournamentScoresManually()- TournamentSyncService.kt:188 - Manual sync triggersubmitTournamentScoreWithRetry()- TournamentSyncService.kt:240 - Exponential backoff retryobserveTournamentRoundState()- TournamentSyncService.kt:379 - Round state observationcleanup()- TournamentSyncService.kt:424 - Lifecycle cleanup
Retry Logic:
// TournamentSyncService.kt:240
suspend fun submitTournamentScoreWithRetry(
tournamentId: String,
participantId: String,
roundNumber: Int,
endNumber: Int,
arrowScores: List<Int>,
isXFlags: List<Boolean> = emptyList(),
deviceId: String,
maxRetries: Int = 3
): Boolean {
var attempt = 0
while (attempt < maxRetries) {
try {
val result = tournamentRepository.submitScore(/* ... */)
if (result.isSuccess) return true
} catch (e: Exception) {
// Exponential backoff: 100ms, 200ms, 400ms...
val delayMs = 100L * (1 shl (attempt - 1))
delay(delayMs)
}
attempt++
}
// Error classification
val errorType = classifyError(lastException)
_syncStatus.value = TournamentSyncStatus.Error(
message = getErrorMessage(errorType),
errorType = errorType,
retryable = errorType != SyncErrorType.PERMISSION_DENIED
)
return false // Score saved locally
}3. ScoreConflictResolutionService
Purpose: Detect and resolve conflicts between local and remote tournament scores
Extraction Metrics:
- Lines Extracted: From LiveScoringViewModel
- File:
ScoreConflictResolutionService.kt:54
Core Responsibilities:
- Conflict detection (compare local vs remote scores)
- Version-based and timestamp-based resolution strategies
- Manual conflict resolution
- Conflict state management
- Conflict dismissal
Code Reference:
// ScoreConflictResolutionService.kt:54
@Singleton
class ScoreConflictResolutionService @Inject constructor(
private val serviceScope: CoroutineScope? = null
) {
// StateFlow for tracking active conflicts
private val _scoreConflicts = MutableStateFlow<Map<String, ScoreConflict>>(emptyMap())
val scoreConflicts: StateFlow<Map<String, ScoreConflict>> = _scoreConflicts.asStateFlow()
// ScoreConflictResolutionService.kt:69
fun detectConflicts(
tournamentScores: List<TournamentScore>,
localEditableIds: Set<String>,
localEndsMap: Map<String, List<EndScoreWithArrows>>
): Map<String, ScoreConflict> {
val conflicts = mutableMapOf<String, ScoreConflict>()
tournamentScores.forEach { remoteScore ->
val localEnd = localEndsMap[remoteScore.participantId]
?.find { it.endScore.endNumber == remoteScore.endNumber }
if (localEnd != null && localEnd.endScore.totalScore != remoteScore.endTotal) {
val strategy = determineConflictResolution(
localScore = localEnd.endScore.totalScore,
remoteScore = remoteScore.endTotal,
localVersion = 1,
remoteVersion = remoteScore.version,
remoteTimestamp = remoteScore.timestamp
)
conflicts["${remoteScore.participantId}_${remoteScore.endNumber}"] =
ScoreConflict(/* ... */, resolutionStrategy = strategy)
}
}
_scoreConflicts.value = conflicts
return conflicts
}
}Conflict Resolution Strategies:
LOCAL_WINS- Keep local score (default - local authority)REMOTE_WINS- Accept remote score (newer version)HIGHEST_WINS- Use highest scoreLATEST_WINS- Use most recently updated scoreMANUAL- Requires user intervention
Key Methods:
detectConflicts()- ScoreConflictResolutionService.kt:69determineConflictResolution()- ScoreConflictResolutionService.kt:138resolveConflict()- ScoreConflictResolutionService.kt:178dismissConflict()- ScoreConflictResolutionService.kt:240
4. ScoringPermissionService
Purpose: Manage scoring permissions based on participant types
File: ScoringPermissionService.kt:18
Core Responsibilities:
- Participant type permission checking
- Scoreable participant filtering
- Read-only state determination
- Tab visibility logic
Code Reference:
// ScoringPermissionService.kt:18
@Singleton
class ScoringPermissionService @Inject constructor() {
fun canScoreFor(
currentParticipant: SessionParticipant,
targetParticipant: SessionParticipant
): Boolean {
// Ghost participants are never accessible
if (targetParticipant.participantType == ParticipantType.GHOST) return false
// Only LOCAL_MAIN can score for others
if (currentParticipant.participantType != ParticipantType.LOCAL_MAIN) return false
return when (targetParticipant.participantType) {
ParticipantType.LOCAL_MAIN -> currentParticipant.id == targetParticipant.id
ParticipantType.LOCAL_GUEST -> true // Can score for guests
ParticipantType.NETWORK -> false // Cannot score for other users
ParticipantType.GHOST -> false
}
}
}Permission Rules:
LOCAL_MAINcan score for themselves andLOCAL_GUESTparticipantsLOCAL_GUESTparticipants can only be scored byLOCAL_MAINNETWORKparticipants are read-only (other authenticated users)GHOSTparticipants are not accessible (pending cleanup)
5. EndStateTransitionService (Week 3)
Purpose: Build participant completion state transitions when completing ends
Extraction Metrics:
- Lines Extracted: 230 lines from LiveScoringViewModel (normal completion + final completion logic)
- Reduction: Week 3 contribution to 12.4% reduction
- Priority: 🚨 HIGH - Critical end completion state management
- Week: Week 3 (Oct 2025)
File: EndStateTransitionService.kt
Core Responsibilities:
- Update participant progress and mark complete (delegates to ProgressTrackingService)
- Calculate and update completed totals (score, arrow count, X count)
- Build
EndScoreWithArrowswith unique IDs for LazyColumn keys - Clear participant state (arrows, X-rings, coordinates) when appropriate
- Advance to next end or mark participant complete
Code Reference:
@Singleton
class EndStateTransitionService @Inject constructor(
private val progressTrackingService: ProgressTrackingService
) {
data class EndTransitionResult(
val updatedSession: ScoringSessionState,
val completedEndWithArrows: EndScoreWithArrows,
val currentEndScore: Int
)
fun buildEndTransitionState(
session: ScoringSessionState,
round: Round,
participantId: String,
nextEndNumber: Int?,
markComplete: Boolean = false,
tempEndId: Int
): EndTransitionResult { /* ... */ }
}Key Features:
- Single service handles both normal and final end completion with
markCompleteflag - Returns complete state transition in one method call
- Builds
EndScoreWithArrowswith proper ID management
KMP Status: ⚠️ Partial compatibility
- Currently uses
androidx.compose.ui.geometry.Offsetfor coordinates - TODO: Replace with DomainCoordinate model (Technical Debt #5)
6. ProgressUpdateService (Week 3)
Purpose: Build real-time progress and live statistics updates
Extraction Metrics:
- Lines Extracted: 85 lines from LiveScoringViewModel
updateProgressImmediate()method - Reduction: Week 3 contribution to 12.4% reduction
- Priority: MEDIUM - Progress tracking and UI state updates
- Week: Week 3 (Oct 2025)
File: ProgressUpdateService.kt
Core Responsibilities:
- Calculate current end progress and overall progress (delegates to ProgressCalculationService)
- Create live statistics (delegates to StatisticsAggregationService)
- Determine animation duration based on UpdateSource
- Build updated session with RealTimeProgress and timestamps
Code Reference:
@Singleton
class ProgressUpdateService @Inject constructor(
private val progressCalculationService: ProgressCalculationService,
private val statisticsAggregationService: StatisticsAggregationService
) {
fun buildProgressUpdate(
session: ScoringSessionState,
round: Round,
participantId: String,
updateSource: UpdateSource
): ScoringSessionState {
val currentEndProgress = progressCalculationService.calculateCurrentEndProgress(
session.currentEndArrows.size,
round.arrowsPerEnd
)
val overallProgress = progressCalculationService.calculateOverallProgress(
session.currentEndNumber,
session.currentArrowNumber,
round.numEnds,
round.arrowsPerEnd
)
val liveStatistics = statisticsAggregationService.createLiveStatistics(
session, round, participantId
)
return session.copy(
realTimeProgress = RealTimeProgress(
currentEndProgress = currentEndProgress,
overallProgress = overallProgress,
animationDurationMs = determineAnimationDuration(updateSource)
),
liveStatistics = liveStatistics,
lastUpdated = System.currentTimeMillis()
)
}
}Delegation Pattern:
- Delegates progress calculation to
ProgressCalculationService - Delegates statistics creation to
StatisticsAggregationService - Orchestrates multiple services to build complete progress update
KMP Status: ✅ Fully compatible (zero Android dependencies)
7. Other Domain Services
ProgressCalculationService - ProgressCalculationService.kt:9
- Delegates to
ProgressCalculatorfor pure computation - Calculates current end progress (0.0 to 1.0)
- Calculates overall round progress
StatisticsCalculationService - StatisticsCalculationService.kt:11
- Delegates to
StatisticsCalculatorfor pure computation - Calculates current end contribution
- Combines base statistics with current end
Additional Services:
AccuracyCalculationServiceRankingCalculationServiceParticipantValidationServiceAnonymousParticipantCleanupService
Before/After Comparisons
LiveScoringViewModel Refactoring
Before Extraction:
LiveScoringViewModel.kt: ~3,279 lines
Responsibilities:
✗ Session state management (arrows, participants, progress)
✗ End completion validation
✗ Database score recording
✗ Firebase tournament sync
✗ Leaderboard updates
✗ Conflict detection/resolution
✗ Score submission with retry logic
✗ Statistics calculation
✗ Permission checking
✗ Event coordination
After Extraction (Week 3 Complete - Oct 25, 2025):
LiveScoringViewModel.kt: 1,481 lines (-1,327 lines, 47.3% reduction from original 2,808)
Retained Responsibilities:
✓ Session state orchestration (ScoringSessionState)
✓ Arrow scoring coordination
✓ Participant switching
✓ UI event emission
✓ Service delegation
Extracted to Services (Weeks 2-3):
→ Week 2:
- ArrowScoringDomainService: ~150 lines (arrow validation, scoring logic)
- ParticipantStateService: ~100 lines (participant state management)
- SessionStateBuilderService: ~80 lines (session state construction)
- ParticipantStateQueryService: ~40 lines (participant state queries)
→ Week 3:
- EndStateTransitionService: 230 lines (end completion state transitions)
- ProgressUpdateService: 85 lines (real-time progress and statistics)
Previously Extracted:
→ EndCompletionService: ~400 lines (end validation, recording)
→ TournamentSyncService: ~600 lines (Firebase sync, retry logic)
→ ScoreConflictResolutionService: ~262 lines (conflict detection/resolution)
→ ScoringPermissionService: (permission checks)
**Week 3 Achievement:**
- Starting: 1,691 lines → Ending: 1,481 lines (210 line reduction)
- EXCEEDED <1,500 line goal by 19 lines
- 37 new tests, 100% pass rate, zero regressions
ViewModel Constructor Comparison:
Before:
class LiveScoringViewModel(
application: Application,
private val roundRepository: RoundRepository
// All logic embedded in ViewModel
) : AndroidViewModel(application)After:
// LiveScoringViewModel.kt:68
class LiveScoringViewModel(
application: Application,
private val roundRepository: RoundRepository,
private val progressCalculationService: ProgressCalculationService,
private val statisticsCalculationService: StatisticsCalculationService,
private val scoringPermissionService: ScoringPermissionService,
private val repositoryFactory: RepositoryFactory? = null,
private val tournamentSyncService: TournamentSyncService,
private val scoreConflictResolutionService: ScoreConflictResolutionService,
private val endCompletionService: EndCompletionService
) : AndroidViewModel(application)Service Usage Examples:
// LiveScoringViewModel.kt:507 - Delegate to EndCompletionService
fun finalizeEnd(participantId: String, endNumber: Int): Boolean {
return endCompletionService.finalizeEnd(
participantId, endNumber, _scoringSession.value, currentRound ?: return false
)
}
// LiveScoringViewModel.kt:1544 - Delegate to TournamentSyncService
private fun setupTournamentSync(tournamentId: String, roundNumber: Int) {
tournamentSyncService.setupTournamentSync(tournamentId, roundNumber)
// Observe leaderboard updates
viewModelScope.launch {
tournamentSyncService.liveLeaderboard.collect { leaderboardEntries ->
syncTournamentScores(leaderboardEntries)
}
}
}
// LiveScoringViewModel.kt:1616 - Delegate to ScoreConflictResolutionService
private suspend fun detectScoreConflicts(tournamentScores: List<TournamentScore>) {
val conflicts = scoreConflictResolutionService.detectConflicts(
tournamentScores, localEditableIds, localEndsMap
)
}
// LiveScoringViewModel.kt:216 - Use ScoringPermissionService
if (!scoringPermissionService.canScoreFor(currentUser, targetParticipant)) {
val message = scoringPermissionService.getScoringRestrictionMessage(currentUser, targetParticipant)
_errorMessage.value = message
return
}RoundViewModel Refactoring (In Progress)
Current State:
RoundViewModel.kt: 2,177 lines
Target Extraction:
→ StatisticsService: Round statistics calculation (~500 lines)
→ DisplayDataService: Round display data formatting (~300 lines)
→ RankingService: Participant ranking with tie-breaking (~200 lines)
Expected Outcome: ~1,200 lines (45% reduction)
Service Responsibilities
Service Boundary Guidelines
What Belongs in a Service:
- ✅ Business logic independent of UI state
- ✅ Data transformation and validation
- ✅ External API communication (Firebase)
- ✅ Conflict resolution algorithms
- ✅ Complex calculations (statistics, rankings)
- ✅ Permission and authorization logic
What Stays in ViewModel:
- ❌ UI state management (StateFlow, MutableStateFlow)
- ❌ Navigation events
- ❌ User input handling
- ❌ Service orchestration and coordination
- ❌ Lifecycle-aware operations (viewModelScope)
Service Communication:
- ✅ StateFlow exposure for reactive updates
- ✅ Direct method calls between services (with caution)
- ✅ Event channels for one-time events
- ❌ Circular dependencies (use interfaces if needed)
Dependency Injection Patterns
Hilt/Dagger Integration
Service Annotations:
// EndCompletionService.kt:38
@Singleton
class EndCompletionService @Inject constructor(
private val application: Application,
private val roundRepository: RoundRepository,
private val repositoryFactory: RepositoryFactory?,
private val tournamentSyncService: TournamentSyncService,
private val serviceScope: CoroutineScope? = null
) {
// Service implementation
}Key Patterns:
@Singleton- Single instance across entire app lifecycle@Inject constructor- Hilt/Dagger constructor injection- Optional
serviceScope: CoroutineScope? = null- Testability
Benefits:
- Automatic dependency resolution - Hilt creates dependency graph
- Singleton lifecycle - Services persist across ViewModel recreation
- Testing flexibility - Mock services with test doubles
- Compile-time safety - Missing dependencies caught at build time
Service Lifecycle Management
Coroutine Scope Pattern
Service-Level Scope with SupervisorJob:
// TournamentSyncService.kt:87
@Singleton
class TournamentSyncService @Inject constructor(
private val serviceScope: CoroutineScope? = null
) {
/**
* Service-level coroutine scope with SupervisorJob for independent child failure handling
* Can be injected for testing purposes
*/
private val scope = serviceScope ?: CoroutineScope(SupervisorJob())
fun setupTournamentSync(tournamentId: String, currentRoundNumber: Int = 1) {
scope.launch {
// Long-lived listener that survives ViewModel recreation
leaderboardListenerJob = launch {
tournamentRepository.getTournamentLeaderboard(tournamentId).collect { entries ->
_liveLeaderboard.value = entries
}
}
}
}
}Why SupervisorJob?
- Independent Failure Handling: One child job failure doesn’t cancel siblings
- Service Persistence: Listeners survive ViewModel recreation
- Testing: Inject TestCoroutineScope for deterministic tests
Cleanup Pattern:
// TournamentSyncService.kt:424
fun cleanup() {
android.util.Log.d("TournamentSyncService", "🧹 Cleaning up TournamentSyncService")
leaderboardListenerJob?.cancel()
participantsListenerJob?.cancel()
roundStateListenerJob?.cancel()
leaderboardListenerJob = null
participantsListenerJob = null
roundStateListenerJob = null
}ViewModel Integration:
// LiveScoringViewModel.kt:1914
override fun onCleared() {
super.onCleared()
tournamentSyncService.cleanup()
// Other cleanup...
}Inter-Service Communication
1. StateFlow Exposure (Reactive)
Pattern: Services expose StateFlows for ViewModels/other services to observe
// TournamentSyncService.kt:96
private val _syncStatus = MutableStateFlow<TournamentSyncStatus>(TournamentSyncStatus.Idle)
val syncStatus: StateFlow<TournamentSyncStatus> = _syncStatus.asStateFlow()
private val _liveLeaderboard = MutableStateFlow<List<LeaderboardEntry>>(emptyList())
val liveLeaderboard: StateFlow<List<LeaderboardEntry>> = _liveLeaderboard.asStateFlow()ViewModel Consumption:
// LiveScoringViewModel.kt:114
val scoreConflicts: StateFlow<Map<String, ScoreConflict>> =
scoreConflictResolutionService.scoreConflicts
val liveLeaderboard: StateFlow<List<LeaderboardEntry>>
get() = tournamentSyncService.liveLeaderboard2. Direct Method Calls (Service-to-Service)
Pattern: EndCompletionService calls TournamentSyncService directly
// EndCompletionService.kt:296 - Service calling another service
private fun submitTournamentEnd(
round: Round,
session: ScoringSessionState,
arrowScores: List<Int>,
isXFlags: List<Boolean>,
endNumber: Int,
onSyncStatusUpdate: (TournamentSyncStatus) -> Unit
) {
scope.launch {
val success = tournamentSyncService.submitTournamentScoreWithRetry(
tournamentId = round.tournamentId ?: return@launch,
participantId = session.currentParticipantId,
roundNumber = round.tournamentRoundNumber ?: 1,
endNumber = endNumber,
arrowScores = arrowScores,
isXFlags = isXFlags,
deviceId = deviceId,
maxRetries = 3,
onSyncStatusUpdate = onSyncStatusUpdate
)
}
}Dependency Flow:
EndCompletionService → TournamentSyncService → TournamentRepository → Firebase
3. Event Channels (One-Time Events)
Pattern: Services emit events through Channels for UI navigation/coordination
// EndCompletionService.kt:63
private val _endCompleteEvent = Channel<EndCompletionResult>(Channel.BUFFERED)
val endCompleteEvent = _endCompleteEvent.receiveAsFlow()
private val _roundCompleteEvent = Channel<Unit>(Channel.BUFFERED)
val roundCompleteEvent = _roundCompleteEvent.receiveAsFlow()ViewModel Consumption:
// LiveScoringViewModel (hypothetical)
init {
viewModelScope.launch {
endCompletionService.endCompleteEvent.collect { result ->
handleEndCompletion(result)
}
}
}4. Callback Functions (Synchronous Coordination)
Pattern: Pass callbacks for immediate UI updates or cache invalidation
// EndCompletionService.kt:173
suspend fun completeCurrentEnd(
session: ScoringSessionState,
round: Round,
onStatisticsCacheClear: () -> Unit // Callback for coordination
): Pair<Boolean, Int?>? {
// Record end completion
val nextEndNumber = recordEndCompletion(round, session)
// Clear statistics cache via callback
onStatisticsCacheClear()
return Pair(true, nextEndNumber)
}Testing Strategies
1. Service Unit Tests (Isolated)
Strategy: Test services in isolation with mocked dependencies
class EndCompletionServiceTest {
@Mock private lateinit var roundRepository: RoundRepository
@Mock private lateinit var tournamentSyncService: TournamentSyncService
private lateinit var endCompletionService: EndCompletionService
@Before
fun setup() {
MockKAnnotations.init(this)
endCompletionService = EndCompletionService(
application = mockApplication,
roundRepository = roundRepository,
repositoryFactory = null,
tournamentSyncService = tournamentSyncService,
serviceScope = TestCoroutineScope() // Inject test scope
)
}
@Test
fun `finalizeEnd validates end number bounds`() = runTest {
val session = ScoringSessionState(currentEndNumber = 5)
val round = Round(numEnds = 10, numArrows = 6)
val result = endCompletionService.finalizeEnd(
participantId = "test_id",
endNumber = 0, // Invalid
session = session,
round = round
)
assertFalse(result)
}
}2. ViewModel Integration Tests (Service Orchestration)
Strategy: Test ViewModel coordination of multiple services
class LiveScoringViewModelTest {
@Mock private lateinit var endCompletionService: EndCompletionService
@Mock private lateinit var tournamentSyncService: TournamentSyncService
private lateinit var viewModel: LiveScoringViewModel
@Test
fun `completeCurrentEnd delegates to EndCompletionService`() = runTest {
// Given
val mockResult = Pair(true, 6)
coEvery {
endCompletionService.completeCurrentEnd(any(), any(), any())
} returns mockResult
// When
viewModel.completeCurrentEnd()
// Then
coVerify { endCompletionService.completeCurrentEnd(any(), any(), any()) }
}
}3. Service Integration Tests (Service-to-Service)
Strategy: Test real service interactions with mocked repositories
class ServiceIntegrationTest {
private lateinit var endCompletionService: EndCompletionService
private lateinit var tournamentSyncService: TournamentSyncService
@Mock private lateinit var tournamentRepository: TournamentRepository
@Test
fun `EndCompletionService submits tournament score via TournamentSyncService`() = runTest {
// Real services with mocked repository
tournamentSyncService = TournamentSyncService(
context = mockContext,
repositoryFactory = mockFactory,
serviceScope = TestCoroutineScope()
)
endCompletionService = EndCompletionService(
application = mockApplication,
roundRepository = mockRoundRepository,
repositoryFactory = mockFactory,
tournamentSyncService = tournamentSyncService, // Real service
serviceScope = TestCoroutineScope()
)
// Test full flow
val result = endCompletionService.recordEndCompletion(/* ... */)
// Verify repository called via service chain
coVerify { tournamentRepository.submitScore(any(), any(), any(), any()) }
}
}Service Flow Diagrams
End Completion with Tournament Sync
sequenceDiagram participant UI as ActiveScoringScreen participant VM as LiveScoringViewModel participant ECS as EndCompletionService participant RR as RoundRepository participant TSS as TournamentSyncService participant TR as TournamentRepository participant FB as Firebase UI->>VM: completeCurrentEnd() VM->>ECS: completeCurrentEnd(session, round) ECS->>ECS: finalizeEnd() - validation alt Validation Failed ECS-->>VM: null (failure) VM-->>UI: Show error else Validation Passed ECS->>RR: scoreEnd(roundId, endNumber, arrows) RR-->>ECS: Success alt Tournament Round ECS->>TSS: submitTournamentScoreWithRetry() TSS->>TR: submitScore(tournamentId, participantId, scores) TR->>FB: POST /tournaments/{id}/scores FB-->>TR: Success TR-->>TSS: Result.success TSS-->>ECS: true end ECS-->>VM: Pair(true, nextEndNumber) VM->>VM: updateScoringSession(nextEnd) VM-->>UI: UI update with next end end
Tournament Sync Setup (3 Concurrent Listeners)
sequenceDiagram participant VM as LiveScoringViewModel participant TSS as TournamentSyncService participant TR as TournamentRepository participant FB as Firebase VM->>TSS: setupTournamentSync(tournamentId, roundNumber) par Leaderboard Listener TSS->>TR: getTournamentLeaderboard(tournamentId) TR->>FB: observeCollection("/leaderboard") FB-->>TR: Flow<List<LeaderboardEntry>> TR-->>TSS: Flow emits entries TSS->>TSS: _liveLeaderboard.value = entries TSS-->>VM: StateFlow update and Participants Listener TSS->>TR: getTournamentParticipants(tournamentId) TR->>FB: observeCollection("/participants") FB-->>TR: Flow<List<Participant>> TR-->>TSS: Flow emits participants TSS->>TSS: _tournamentParticipants.value = participants and Detailed Scores Listener TSS->>TR: observeTournamentScores(tournamentId, roundNumber) TR->>FB: observeCollection("/scores") FB-->>TR: Flow<List<TournamentScore>> TR-->>TSS: Flow emits scores TSS->>TSS: _incomingTournamentScores.value = scores end Note over VM,FB: All 3 listeners run concurrently with independent lifecycle
Score Conflict Detection and Resolution
sequenceDiagram participant TSS as TournamentSyncService participant VM as LiveScoringViewModel participant SCRS as ScoreConflictResolutionService participant RR as RoundRepository participant UI as ActiveScoringScreen TSS->>TSS: observeTournamentScores().collect TSS->>VM: _incomingTournamentScores.value = scores VM->>VM: Observe incomingTournamentScores VM->>RR: getEndScoresForRound(roundId) RR-->>VM: List<EndScoreWithArrows> (local) VM->>SCRS: detectConflicts(remoteScores, localEditableIds, localEnds) SCRS->>SCRS: Compare local vs remote for each end alt Score Mismatch Found SCRS->>SCRS: determineConflictResolution(versions, timestamps) SCRS->>SCRS: _scoreConflicts.value = conflicts SCRS-->>VM: Map<String, ScoreConflict> VM->>VM: Observe scoreConflicts StateFlow VM-->>UI: Show conflict indicator UI->>VM: resolveConflict(participantId, endNumber, strategy) VM->>SCRS: resolveConflict(participantId, endNumber, strategy) alt Strategy: LOCAL_WINS SCRS->>SCRS: Keep local score, mark resolved else Strategy: REMOTE_WINS SCRS->>SCRS: Accept remote score, mark resolved SCRS-->>VM: onResolved(remoteScore) VM->>RR: updateEndScore(endNumber, remoteScore) else Strategy: MANUAL SCRS-->>VM: User must choose VM-->>UI: Show conflict resolution dialog end end
Key Architectural Decisions
1. Why @Singleton Services?
Decision: Use @Singleton for all extracted services
Rationale:
- Services persist across ViewModel recreation (process death recovery)
- StateFlows maintain state during configuration changes
- Firebase listeners survive ViewModel lifecycle
- Shared state across multiple ViewModels (if needed)
Trade-offs:
- ⚠️ Memory persists for app lifetime (mitigated by cleanup methods)
- ⚠️ Shared mutable state risks (mitigated by StateFlow immutability)
2. Why Service-Level CoroutineScope?
Decision: Services own their own CoroutineScope with SupervisorJob
Rationale:
- ViewModel destruction doesn’t cancel long-lived operations (Firebase listeners)
- Independent failure handling (one listener failure doesn’t kill all)
- Testing flexibility (inject TestCoroutineScope)
Pattern:
private val scope = serviceScope ?: CoroutineScope(SupervisorJob())Alternative Considered: Use viewModelScope
- ❌ Listeners canceled on ViewModel clear
- ❌ Process death would lose Firebase connections
- ❌ Less testable (tied to ViewModel lifecycle)
3. Why StateFlow Over LiveData?
Decision: Use StateFlow for reactive state in services
Rationale:
- Kotlin Coroutines native (no Android dependency)
- Type-safe and null-safe
- Supports Flow operators (map, filter, combine)
- Better testability (no lifecycle complications)
Pattern:
private val _liveLeaderboard = MutableStateFlow<List<LeaderboardEntry>>(emptyList())
val liveLeaderboard: StateFlow<List<LeaderboardEntry>> = _liveLeaderboard.asStateFlow()4. Why Direct Service-to-Service Calls?
Decision: Allow services to depend on other services (with caution)
Rationale:
- EndCompletionService naturally needs TournamentSyncService
- Reduces ViewModel orchestration complexity
- Clear dependency direction (no cycles)
Guidelines:
- ✅ One-way dependencies (A → B, never B → A)
- ✅ Use interfaces if circular dependency needed
- ⚠️ Document dependency graph in service KDoc
Performance Characteristics
Memory Footprint Reduction
Before (God ViewModel):
- LiveScoringViewModel instance: ~15-20 KB
- Includes all sync logic, validation, calculation code
- Multiple large data structures embedded
After (Service Extraction):
- LiveScoringViewModel instance: ~8-12 KB (40% reduction)
- Services shared across app: +5 KB total (singleton)
- Net savings: ~7-15 KB per ViewModel instance
- Multi-ViewModel benefit: Shared service code, no duplication
Compilation Performance
Before:
- Single 3,279-line file: ~2-3 seconds per incremental compile
- Changes cascade to all dependent components
After:
- Smaller files: ~0.5-1 second per incremental compile
- Service changes isolated (less recompilation)
- Build time improvement: ~40-60% faster incremental builds
Future Extraction Targets
High-Priority Candidates
RoundViewModel.kt (2,177 lines)
- StatisticsService - Round statistics calculation (~500 lines)
- DisplayDataService - Round display data formatting (~300 lines)
- RankingService - Participant ranking with tie-breaking (~200 lines)
Expected Outcome: ~1,200 lines (45% reduction)
Related Documentation
- Scoring-Flow - End completion and arrow scoring flows
- Tournament-Flow - Tournament creation and lifecycle
- Data-Sync-Flow - Offline-first sync and conflict resolution
- Round-Lifecycle-Flow - Round state machine and transitions
Code References Summary
| Service | File:Line | LOC | Extracted From | Reduction |
|---|---|---|---|---|
| EndCompletionService | EndCompletionService.kt:38 | 362 | LiveScoringViewModel | ~400 lines (15%) |
| TournamentSyncService | TournamentSyncService.kt:74 | 498 | LiveScoringViewModel | ~600 lines (21%) |
| ScoreConflictResolutionService | ScoreConflictResolutionService.kt:54 | 263 | LiveScoringViewModel | - |
| ScoringPermissionService | ScoringPermissionService.kt:18 | 160 | LiveScoringViewModel | - |
Total LiveScoringViewModel Reduction: ~3,279 → 2,279 lines (31% reduction, -1,000 lines)
Last Updated: 2025-10-08
Part of the comprehensive flow documentation series for the Archery Apprentice app