Learn how to add new features, database entities, use cases, and UI screens while maintaining Clean Architecture principles
GemAI’s architecture makes it straightforward to add new features. This guide walks you through common extension scenarios while preserving architectural integrity.
Follow these steps to add a new table to the database:
1
Create the entity class
Define your Room entity:
data/local/model/TagEntity.kt
package com.sarath.gem.data.local.modelimport androidx.room.Entityimport androidx.room.ForeignKeyimport androidx.room.Indeximport androidx.room.PrimaryKey@Entity( tableName = "tags", indices = [Index(value = ["conversationId"])], foreignKeys = [ ForeignKey( entity = ConversationEntity::class, parentColumns = ["id"], childColumns = ["conversationId"], onDelete = ForeignKey.CASCADE ) ])data class TagEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, val conversationId: Long, val name: String, val color: String, val timestamp: Long)
2
Create the DAO
Extend BaseDao for common operations:
data/local/dao/TagDao.kt
package com.sarath.gem.data.local.daoimport androidx.room.Daoimport androidx.room.Queryimport com.sarath.gem.core.base.BaseDaoimport com.sarath.gem.data.local.model.TagEntityimport kotlinx.coroutines.flow.Flow@Daointerface TagDao : BaseDao<TagEntity> { @Query("SELECT * FROM tags WHERE conversationId = :conversationId") fun getTagsForConversation(conversationId: Long): Flow<List<TagEntity>> @Query("SELECT * FROM tags WHERE id = :tagId") suspend fun getTagById(tagId: Long): TagEntity? @Query("DELETE FROM tags WHERE conversationId = :conversationId") suspend fun deleteTagsForConversation(conversationId: Long) @Query("SELECT DISTINCT name FROM tags ORDER BY name ASC") fun getAllTagNames(): Flow<List<String>>}
3
Create domain model and mapper
Create the domain model:
domain/model/Tag.kt
package com.sarath.gem.domain.modeldata class Tag( val id: Long = 0, val conversationId: Long, val name: String, val color: String, val timestamp: Long)
Create the mapper:
data/local/mapper/TagMapper.kt
package com.sarath.gem.data.local.mapperimport com.sarath.gem.core.base.BaseMapperimport com.sarath.gem.data.local.model.TagEntityimport com.sarath.gem.domain.model.Tagobject TagMapper : BaseMapper<TagEntity, Tag> { override fun mapToDomain(entity: TagEntity): Tag { return with(entity) { Tag( id = id, conversationId = conversationId, name = name, color = color, timestamp = timestamp ) } } override fun mapToEntity(domain: Tag): TagEntity { return with(domain) { TagEntity( id = id, conversationId = conversationId, name = name, color = color, timestamp = timestamp ) } }}fun Tag.toEntity() = TagMapper.mapToEntity(this)fun TagEntity.toDomain() = TagMapper.mapToDomain(this)
4
Update AppDatabase
Add the entity to the database and increment version:
data/local/AppDatabase.kt
@Database( entities = [ ConversationEntity::class, MessageEntity::class, PromptEntity::class, TagEntity::class // Add new entity ], version = 2, // Increment version exportSchema = true,)abstract class AppDatabase : RoomDatabase() { abstract fun conversationDao(): ConversationDao abstract fun messageDao(): MessageDao abstract fun promptDao(): PromptDao abstract fun tagDao(): TagDao // Add new DAO // ... rest of the code}
val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( """ CREATE TABLE IF NOT EXISTS `tags` ( `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `conversationId` INTEGER NOT NULL, `name` TEXT NOT NULL, `color` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`conversationId`) REFERENCES `conversations`(`id`) ON DELETE CASCADE ) """.trimIndent() ) database.execSQL( "CREATE INDEX IF NOT EXISTS `index_tags_conversationId` ON `tags` (`conversationId`)" ) }}private fun buildDatabase(context: Context): AppDatabase { return Room.databaseBuilder(context, AppDatabase::class.java, "gemai.db") .addMigrations(MIGRATION_1_2) // Add migration .addCallback(/* ... */) .build()}
Always increment the database version and provide a migration when changing the schema in production. During development, fallbackToDestructiveMigration() is acceptable.
package com.sarath.gem.presentation.screen.tags.viewmodelimport com.sarath.gem.core.base.UIActionimport com.sarath.gem.core.base.UIEventimport com.sarath.gem.core.base.UIStateimport com.sarath.gem.domain.model.Tagdata class TagUIState( val tags: List<Tag>, val isLoading: Boolean, val selectedTag: Tag?) : UIStatesealed class TagUIEvent : UIEvent { data class ShowError(val message: String) : TagUIEvent() object TagCreated : TagUIEvent()}sealed class TagUIAction : UIAction { data class CreateTag(val name: String, val color: String) : TagUIAction() data class DeleteTag(val tagId: Long) : TagUIAction() data class SelectTag(val tag: Tag) : TagUIAction()}
Don’t create dependencies manually or use service locators.
Handle errors consistently
Use the Result type for operations that can fail:
Result handling
sealed class Result<out T, out E> { data class Success<T>(val data: T) : Result<T, Nothing>() data class Error<E>(val error: E) : Result<Nothing, E>()}// Usageresult .onSuccess { data -> /* handle success */ } .onError { error -> /* handle error */ }
Write meaningful documentation
Document public APIs, especially use cases and repositories:
/** * Retrieves all conversations for the current user. * * @return A [Flow] emitting the list of conversations, ordered by * last message timestamp in descending order. */fun getConversations(): Flow<List<Conversation>>
class CreateConversationUseCaseTest { private lateinit var useCase: CreateConversationUseCase private lateinit var repository: ChatRepository @Before fun setup() { repository = mockk() useCase = CreateConversationUseCase(repository) } @Test fun `perform creates conversation successfully`() = runTest { val title = "Test Chat" val expected = Conversation(id = 1, title = title) coEvery { repository.createConversation(title) } returns Result.Success(expected) val result = useCase.perform(title) assertTrue(result is Result.Success) assertEquals(expected, (result as Result.Success).data) }}