In the previous tutorial, we planned our task manager app. Now we build the foundation — the data layer.

The data layer is everything below the UI: the database, the repository, and the use cases. Get this right, and the UI layer writes itself.

What We Build in This Tutorial

data/
├── local/
│   ├── TaskEntity.kt       ← Database table definition
│   ├── TaskDao.kt           ← Database operations
│   └── AppDatabase.kt       ← Room database
├── mapper/
│   └── TaskMapper.kt        ← Convert Entity ↔ Domain Model
├── repository/
│   └── TaskRepositoryImpl.kt ← Repository implementation
domain/
├── model/
│   ├── Task.kt              ← Clean domain model
│   ├── Category.kt          ← Enum
│   └── Priority.kt          ← Enum
├── repository/
│   └── TaskRepository.kt    ← Interface (contract)
└── usecase/
    ├── GetTasksUseCase.kt
    ├── AddTaskUseCase.kt
    ├── ToggleTaskUseCase.kt
    └── DeleteTaskUseCase.kt

Step 1: Domain Models

The domain layer is pure Kotlin — no Android dependencies, no Room annotations. This is the “truth” of your app.

Task Model

// domain/model/Task.kt
// The clean model that the UI works with.
// No database annotations — just plain Kotlin.

data class Task(
    val id: Long = 0,
    val title: String,
    val description: String = "",
    val category: Category = Category.PERSONAL,
    val priority: Priority = Priority.MEDIUM,
    val isCompleted: Boolean = false,
    val dueDate: Long? = null,
    val createdAt: Long = System.currentTimeMillis(),
    val updatedAt: Long = System.currentTimeMillis()
)

Enums

// domain/model/Category.kt
enum class Category(val displayName: String) {
    WORK("Work"),
    PERSONAL("Personal"),
    SHOPPING("Shopping"),
    HEALTH("Health")
}

// domain/model/Priority.kt
enum class Priority(val level: Int, val displayName: String) {
    LOW(0, "Low"),
    MEDIUM(1, "Medium"),
    HIGH(2, "High")
}

// domain/model/TaskFilter.kt
enum class TaskFilter {
    ALL, ACTIVE, COMPLETED
}

// domain/model/SortOrder.kt
enum class SortOrder {
    DATE_NEWEST, DATE_OLDEST, PRIORITY_HIGH, PRIORITY_LOW, NAME_AZ
}

Step 2: Room Entity

The entity is the database representation of a task. It has Room annotations and uses database-friendly types:

// data/local/TaskEntity.kt
// This class maps directly to a database table.
// It uses simple types (String, Int, Long) because SQLite
// doesn't understand Kotlin enums or custom classes.

@Entity(tableName = "tasks")
data class TaskEntity(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val title: String,
    val description: String = "",
    val category: String = "PERSONAL",   // Enum stored as String
    val priority: Int = 1,               // Enum stored as Int (level)
    val isCompleted: Boolean = false,
    val dueDate: Long? = null,           // Nullable — not all tasks have due dates
    val createdAt: Long = System.currentTimeMillis(),
    val updatedAt: Long = System.currentTimeMillis()
)

Why Separate Entity and Model?

TaskEntity (Data)Task (Domain)
PurposeDatabase storageApp logic
TypesString, Int, LongCategory, Priority enums
Annotations@Entity, @PrimaryKeyNone
Changes whenDatabase schema changesBusiness rules change
Used byRoom DAO onlyViewModels, UI, use cases

If you change how priorities are stored in the database (e.g., from Int to String), only the Entity and mapper change. The rest of the app doesn’t care.

Step 3: Entity-Model Mapper

Convert between the database representation and the clean model:

// data/mapper/TaskMapper.kt
// Converts between TaskEntity (database) and Task (domain).
// This is the bridge between the data layer and domain layer.

fun TaskEntity.toDomainModel(): Task {
    return Task(
        id = id,
        title = title,
        description = description,
        category = try {
            Category.valueOf(category)
        } catch (e: IllegalArgumentException) {
            Category.PERSONAL  // Fallback if stored value is invalid
        },
        priority = Priority.entries.find { it.level == priority }
            ?: Priority.MEDIUM,  // Fallback
        isCompleted = isCompleted,
        dueDate = dueDate,
        createdAt = createdAt,
        updatedAt = updatedAt
    )
}

fun Task.toEntity(): TaskEntity {
    return TaskEntity(
        id = id,
        title = title,
        description = description,
        category = category.name,        // Enum → String
        priority = priority.level,       // Enum → Int
        isCompleted = isCompleted,
        dueDate = dueDate,
        createdAt = createdAt,
        updatedAt = updatedAt
    )
}

Extension functions keep the mapping clean. task.toEntity() and entity.toDomainModel() — readable and simple.

Step 4: Room DAO

The DAO defines all database operations:

// data/local/TaskDao.kt
// Every database operation is defined here.
// Functions returning Flow auto-update when data changes.
// Suspend functions run on a background thread.

@Dao
interface TaskDao {

    // --- READ operations (Flow = reactive, auto-updates UI) ---

    @Query("SELECT * FROM tasks ORDER BY createdAt DESC")
    fun getAllTasks(): Flow<List<TaskEntity>>

    @Query("SELECT * FROM tasks WHERE isCompleted = 0 ORDER BY createdAt DESC")
    fun getActiveTasks(): Flow<List<TaskEntity>>

    @Query("SELECT * FROM tasks WHERE isCompleted = 1 ORDER BY createdAt DESC")
    fun getCompletedTasks(): Flow<List<TaskEntity>>

    @Query("""
        SELECT * FROM tasks
        WHERE title LIKE '%' || :query || '%'
           OR description LIKE '%' || :query || '%'
        ORDER BY createdAt DESC
    """)
    fun searchTasks(query: String): Flow<List<TaskEntity>>

    @Query("SELECT * FROM tasks WHERE id = :id")
    suspend fun getTaskById(id: Long): TaskEntity?

    @Query("SELECT * FROM tasks WHERE category = :category ORDER BY priority DESC")
    fun getTasksByCategory(category: String): Flow<List<TaskEntity>>

    @Query("SELECT COUNT(*) FROM tasks")
    fun getTaskCount(): Flow<Int>

    @Query("SELECT COUNT(*) FROM tasks WHERE isCompleted = 1")
    fun getCompletedCount(): Flow<Int>

    // --- WRITE operations (suspend = background thread) ---

    @Insert
    suspend fun insert(task: TaskEntity): Long  // Returns the new ID

    @Update
    suspend fun update(task: TaskEntity)

    @Delete
    suspend fun delete(task: TaskEntity)

    @Query("DELETE FROM tasks WHERE id = :id")
    suspend fun deleteById(id: Long)

    @Query("DELETE FROM tasks WHERE isCompleted = 1")
    suspend fun deleteCompleted(): Int  // Returns count of deleted rows

    // --- Batch operations ---

    @Insert
    suspend fun insertAll(tasks: List<TaskEntity>)

    @Query("UPDATE tasks SET isCompleted = :isCompleted, updatedAt = :updatedAt WHERE id = :id")
    suspend fun updateCompletionStatus(id: Long, isCompleted: Boolean, updatedAt: Long)
}

Why updateCompletionStatus instead of @Update? Because @Update replaces the entire row. updateCompletionStatus updates only two fields — faster and clearer about what changed.

Step 5: Room Database

// data/local/AppDatabase.kt

@Database(
    entities = [TaskEntity::class],
    version = 1,
    exportSchema = true  // Enables migration verification
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun taskDao(): TaskDao
}

Short and clean. Hilt will create the instance — no singleton needed.

Step 6: Repository Interface (Domain Layer)

The repository interface lives in the domain layer. It defines WHAT operations are available without saying HOW they work:

// domain/repository/TaskRepository.kt
// This is a CONTRACT — it says what operations exist.
// The domain layer uses this interface.
// The data layer implements it.

interface TaskRepository {
    fun getAllTasks(): Flow<List<Task>>
    fun getActiveTasks(): Flow<List<Task>>
    fun getCompletedTasks(): Flow<List<Task>>
    fun searchTasks(query: String): Flow<List<Task>>
    fun getTasksByCategory(category: Category): Flow<List<Task>>
    fun getTaskCount(): Flow<Int>
    fun getCompletedCount(): Flow<Int>

    suspend fun getTaskById(id: Long): Task?
    suspend fun addTask(task: Task): Long
    suspend fun updateTask(task: Task)
    suspend fun toggleTask(id: Long)
    suspend fun deleteTask(id: Long)
    suspend fun deleteCompletedTasks(): Int
}

Notice: the interface uses Task (domain model), not TaskEntity. The domain layer never sees database types.

Step 7: Repository Implementation (Data Layer)

The implementation lives in the data layer and converts between entities and models:

// data/repository/TaskRepositoryImpl.kt
// This implements the domain interface using Room.
// It converts between TaskEntity (database) and Task (domain).
// @Inject tells Hilt to provide this automatically.

class TaskRepositoryImpl @Inject constructor(
    private val dao: TaskDao
) : TaskRepository {

    override fun getAllTasks(): Flow<List<Task>> {
        return dao.getAllTasks().map { entities ->
            entities.map { it.toDomainModel() }
        }
    }

    override fun getActiveTasks(): Flow<List<Task>> {
        return dao.getActiveTasks().map { entities ->
            entities.map { it.toDomainModel() }
        }
    }

    override fun getCompletedTasks(): Flow<List<Task>> {
        return dao.getCompletedTasks().map { entities ->
            entities.map { it.toDomainModel() }
        }
    }

    override fun searchTasks(query: String): Flow<List<Task>> {
        return dao.searchTasks(query).map { entities ->
            entities.map { it.toDomainModel() }
        }
    }

    override fun getTasksByCategory(category: Category): Flow<List<Task>> {
        return dao.getTasksByCategory(category.name).map { entities ->
            entities.map { it.toDomainModel() }
        }
    }

    override fun getTaskCount(): Flow<Int> = dao.getTaskCount()

    override fun getCompletedCount(): Flow<Int> = dao.getCompletedCount()

    override suspend fun getTaskById(id: Long): Task? {
        return dao.getTaskById(id)?.toDomainModel()
    }

    override suspend fun addTask(task: Task): Long {
        return dao.insert(task.toEntity())
    }

    override suspend fun updateTask(task: Task) {
        dao.update(task.toEntity().copy(updatedAt = System.currentTimeMillis()))
    }

    override suspend fun toggleTask(id: Long) {
        val task = dao.getTaskById(id) ?: return
        dao.updateCompletionStatus(
            id = id,
            isCompleted = !task.isCompleted,
            updatedAt = System.currentTimeMillis()
        )
    }

    override suspend fun deleteTask(id: Long) {
        dao.deleteById(id)
    }

    override suspend fun deleteCompletedTasks(): Int {
        return dao.deleteCompleted()
    }
}

The repository:

  1. Receives TaskDao via Hilt injection
  2. Calls DAO methods
  3. Converts entities to domain models using the mapper
  4. Returns Flow<List<Task>> (not Flow<List<TaskEntity>>)

Step 8: Use Cases

Use cases represent one business action each. They sit between the ViewModel and the repository:

// domain/usecase/GetTasksUseCase.kt
// Returns tasks based on filter and search query.
// The ViewModel calls this instead of the repository directly.

class GetTasksUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    operator fun invoke(
        filter: TaskFilter = TaskFilter.ALL,
        searchQuery: String = ""
    ): Flow<List<Task>> {
        return if (searchQuery.isNotBlank()) {
            repository.searchTasks(searchQuery)
        } else {
            when (filter) {
                TaskFilter.ALL -> repository.getAllTasks()
                TaskFilter.ACTIVE -> repository.getActiveTasks()
                TaskFilter.COMPLETED -> repository.getCompletedTasks()
            }
        }
    }
}
// domain/usecase/AddTaskUseCase.kt
// Validates and adds a new task.

class AddTaskUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    suspend operator fun invoke(
        title: String,
        description: String = "",
        category: Category = Category.PERSONAL,
        priority: Priority = Priority.MEDIUM,
        dueDate: Long? = null
    ): Result<Long> {
        // Validation
        if (title.isBlank()) {
            return Result.failure(IllegalArgumentException("Title cannot be empty"))
        }
        if (title.length > 200) {
            return Result.failure(IllegalArgumentException("Title too long (max 200 chars)"))
        }

        val task = Task(
            title = title.trim(),
            description = description.trim(),
            category = category,
            priority = priority,
            dueDate = dueDate
        )

        val id = repository.addTask(task)
        return Result.success(id)
    }
}
// domain/usecase/ToggleTaskUseCase.kt
class ToggleTaskUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    suspend operator fun invoke(taskId: Long) {
        repository.toggleTask(taskId)
    }
}
// domain/usecase/DeleteTaskUseCase.kt
class DeleteTaskUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    suspend operator fun invoke(taskId: Long) {
        repository.deleteTask(taskId)
    }
}

Why Use Cases?

Without Use CasesWith Use Cases
ViewModel calls repository directlyViewModel calls use case
Validation logic in ViewModelValidation in use case
Same logic duplicated in multiple ViewModelsShared through use cases
Hard to test business rulesEasy to test (pure functions)

For a small app, use cases might feel like overkill. But as your app grows, they keep business logic organized and reusable.

Step 9: Hilt Module

Tell Hilt how to create and provide all dependencies:

// di/AppModule.kt
// This module teaches Hilt how to create:
// - The Room database (singleton — one instance for the whole app)
// - The DAO (from the database)
// - The repository (implementation bound to the interface)

@Module
@InstallIn(SingletonComponent::class)
object AppModule {

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
        return Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "task_manager_db"
        ).build()
    }

    @Provides
    fun provideTaskDao(database: AppDatabase): TaskDao {
        return database.taskDao()
    }
}

// Separate module for repository binding
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Binds
    abstract fun bindTaskRepository(
        impl: TaskRepositoryImpl
    ): TaskRepository
}

@Binds vs @Provides:

  • @Provides — you write the creation code (for Room database)
  • @Binds — you tell Hilt “use this implementation for this interface” (cleaner for interfaces)

How It All Connects

Hilt creates: AppDatabase → TaskDao → TaskRepositoryImpl → Use Cases → ViewModel

Data flow:
  Room DB → TaskDao (Flow<List<TaskEntity>>)
          → TaskRepositoryImpl (converts to Flow<List<Task>>)
          → GetTasksUseCase (applies filter/search)
          → ViewModel (collects into StateFlow)
          → UI (observes with collectAsStateWithLifecycle)

Everything is injected. No manual creation. Change the database? Only the Entity, DAO, and mapper change. Everything else stays the same.

Testing the Data Layer

// domain/usecase/AddTaskUseCaseTest.kt

class AddTaskUseCaseTest {

    private val fakeRepository = FakeTaskRepository()
    private val addTask = AddTaskUseCase(fakeRepository)

    @Test
    fun `empty title returns failure`() = runTest {
        val result = addTask(title = "")
        assertTrue(result.isFailure)
    }

    @Test
    fun `valid title returns success with ID`() = runTest {
        val result = addTask(title = "Buy groceries")
        assertTrue(result.isSuccess)
        assertTrue(result.getOrNull()!! > 0)
    }

    @Test
    fun `title is trimmed before saving`() = runTest {
        addTask(title = "  Buy groceries  ")
        val tasks = fakeRepository.getAllTasks().first()
        assertEquals("Buy groceries", tasks.first().title)
    }

    @Test
    fun `title over 200 chars returns failure`() = runTest {
        val longTitle = "a".repeat(201)
        val result = addTask(title = longTitle)
        assertTrue(result.isFailure)
    }
}

Use cases are easy to test because they depend on an interface (TaskRepository), not a real database. Swap in a FakeTaskRepository and test the logic in isolation.

Common Mistakes

Mistake 1: Exposing Entities to the UI

// BAD — UI sees database types
class TaskViewModel(private val dao: TaskDao) {
    val tasks: Flow<List<TaskEntity>> = dao.getAllTasks()
}

// GOOD — UI sees clean domain models
class TaskViewModel(private val getTasks: GetTasksUseCase) {
    val tasks: Flow<List<Task>> = getTasks()
}

Mistake 2: Skipping the Mapper

// BAD — casting enum to String inline everywhere
val category = task.category.name  // Scattered across codebase

// GOOD — one mapper function, used everywhere
fun TaskEntity.toDomainModel(): Task { ... }
fun Task.toEntity(): TaskEntity { ... }

Mistake 3: Putting Validation in the ViewModel

// BAD — validation in ViewModel (not reusable)
class AddTaskViewModel {
    fun addTask(title: String) {
        if (title.isBlank()) { /* error */ }
        repository.addTask(...)
    }
}

// GOOD — validation in use case (reusable)
class AddTaskUseCase {
    operator fun invoke(title: String): Result<Long> {
        if (title.isBlank()) return Result.failure(...)
        return Result.success(repository.addTask(...))
    }
}

Mistake 4: Not Using @Binds for Interfaces

// BAD — unnecessary code
@Provides
fun provideRepository(dao: TaskDao): TaskRepository {
    return TaskRepositoryImpl(dao)
}

// GOOD — cleaner
@Binds
abstract fun bindRepository(impl: TaskRepositoryImpl): TaskRepository

Quick Reference

ComponentLayerPurpose
TaskEntityDataDatabase table definition
TaskDaoDataDatabase operations
AppDatabaseDataRoom database
TaskMapperDataEntity ↔ Model conversion
TaskRepositoryImplDataImplements TaskRepository
TaskDomainClean business model
TaskRepositoryDomainInterface (contract)
GetTasksUseCaseDomainGet tasks with filter/search
AddTaskUseCaseDomainValidate + add task
AppModuleDIHilt dependency wiring

Source Code

The complete working code for this tutorial is on GitHub:

View source code on GitHub →

What’s Next?

In the next tutorial, we will build the UI Layer — the task list screen, add task screen, and settings screen. We will connect them to the data layer through ViewModels using the MVI pattern.

See you there.