CountIn: Modern Android Architecture in Practice

Building CountIn was an excellent opportunity to implement modern Android architecture patterns in a real-world application. The app’s requirements - real-time synchronization, offline capability, and multi-user coordination - provided interesting technical challenges that pushed me to make thoughtful architectural decisions.
Architecture Overview
CountIn follows clean architecture principles with clear separation of concerns across three main layers:
Presentation Layer
- Jetpack Compose for modern, declarative UI
- ViewModels for lifecycle-aware state management
- Navigation Component for type-safe screen transitions
- Hilt for dependency injection throughout the UI layer
Domain Layer
- Use Cases encapsulating business logic
- Repository interfaces defining data contracts
- Domain models representing core business entities
- Event-driven architecture for real-time updates
Data Layer
- Room database for local persistence
- Firebase Realtime Database for cloud synchronization
- Repository implementations handling data orchestration
- WorkManager for background synchronization
Real-Time Synchronization Challenges
Conflict Resolution
Multiple users incrementing/decrementing counts simultaneously required careful conflict resolution:
class CounterRepository {
suspend fun updateCount(delta: Int) {
// Optimistic local update
localDatabase.updateCount(currentCount + delta)
// Firebase transaction for atomic updates
firebase.runTransaction { snapshot ->
val currentValue = snapshot.getValue(Int::class.java) ?: 0
snapshot.ref.setValue(currentValue + delta)
}
}
}
Offline-First Design
The app needed to function perfectly without internet connectivity:
- Local-first operations with immediate UI feedback
- Conflict-free replicated data types (CRDTs) for eventual consistency
- Automatic sync queues when connectivity returns
- Graceful degradation with clear offline indicators
State Management
Reactive UI with Compose
Jetpack Compose’s reactive nature paired perfectly with the real-time requirements:
@Composable
fun CounterScreen(viewModel: CounterViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is CounterUiState.Loading -> LoadingIndicator()
is CounterUiState.Success -> {
CounterDisplay(
count = uiState.currentCount,
capacity = uiState.maxCapacity,
onIncrement = viewModel::incrementCount,
onDecrement = viewModel::decrementCount
)
}
}
}
ViewModel Architecture
ViewModels coordinate between UI and business logic while surviving configuration changes:
@HiltViewModel
class CounterViewModel @Inject constructor(
private val counterUseCase: CounterUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(CounterUiState.Loading)
val uiState: StateFlow<CounterUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
counterUseCase.observeCounter()
.collect { counter ->
_uiState.value = CounterUiState.Success(
currentCount = counter.count,
maxCapacity = counter.capacity
)
}
}
}
}
Data Persistence Strategy
Room Database Design
Local storage uses Room with a simple but effective schema:
@Entity(tableName = "counters")
data class CounterEntity(
@PrimaryKey val id: String,
val count: Int,
val capacity: Int,
val lastUpdated: Long,
val syncStatus: SyncStatus
)
@Dao
interface CounterDao {
@Query("SELECT * FROM counters WHERE id = :id")
fun observeCounter(id: String): Flow<CounterEntity?>
@Update
suspend fun updateCounter(counter: CounterEntity)
}
Firebase Integration
Cloud synchronization leverages Firebase Realtime Database for instant updates:
class FirebaseCounterDataSource @Inject constructor(
private val database: FirebaseDatabase
) {
fun observeCounter(id: String): Flow<CounterDto> = callbackFlow {
val listener = object : ValueEventListener {
override fun onDataChange(snapshot: DataSnapshot) {
snapshot.getValue<CounterDto>()?.let { trySend(it) }
}
}
database.reference.child("counters").child(id)
.addValueEventListener(listener)
awaitClose {
database.reference.child("counters").child(id)
.removeEventListener(listener)
}
}
}
Performance Optimizations
Efficient UI Updates
Real-time updates required careful performance consideration:
- Compose recomposition optimization using
derivedStateOf
for computed values - List diffing with stable keys for large counter lists
- Background threading for all database operations
- Memory leak prevention with proper lifecycle handling
Battery Optimization
Long-running usage demanded battery-conscious implementation:
- Doze mode compatibility with Firebase’s built-in optimizations
- Background restrictions awareness with foreground service when needed
- Network efficiency through batched updates and connection pooling
- Wake lock management for critical synchronization operations
Testing Strategy
Comprehensive Test Coverage
The architecture enabled thorough testing at each layer:
@Test
fun `increment counter updates local and remote storage`() = runTest {
// Given
val initialCount = 5
coEvery { localDataSource.getCounter(counterId) } returns
CounterEntity(counterId, initialCount, 100, System.currentTimeMillis())
// When
repository.incrementCount(counterId)
// Then
coVerify { localDataSource.updateCounter(any()) }
coVerify { remoteDataSource.updateCounter(counterId, initialCount + 1) }
}
UI Testing with Compose
Jetpack Compose testing made UI verification straightforward:
@Test
fun `counter displays current count and responds to taps`() {
composeTestRule.setContent {
CounterScreen()
}
composeTestRule
.onNodeWithTag("counter_value")
.assertTextEquals("0")
composeTestRule
.onNodeWithTag("increment_button")
.performClick()
composeTestRule
.onNodeWithTag("counter_value")
.assertTextEquals("1")
}
Lessons Learned
Architecture Benefits
- Clean separation made testing and maintenance straightforward
- Dependency injection enabled easy mocking and testing
- Reactive streams provided natural real-time update handling
- Modern Android patterns resulted in robust, maintainable code
Real-World Challenges
- Network partition handling required careful consideration of edge cases
- User experience during conflicts needed clear feedback and resolution
- Performance under load demanded profiling and optimization
- Device compatibility across different Android versions and hardware
Impact and Results
CountIn successfully demonstrated that modern Android architecture patterns can handle complex real-time requirements while maintaining code quality and developer productivity. The app currently serves events with thousands of attendees, providing reliable occupancy tracking even under high-load conditions.
The project reinforced my belief that investment in proper architecture pays dividends in maintainability, testability, and feature development velocity. It also highlighted the importance of considering real-world usage patterns when making technical decisions.
You can explore the complete implementation on GitHub to see how these architectural decisions translate to production code.