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

Problem: God Class Anti-Pattern

Original State:

  • LiveScoringViewModel.kt: ~3,279 lines before extraction
  • RoundViewModel.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:

  1. Identify cohesive responsibilities (sync, validation, calculation)
  2. Extract to @Singleton services with clear boundaries
  3. Use dependency injection for testability
  4. Expose StateFlows for reactive state management
  5. 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 finalized
  • completeCurrentEnd() - EndCompletionService.kt:173 - Main end completion flow
  • recordEndCompletion() - EndCompletionService.kt:226 - Repository submission with validation
  • checkAllParticipantsComplete() - 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 listeners
  • syncTournamentScoresManually() - TournamentSyncService.kt:188 - Manual sync trigger
  • submitTournamentScoreWithRetry() - TournamentSyncService.kt:240 - Exponential backoff retry
  • observeTournamentRoundState() - TournamentSyncService.kt:379 - Round state observation
  • cleanup() - 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 score
  • LATEST_WINS - Use most recently updated score
  • MANUAL - Requires user intervention

Key Methods:

  • detectConflicts() - ScoreConflictResolutionService.kt:69
  • determineConflictResolution() - ScoreConflictResolutionService.kt:138
  • resolveConflict() - ScoreConflictResolutionService.kt:178
  • dismissConflict() - 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_MAIN can score for themselves and LOCAL_GUEST participants
  • LOCAL_GUEST participants can only be scored by LOCAL_MAIN
  • NETWORK participants are read-only (other authenticated users)
  • GHOST participants 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 EndScoreWithArrows with 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 markComplete flag
  • Returns complete state transition in one method call
  • Builds EndScoreWithArrows with proper ID management

KMP Status: ⚠️ Partial compatibility

  • Currently uses androidx.compose.ui.geometry.Offset for 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 ProgressCalculator for pure computation
  • Calculates current end progress (0.0 to 1.0)
  • Calculates overall round progress

StatisticsCalculationService - StatisticsCalculationService.kt:11

  • Delegates to StatisticsCalculator for pure computation
  • Calculates current end contribution
  • Combines base statistics with current end

Additional Services:

  • AccuracyCalculationService
  • RankingCalculationService
  • ParticipantValidationService
  • AnonymousParticipantCleanupService

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:

  1. Automatic dependency resolution - Hilt creates dependency graph
  2. Singleton lifecycle - Services persist across ViewModel recreation
  3. Testing flexibility - Mock services with test doubles
  4. 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.liveLeaderboard

2. 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)

  1. StatisticsService - Round statistics calculation (~500 lines)
  2. DisplayDataService - Round display data formatting (~300 lines)
  3. RankingService - Participant ranking with tie-breaking (~200 lines)

Expected Outcome: ~1,200 lines (45% reduction)



Code References Summary

ServiceFile:LineLOCExtracted FromReduction
EndCompletionServiceEndCompletionService.kt:38362LiveScoringViewModel~400 lines (15%)
TournamentSyncServiceTournamentSyncService.kt:74498LiveScoringViewModel~600 lines (21%)
ScoreConflictResolutionServiceScoreConflictResolutionService.kt:54263LiveScoringViewModel-
ScoringPermissionServiceScoringPermissionService.kt:18160LiveScoringViewModel-

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