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) | |
|---|---|---|
| Purpose | Database storage | App logic |
| Types | String, Int, Long | Category, Priority enums |
| Annotations | @Entity, @PrimaryKey | None |
| Changes when | Database schema changes | Business rules change |
| Used by | Room DAO only | ViewModels, 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:
- Receives
TaskDaovia Hilt injection - Calls DAO methods
- Converts entities to domain models using the mapper
- Returns
Flow<List<Task>>(notFlow<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 Cases | With Use Cases |
|---|---|
| ViewModel calls repository directly | ViewModel calls use case |
| Validation logic in ViewModel | Validation in use case |
| Same logic duplicated in multiple ViewModels | Shared through use cases |
| Hard to test business rules | Easy 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
| Component | Layer | Purpose |
|---|---|---|
TaskEntity | Data | Database table definition |
TaskDao | Data | Database operations |
AppDatabase | Data | Room database |
TaskMapper | Data | Entity ↔ Model conversion |
TaskRepositoryImpl | Data | Implements TaskRepository |
Task | Domain | Clean business model |
TaskRepository | Domain | Interface (contract) |
GetTasksUseCase | Domain | Get tasks with filter/search |
AddTaskUseCase | Domain | Validate + add task |
AppModule | DI | Hilt dependency wiring |
Source Code
The complete working code for this tutorial is on GitHub:
Related Tutorials
- Tutorial #13: Room — Room basics we build on
- Tutorial #14: Hilt — Hilt setup we use for DI
- Tutorial #21: App Planning — the architecture we follow
- Jetpack Compose Cheat Sheet — quick reference for every component and modifier.
- Full Series: Jetpack Compose Tutorial — all 25 tutorials from zero to publishing.
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.