Architecture Overview
This project follows the official Android Architecture Guidelines with some pragmatic adaptations to keep the codebase simple and maintainable.
Architectural Principles
The architecture is built on several key principles:
- Separation of Concerns: Each component has its own responsibility
- Single Source of Truth: Data is managed in a single place
- Unidirectional Data Flow: Data flows in one direction, events flow in the opposite
- State-Based UI: UI is a reflection of the state
- Pragmatic Simplicity: Complex patterns are only added when necessary
Core Layers
The app uses a two-layer architecture:
graph TB
subgraph UI["UI Layer"]
direction TB
Composable["Composables<br/>(Pure UI)"]
ViewModel["ViewModels<br/>(State Management)"]
ScreenData["Screen Data<br/>(Immutable State)"]
UiState["UiState<T><br/>(Wrapper)"]
Composable -. observes .-> UiState
ViewModel -->|manages| UiState
UiState -->|wraps| ScreenData
end
subgraph Data["Data Layer"]
direction TB
Repository["Repositories<br/>(Single Source of Truth)"]
LocalDS["Local Data Sources<br/>(Room, DataStore)"]
NetworkDS["Network Data Sources<br/>(Retrofit, Firebase)"]
Repository -->|reads/writes| LocalDS
Repository -->|fetches| NetworkDS
NetworkDS -. syncs .-> LocalDS
end
subgraph DI["Dependency Injection"]
Hilt["Hilt<br/>(Provides Dependencies)"]
end
ViewModel -->|calls| Repository
Hilt -. injects .-> ViewModel
Hilt -. injects .-> Repository
Simplified View:
graph TD
A[UI Layer] --> B[Data Layer]
UI Layer
The UI layer follows MVVM pattern and consists of:
- Composables: Pure UI components built with Jetpack Compose
- ViewModels: Manage UI state and business logic
- Screen Data: Immutable data classes representing screen state
Example UI Layer structure:
data class HomeScreenData(
val items: List<Item> = emptyList(),
// other UI state properties
)
@HiltViewModel
class HomeViewModel @Inject constructor(
private val repository: HomeRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState(HomeScreenData()))
val uiState = _uiState.asStateFlow()
}
@Composable
fun HomeScreen(
screenData: HomeScreenData,
onAction: (HomeAction) -> Unit
) {
}
Data Layer
The data layer handles data operations and consists of:
- Repositories: Single source of truth for data
- Data Sources: Interface with external systems (API, database, etc.)
- Models: Data representation classes
Example Data Layer structure:
class HomeRepositoryImpl @Inject constructor(
private val localDataSource: LocalDataSource,
private val networkDataSource: NetworkDataSource
) : HomeRepository {
override fun getData(): Flow<List<Data>> =
networkBoundResource(
query = { localDataSource.getData() },
fetch = { networkDataSource.getData() },
saveFetchResult = { localDataSource.saveData(it) }
)
}
Note
Unlike the official guidelines, this project intentionally omits the domain layer to reduce complexity. You can add a domain layer if your app requires complex business logic or needs to share logic between multiple ViewModels.
State Management
The project uses a consistent state management pattern:
-
UiState Wrapper:
-
State Updates:
[!TIP] Kotlin Context Parameters: The
updateStateWithandupdateWithfunctions use Kotlin's context parameters feature (enabled via-Xcontext-parameterscompiler flag) to automatically access the ViewModel's scope. You don't need to passviewModelScopeexplicitly - it's injected via thecontext(viewModel: ViewModel)parameter.
-
State Display:
Dependency Injection
The project uses Hilt for dependency injection:
- Modules: Organized by feature and core functionality
- Scoping: Primarily uses singleton scope for repositories and data sources
- Testing: Enables easy dependency replacement for testing
Data Flow
- User Interaction → UI Events
- ViewModel → Business Logic
- Repository → Data Operations
- DataSource → External Systems
- Back to UI through StateFlow
graph LR
A[User Action] --> B[ViewModel]
B --> C[Repository]
C --> D[Data Sources]
D --> C
C --> B
B --> E[UI State]
E --> F[UI]
Adding a Domain Layer
If your app grows in complexity, you can add a domain layer:
class GetDataUseCase @Inject constructor(
private val repository: Repository
) {
suspend operator fun invoke(params: Params): Result<Data> =
repository.getData(params)
}
Tip
Consider adding a domain layer when: - Multiple ViewModels share business logic - Business rules become complex - You need to transform data between layers
Testing Strategy
Note
Testing infrastructure is planned but not yet implemented in this template.
The architecture enables different types of tests:
- UI Tests: Test Composables in isolation
- ViewModel Tests: Test state management and business logic
- Repository Tests: Test data operations
- Integration Tests: Test multiple layers together
Best Practices
- Keep Screen Data Simple: Only include what's needed for the UI
- Single Responsibility: Each class should have one clear purpose
- Error Handling: Use
Resulttype for operations that can fail - Coroutines: Use structured concurrency with proper scoping
- Immutable Data: Use data classes for state and models
Integration Patterns
Understanding how different architectural components work together is crucial for building features effectively. This section explains the key integration patterns in the template.
Navigation + State Management Integration
Navigation and state management work together to create a seamless user experience with proper state preservation.
Pattern: Type-safe navigation with state restoration
// 1. Define navigation route with parameters
@Serializable
data class ProfileRoute(val userId: String)
// 2. ViewModel manages state
@HiltViewModel
class ProfileViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: UserRepository
) : ViewModel() {
// Extract userId from navigation arguments
private val userId: String = savedStateHandle.toRoute<ProfileRoute>().userId
private val _uiState = MutableStateFlow(UiState(ProfileScreenData()))
val uiState = _uiState.asStateFlow()
init {
loadProfile()
}
private fun loadProfile() {
_uiState.updateStateWith {
repository.getUserProfile(userId)
}
}
}
// 3. Navigation integration in NavGraph
fun NavGraphBuilder.profileScreen(
onShowSnackbar: suspend (String, SnackbarAction, Throwable?) -> Boolean,
onNavigateBack: () -> Unit
) {
composable<ProfileRoute> { backStackEntry ->
ProfileRoute(
onShowSnackbar = onShowSnackbar,
onNavigateBack = onNavigateBack
)
}
}
Key Integration Points:
SavedStateHandleprovides navigation arguments to ViewModeltoRoute<T>()converts type-safe route to data class- State survives configuration changes automatically
- Back stack preservation handled by Navigation Compose
Tip
For detailed navigation patterns, see Navigation Deep Dive. For state management patterns, see State Management Guide.
Firebase + Data Layer Integration
Firebase services integrate with the repository pattern to provide seamless authentication and cloud data access.
Pattern: Firebase authentication flow with repository pattern
// 1. Firebase wrapper abstracts Firebase SDK
class FirebaseAuthWrapper @Inject constructor(
private val auth: FirebaseAuth
) {
fun currentUserFlow(): Flow<FirebaseUser?> = callbackFlow {
val listener = FirebaseAuth.AuthStateListener { auth ->
trySend(auth.currentUser)
}
auth.addAuthStateListener(listener)
awaitClose { auth.removeAuthStateListener(listener) }
}
}
// 2. Repository uses Firebase wrapper
class UserRepositoryImpl @Inject constructor(
private val firebaseAuth: FirebaseAuthWrapper,
private val firestore: FirestoreWrapper,
private val localDataSource: UserLocalDataSource
) : UserRepository {
// Observe authentication state
override fun observeCurrentUser(): Flow<User?> =
firebaseAuth.currentUserFlow()
.map { firebaseUser ->
firebaseUser?.let { getUserFromFirestore(it.uid) }
}
// Sync user data from Firestore to local database
private suspend fun getUserFromFirestore(uid: String): User {
val firestoreUser = firestore.getUser(uid)
localDataSource.saveUser(firestoreUser.toEntity())
return firestoreUser.toDomain()
}
}
// 3. ViewModel observes repository
@HiltViewModel
class AuthViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
val currentUser: StateFlow<User?> = userRepository.observeCurrentUser()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = null
)
}
Key Integration Points:
- Firebase wrappers provide reactive Flow-based APIs
- Repositories coordinate between Firebase and local database
- ViewModels observe repositories using StateFlow
- Local database serves as cache for offline access
Tip
For Firebase setup, see Firebase Setup Guide. For repository patterns, see Data Layer Guide.
Dependency Injection Integration
Hilt ties all architectural components together by providing dependencies throughout the app.
Pattern: Complete DI flow from data sources to UI
// 1. Provide data sources
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideApiService(): ApiService = Retrofit.Builder()
.baseUrl(BASE_URL)
.build()
.create(ApiService::class.java)
}
// 2. Bind repositories
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindUserRepository(
impl: UserRepositoryImpl
): UserRepository
}
// 3. Inject into ViewModels
@HiltViewModel
class HomeViewModel @Inject constructor(
private val userRepository: UserRepository, // Injected by Hilt
private val contentRepository: ContentRepository // Injected by Hilt
) : ViewModel() {
// ViewModel automatically receives dependencies
}
// 4. Inject into Composables
@Composable
fun HomeRoute(
viewModel: HomeViewModel = hiltViewModel() // Hilt provides ViewModel
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Use ViewModel
}
Key Integration Points:
- Data sources provided in Singleton scope
- Repositories use
@Bindsfor interface-to-implementation mapping - ViewModels annotated with
@HiltViewModelfor automatic injection hiltViewModel()retrieves ViewModels in Composables@AndroidEntryPointenables injection in Activities/Fragments
Tip
For complete DI patterns, see Dependency Injection Guide (993 lines of comprehensive guidance).
Sync + Repositories Integration
WorkManager-based sync integrates with repositories to keep local data synchronized with remote sources.
Pattern: Background sync with repository coordination
// 1. Repository implements Syncable interface
class ContentRepositoryImpl @Inject constructor(
private val localDataSource: ContentLocalDataSource,
private val networkDataSource: ContentNetworkDataSource,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : ContentRepository, Syncable {
// UI observes local database (single source of truth)
override fun observeContent(): Flow<List<Content>> =
localDataSource.observeContent()
.map { entities -> entities.map { it.toDomain() } }
// Sync updates local database in background
override suspend fun sync(): Boolean = withContext(ioDispatcher) {
suspendRunCatching {
val remoteContent = networkDataSource.getContent()
localDataSource.saveContent(remoteContent.map { it.toEntity() })
}.isSuccess
}
}
// 2. SyncWorker coordinates repository sync with progress tracking
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted workerParameters: WorkerParameters,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val homeRepository: HomeRepository
) : CoroutineWorker(context, workerParameters) {
override suspend fun doWork(): Result = withContext(ioDispatcher) {
try {
setForeground(getForegroundInfo())
homeRepository.sync()
.flowOn(ioDispatcher)
.collect { progress ->
setForeground(getForegroundInfo(progress.total, progress.current))
}
Result.success()
} catch (e: Exception) {
if (runAttemptCount < TOTAL_SYNC_ATTEMPTS) Result.retry()
else Result.failure()
}
}
}
// 3. Initialize sync in Application
class App : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
Sync.initialize(context = this) // Sets up periodic sync
}
}
Key Integration Points:
- Repositories implement
sync()method returningFlow<SyncProgress> SyncWorkerreceives repository via Hilt dependency injectionsetForeground()displays progress notification as sync runsSync.initialize()sets up periodic WorkManager sync- Local database updated in background
- UI automatically reflects changes via Flow observation
- Network constraints ensure sync only runs when connected
- Retry logic with exponential backoff (up to 3 attempts)
Tip
For sync patterns and troubleshooting, see Sync Module README.
Complete Integration Flow Example
Here's how all systems work together when a user opens a feature screen:
sequenceDiagram
participant UI as Composable
participant VM as ViewModel
participant Repo as Repository
participant Local as Local DB
participant Remote as Remote API
participant Sync as SyncWorker
Note over UI, Sync: User Opens Screen
UI ->> VM: hiltViewModel() injection
VM ->> Repo: observeData()
Repo ->> Local: observeDataEntities()
Local -->> Repo: Flow<List<Entity>>
Repo -->> VM: Flow<List<Domain>>
VM -->> UI: StateFlow<UiState<Data>>
Note over UI, Sync: Background Sync (Periodic)
Sync ->> Repo: sync()
Repo ->> Remote: fetchData()
Remote -->> Repo: List<DTO>
Repo ->> Local: saveData(entities)
Local -->> Repo: Success
Note over Local, UI: Flow emits new data
Local -->> Repo: Updated Flow
Repo -->> VM: Updated Flow
VM -->> UI: Updated State
UI ->> UI: Recomposition
Flow Breakdown:
-
Screen Opens:
- Hilt injects ViewModel with Repository dependencies
- ViewModel starts observing repository data
- Repository returns Flow from local database (single source of truth)
-
Initial Display:
- UI receives StateFlow with cached data
- Screen displays immediately (offline-first)
-
Background Sync:
- WorkManager triggers SyncWorker periodically
- SyncWorker calls
sync()on all repositories - Repository fetches from remote and updates local database
-
Automatic Update:
- Local database change triggers Flow emission
- ViewModel receives updated data
- UI automatically recomposes with new data
Key Benefits:
- Offline-first: App works without network
- Automatic updates: No manual refresh needed
- Type safety: Compile-time navigation and DI
- Separation of concerns: Each layer has clear responsibility
- Testability: Dependencies easily mocked via Hilt
Summary
This template uses a two-layer architecture (UI + Data) for simplicity:
- UI Layer: Composables + ViewModels with UiState wrapper
- Data Layer: Repositories + Data Sources (Network, Local, Firebase)
- State Management: Centralized with
updateStateandupdateStateWithfunctions - Dependency Injection: Hilt with feature-based modules
- Unidirectional Data Flow: User actions → ViewModel → Repository → Data Sources → UI
The architecture is intentionally simple but allows for growth when needed.
Further Reading
Concept Guides
- Design Philosophy - Understand the design principles behind the architecture
- State Management - Deep dive into the UiState pattern
- Adding Features - Step-by-step guide to implementing new features
- Data Flow - Understand data flow patterns (offline-first, caching, sync)
- Dependency Injection - Complete guide to Hilt setup and patterns
Module Documentation
- Core UI Module - State management utilities and UI components
- Data Layer Module - Repository patterns and implementations
- App Module - Application architecture and MainActivity setup