Adding a New Feature
This guide walks through the process of adding a new feature to the app, following our established patterns and best practices.
Overview of Steps
- Define data models
- Create/update data sources
- Create repository layer
- Create UI layer
- Set up navigation
- Configure dependency injection
Step 1: Data Models
1.1 Data Source Models
Create models in appropriate core module (for example, core:network
or core:room
). Feel free to
create your own if its not present in the template:
// core/network/src/main/kotlin/dev/atick/core/network/model/NetworkFeature.kt
@Serializable
data class NetworkFeature(
val id: String,
val name: String,
val createdAt: Long = System.currentTimeMillis()
)
// core/room/src/main/kotlin/dev/atick/core/room/model/FeatureEntity.kt
@Entity(tableName = "features")
data class FeatureEntity(
@PrimaryKey val id: String,
val name: String,
val lastSynced: Long = 0,
val needsSync: Boolean = true
)
Step 2: Data Sources
2.1 Network Data Source
// core/network/src/main/kotlin/dev/atick/core/network/data/
interface NetworkDataSource {
suspend fun getFeatures(): List<NetworkFeature>
suspend fun createFeature(feature: NetworkFeature)
}
class NetworkDataSourceImpl @Inject constructor(
private val api: FeatureApi,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : NetworkDataSource {
override suspend fun getFeatures(): List<NetworkFeature> =
withContext(ioDispatcher) {
api.getFeatures()
}
override suspend fun createFeature(feature: NetworkFeature) =
withContext(ioDispatcher) {
api.createFeature(feature)
}
}
2.2 Local Data Source
// core/room/src/main/kotlin/dev/atick/core/room/data/
interface LocalDataSource {
fun observeFeatures(): Flow<List<FeatureEntity>>
suspend fun saveFeatures(features: List<FeatureEntity>)
}
class LocalDataSourceImpl @Inject constructor(
private val featureDao: FeatureDao,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : LocalDataSource {
override fun observeFeatures(): Flow<List<FeatureEntity>> =
featureDao.observeFeatures()
.flowOn(ioDispatcher)
override suspend fun saveFeatures(features: List<FeatureEntity>) =
withContext(ioDispatcher) {
featureDao.insertFeatures(features)
}
}
Note
Always use withContext(ioDispatcher)
in data sources to ensure operations run on the IO thread.
Step 3: Repository Layer
3.1 Feature Models
// data/src/main/kotlin/dev/atick/data/model/Feature.kt
data class Feature(
val id: String,
val name: String,
val lastSynced: Long = 0
)
// Conversion Functions
fun NetworkFeature.toFeature() = Feature(
id = id,
name = name
)
fun FeatureEntity.toFeature() = Feature(
id = id,
name = name,
lastSynced = lastSynced
)
fun Feature.toEntity() = FeatureEntity(
id = id,
name = name,
lastSynced = lastSynced
)
3.2 Repository Implementation
// data/src/main/kotlin/dev/atick/data/repository/
interface FeatureRepository {
fun observeFeatures(): Flow<List<Feature>>
suspend fun createFeature(feature: Feature): Result<Unit>
suspend fun syncFeatures(): Result<Unit>
}
class FeatureRepositoryImpl @Inject constructor(
private val localDataSource: LocalDataSource,
private val networkDataSource: NetworkDataSource
) : FeatureRepository {
override fun observeFeatures(): Flow<List<Feature>> =
localDataSource.observeFeatures()
.map { entities -> entities.map { it.toFeature() } }
override suspend fun createFeature(feature: Feature): Result<Unit> =
suspendRunCatching {
networkDataSource.createFeature(feature.toNetworkFeature())
localDataSource.saveFeature(feature.toEntity())
}
override suspend fun syncFeatures(): Result<Unit> =
suspendRunCatching {
val networkFeatures = networkDataSource.getFeatures()
val entities = networkFeatures.map { it.toFeature().toEntity() }
localDataSource.saveFeatures(entities)
}
}
Tip
Use suspendRunCatching
in repositories to handle errors consistently.
Step 4: UI Layer
4.1 UI Models
// feature/feature-name/src/main/kotlin/dev/atick/feature/model/
data class FeatureScreenData(
val features: List<Feature> = emptyList(),
val newFeatureName: String = ""
)
4.2 ViewModel
// feature/feature-name/src/main/kotlin/dev/atick/feature/ui/
@HiltViewModel
class FeatureViewModel @Inject constructor(
private val featureRepository: FeatureRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState(FeatureScreenData()))
val uiState = _uiState.asStateFlow()
init {
observeFeatures()
}
private fun observeFeatures() {
featureRepository.observeFeatures()
.onEach { features ->
_uiState.updateState {
copy(features = features)
}
}
.launchIn(viewModelScope)
}
fun updateFeatureName(name: String) {
_uiState.updateState {
copy(newFeatureName = name)
}
}
fun createFeature() {
_uiState.updateStateWith(viewModelScope) {
val feature = Feature(
id = UUID.randomUUID().toString(),
name = newFeatureName
)
featureRepository.createFeature(feature)
}
}
}
4.3 UI Components
// feature/feature-name/src/main/kotlin/dev/atick/feature/ui/
@Composable
fun FeatureRoute(
onShowSnackbar: suspend (String, SnackbarAction, Throwable?) -> Boolean,
viewModel: FeatureViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
StatefulComposable(
state = uiState,
onShowSnackbar = onShowSnackbar
) { screenData ->
FeatureScreen(
screenData = screenData,
onNameChange = viewModel::updateFeatureName,
onCreateFeature = viewModel::createFeature
)
}
}
@Composable
private fun FeatureScreen(
screenData: FeatureScreenData,
onNameChange: (String) -> Unit,
onCreateFeature: () -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
// UI Implementation
}
}
Step 5: Navigation
// feature/feature-name/src/main/kotlin/dev/atick/feature/navigation/
@Serializable
data object FeatureNavGraph
@Serializable
data object Feature
fun NavController.navigateToFeature(
navOptions: NavOptions? = null
) {
navigate(Feature, navOptions)
}
fun NavGraphBuilder.featureScreen(
onShowSnackbar: suspend (String, SnackbarAction, Throwable?) -> Boolean
) {
composable<Feature> {
FeatureRoute(onShowSnackbar = onShowSnackbar)
}
}
fun NavGraphBuilder.featureNavGraph(
nestedGraphs: NavGraphBuilder.() -> Unit
) {
navigation<FeatureNavGraph>(
startDestination = Feature
) {
nestedGraphs()
}
}
Step 6: Dependency Injection
6.1 Data Source Module
@Module
@InstallIn(SingletonComponent::class)
abstract class DataSourceModule {
@Binds
@Singleton
abstract fun bindNetworkDataSource(
impl: NetworkDataSourceImpl
): NetworkDataSource
}
6.2 Repository Module
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindFeatureRepository(
impl: FeatureRepositoryImpl
): FeatureRepository
}
Best Practices Reminder
-
Data Sources:
- Use
withContext(ioDispatcher)
for IO operations - Handle raw data models
- One responsibility per data source
- Use
-
Repositories:
- Use
suspendRunCatching
for error handling - Convert between data models
- Coordinate between data sources
- Use
-
ViewModels:
- Use
updateState
andupdateStateWith
utilities - Handle UI logic and state management
- Convert to UI models
- Use
-
UI Components:
- Use
StatefulComposable
for consistent loading/error handling - Keep composables pure and state-driven
- Separate route from screen implementation
- Use
Important
Always follow the unidirectional data flow pattern: UI Events → ViewModel → Repository → Data Sources → Back to UI through StateFlow.
Testing (Upcoming 🚧)
Remember to add tests for your new feature:
- Data Source Tests: Test IO operations
- Repository Tests: Test business logic
- ViewModel Tests: Test state management
- UI Tests: Test composables
- Integration Tests: Test full feature flow
Further Reading
- Firebase Setup Guide: Add Firebase to your app
- Dependency Management: Learn about version catalogs and plugin configurations
- Architecture Overview: Understand the app's architecture
- Design Philosophy: Learn about design principles