Skip to content

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:

  1. Separation of Concerns: Each component has its own responsibility
  2. Single Source of Truth: Data is managed in a single place
  3. Unidirectional Data Flow: Data flows in one direction, events flow in the opposite
  4. State-Based UI: UI is a reflection of the state
  5. Pragmatic Simplicity: Complex patterns are only added when necessary

Core Layers

The app uses a two-layer architecture:

graph TD
    A[UI Layer] --> B[Data Layer]
    style A fill: #4CAF50, stroke: #333, stroke-width: 2px
    style B fill: #1976D2, stroke: #333, stroke-width: 2px

UI Layer

The UI layer follows MVVM pattern and consists of:

  1. Composable UI: Pure UI components built with Jetpack Compose
  2. ViewModel: Manages UI state and business logic
  3. UI State: 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(
    uiState: HomeScreenData,
    onAction: (HomeAction) -> Unit
) {
}

Data Layer

The data layer handles data operations and consists of:

  1. Repositories: Single source of truth for data
  2. Data Sources: Interface with external systems (API, database, etc.)
  3. 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:

  1. UiState Wrapper:

    data class UiState<T : Any>(
        val data: T,
        val loading: Boolean = false,
        val error: OneTimeEvent<Throwable?> = OneTimeEvent(null)
    )
    
  2. State Updates:

    // Regular state updates
    _uiState.updateState { copy(value = newValue) }
    
    // Async operations
    _uiState.updateStateWith(viewModelScope) {
        repository.someAsyncOperation()
    }
    
  3. State Display:

    @Composable
    fun StatefulScreen(
        state: UiState<ScreenData>,
        onShowSnackbar: suspend (String, SnackbarAction, Throwable?) -> Boolean
    ) {
        StatefulComposable(
            state = state,
            onShowSnackbar = onShowSnackbar
        ) { screenData ->
            // UI Content
        }
    }
    

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

  1. User Interaction → UI Events
  2. ViewModel → Business Logic
  3. Repository → Data Operations
  4. DataSource → External Systems
  5. 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 (Upcoming 🚧)

The architecture enables different types of tests:

  1. UI Tests: Test Composables in isolation
  2. ViewModel Tests: Test state management and business logic
  3. Repository Tests: Test data operations
  4. Integration Tests: Test multiple layers together

Best Practices

  1. Keep UI State Simple: Only include what's needed for the UI
  2. Single Responsibility: Each class should have one clear purpose
  3. Error Handling: Use Result type for operations that can fail
  4. Coroutines: Use structured concurrency with proper scoping
  5. Immutable Data: Use data classes for state and models

Further Reading