In our previous article, we explored the key components of Room. Now, let’s take a deep dive into Room Entities, their importance, and the various ways to customize them.
Entities are the foundation of Room—they define how your data is stored in the database. Properly structuring your entity ensures efficient querying, maintainability, and scalability. Let’s break it down! 🛠️
🏗️ What is an Entity?
An Entity in Room represents a table in the database. Each instance of the entity corresponds to a row in the table, Room generates corresponding SQL table schema based on how you create an entity class.
Defining an Entity:
@Entity
data class LocalHabitTracker(
@PrimaryKey(autoGenerate = true) val id: Long,
val associatedHabitId: Long,
val positionX: Int,
val positionY: Int,
val note: String
)
Breaking It Down:
@Entity
tells Room that this class is a database table.@PrimaryKey
is used to uniquely identify each row.autoGenerate = true
ensures Room generates unique IDs automatically.@Ignore
→ Excludes a field from being stored in the database.
Before we procced further, Lets check out the sql query generated by the room
CREATE TABLE LocalHabitTracker (
id INTEGER PRIMARY KEY AUTOINCREMENT,
associatedHabitId INTEGER NOT NULL,
positionX INTEGER NOT NULL,
positionY INTEGER NOT NULL,
note TEXT NOT NULL
);
Since its a simple entity it might look workable but think of adding indexes, relations and other complicated relations it can quickly get complicated and prone errors. That being said this is just to show you how room is doing most of the heavy lifting for you, lets move on to customizing our entities.
🔄 Customizing Entities
Room provides several ways to customize entities to fit your data structure requirements. Let’s explore these!
1️⃣ Custom Table and Column Names
By default, Room uses the class name as the table name and variable names as column names. You can override this using annotations:
@Entity(tableName = "habit_tracker")
data class LocalHabitTracker(
@PrimaryKey(autoGenerate = true) val id: Long,
@ColumnInfo(name = "habit_id") val associatedHabitId: Long,
@ColumnInfo(name = "pos_x") val positionX: Int,
@ColumnInfo(name = "pos_y") val positionY: Int,
val note: String
)
- tableName changes the SQL table name while keeping the DataClass name as per your requiremnt.
- @ColumnInfo(name = “custom_name”) changes the column name in the table.
⚠️ These customizations are for the DataBase Tables which means when writing the query you must use the customized name for the query to work properly.
This is extremely useful when you want to Name your tables and columns differently, in kotlin we use camelcase but on sql its common to use underscore to split worlds like habit_tracker instead of LocalHabitTracker it simplifies the query and expedites debugging process.
Using custom name for table and column is optional so if you are comfortable with the existing name you can use just that, next up lets see how can we ensure the entities can be indexed faster.
2️⃣ Indexing for Faster Queries
Indexes speed up query performance, especially for large datasets. Use @Index for frequently queried columns, for example here the associated_habit_id
will be used most frequently by me in different queries so i am adding indices
property on the @Table
annotation, here is an example:
@Entity(
tableName = "habit_tracker",
indices = [Index(value = ["associated_habit_id"])]
)
data class LocalHabitTracker(
@PrimaryKey(autoGenerate = true) val id: Long,
@ColumnInfo(name = "associated_habit_id")val associatedHabitId: Long,
val positionX: Int,
val positionY: Int,
val note: String
)
- Indexing
habit_id
speeds up lookup queries on this column.
When Should You Use an Index?
✅ Use indexing if:
- The column is used frequently in WHERE, JOIN, or ORDER BY.
- The column is a foreign key linking to another table.
🚫 Avoid indexing if:
- The table is small (indexing overhead isn’t worth it).
- The column has many unique values (like id, which is already indexed as PRIMARY KEY).
What actually happens?
When you add an index to a column in Room, Room creates a separate data structure called a B-tree index. This makes queries that search for specific values in the indexed column much faster.
If you want to know more about B-tree Index
: check this out B-Tree Index
Now we have learned about indexing lets checkout how to ensure the columns stays unique
3️⃣ Unique Constraints
Preventing duplicate values on an indexed column is sometimes necessary and we can achieve that by setting the property unique = true
if the Index class, lets checkout an example to see how to apply this:
@Entity(
tableName = "habit_tracker",
indices = [
Index(value = ["entry_data"], unique = true),
Index(value = ["associated_habit_id"])
]
)
data class LocalHabitTracker(
@PrimaryKey(autoGenerate = true) val id: Long,
@ColumnInfo(name = "associated_habit_id")val associatedHabitId: Long,
val positionX: Int,
val positionY: Int,
val note: String,
@ColumnInfo(name = "entry_data")val entryDate: String
)
- Ensures
habit_id
remains unique across all rows.
The unique = true
ensures there is no duplicate value in that particular indexed row, this can be extremely helpful in some cases in our case we are building a habit tracking app where users log their habits daily. We want to ensure that a user cannot log the same habit more than once per day so this property help us to achieve that blissfully.
Lets move on to Keys and Relationships,i meant between the Entities 😜
4️⃣ Foreign Keys for Relationships (@ForeignKey)
Define relationships between tables using @ForeignKey
.
@Entity(
tableName = "habit_tracker",
foreignKeys = [
ForeignKey(
entity = Habit::class,
parentColumns = ["id"],
childColumns = ["habit_id"],
onDelete = ForeignKey.CASCADE
)
]
)
data class LocalHabitTracker(
@PrimaryKey(autoGenerate = true) val id: Long,
val habit_id: Long,
val positionX: Int,
val positionY: Int,
val note: String
)
🔷 habit_id references id from the Habit table. 🔷 onDelete = CASCADE ensures that deleting a Habit deletes all related LocalHabitTracker records.
The @ForeignKey
is a primary key of an associated entity in the context of current entity the associated entity is referred as parent entity, using @ForeignKey
help us to create relations and trigger changes depending on the event triggered, most commonly used trigger event is onDelete
the other one is onUpdate
the actions supported are as follows:
Supported Actions:
Action | Behavior on Delete/Update |
---|---|
CASCADE | Deletes/updates child rows automatically when the parent row is deleted/updated. |
SET NULL | Sets the foreign key column in child rows to NULL when the parent row is deleted/updated. |
SET DEFAULT | Sets the foreign key column in child rows to its default value when the parent row is deleted/updated. |
RESTRICT | Prevents deletion or update of the parent row if child rows exist (throws an error). |
NO ACTION | Similar to RESTRICT, but the check happens after the statement executes. |
This table should have given you a clear picture of what each action’s behavior and next lets checkout how to select them and when to use them effectively:
When to Use Each Action
Action | When to Use? |
---|---|
CASCADE | When child records must be removed/updated along with the parent. |
SET NULL | When child records should remain but lose their reference to the parent. |
SET DEFAULT | When child records should be assigned a default foreign key value (useful for fallback behaviors). |
RESTRICT | When you want to prevent deletion or modification of a parent that still has child records. |
NO ACTION | Similar to RESTRICT, but checks only after execution (rarely used). |
You can add any number of foreign keys so make use of it for simplifying and automate your preferred action when the parent entity is modified.
5️⃣ Embedded Objects (@Embedded)
Instead of creating separate tables, you can embed objects inside an entity, this does not mean that the table will hold your embedded object as is, it basically flattens the properties into the Entity, first lets see how to use @Embedded
:
data class Position(
val x: Int,
val y: Int
)
@Entity
data class LocalHabitTracker(
@PrimaryKey(autoGenerate = true) val id: Long,
val associatedHabitId: Long,
@Embedded val position: Position,
val note: String
)
- The Position object is embedded as separate columns (x, y) in LocalHabitTracker,
This helps with structuring data efficiently without creating a separate table for nested objects, It is primarily used to flatten objects into separate columns within the same table this helps us to avoid using TypeConverters
, extra Table, faster queries as we don’t have to deal with JOINS and other complications.
When to Use @Embedded:
- You have small nested objects (e.g., Position, Address, Metadata).
- You don’t need a separate table for the object.
- You want to avoid TypeConverters for simple objects.
- The embedded object is always used with the parent (it has no independent existence).
When to Avoid it:
- The embedded object is large or frequently updated → Use a separate table.
- The object has relationships with other entities → Use @Relation instead.
- The object needs to be referenced from multiple entities → Use Foreign Keys.
I hope this made sense, simply put the @Embedded
is only a output structure, while creating the SQL query room will flatten the properties into the entity table, Now if you want to store complex objects like lets say List event that is possible, lets see how to achieve it.
6️⃣ Storing Objects as is (TypeConverters)
Room only supports primitive data types (Int, Long, String, Boolean, etc.) by default. If you want to store complex data types (e.g., List, Date, Enum), you need Type Converters to convert them into a format Room understands ie String, lets look at an example of @TypeConverter:
import androidx.room.TypeConverter
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.util.Date
class Converters {
// Convert Date -> Long (for storage)
@TypeConverter
fun fromDate(date: Date?): Long? {
return date?.time
}
// Convert Long -> Date (for retrieval)
@TypeConverter
fun toDate(timestamp: Long?): Date? {
return timestamp?.let { Date(it) }
}
// Convert List<String> -> JSON String (for storage)
@TypeConverter
fun fromStringList(list: List<String>?): String {
return Gson().toJson(list)
}
// Convert JSON String -> List<String> (for retrieval)
@TypeConverter
fun toStringList(json: String?): List<String> {
return Gson().fromJson(json, object : TypeToken<List<String>>() {}.type)
}
}
Once a converter is defined it must be added to the DataBase class,here is an example:
@Database(entities = [User::class], version = 1)
@TypeConverters(Converters::class) // Register converters
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
now you can add it to the entity by annotating it with the the @TypeConverter
, here is an example for it:
@Entity(tableName = "users")
data class User(
@PrimaryKey(autoGenerate = true) val id: Int,
val name: String,
@TypeConverters(Converters::class) // Apply TypeConverter here
val hobbies: List<String>
)
The best use-case for this is when you want to store Enums, Dates and List of items, do note the values in the object will be stored but cannot be queried which means you can’t search, sort or filter the values so ensure you use them only when absolutely need to like for Storing simple objects (e.g., Address
, Date
, Enum
)
These cover the most important aspects of the @Entity
and its properties and how to use them effectively, if you think i have missed any please feel free to add comment and ill definitely add them here so it can be helpful for me and others, before you go lets check the best practices.
🔍 Best Practices
✅ Use autoGenerate = true
for @PrimaryKey
to avoid conflicts if you don’t have a server that provides it.
✅ Optimize query performance with @Index
.
✅ Define @ForeignKey
relationships to maintain integrity but optional for complex cases.
✅ Use @Ignore
for transient fields that shouldn’t be stored in the database.
✅ Keep entity classes small and focused—avoid unnecessary logic inside them.
✅ Ensure Proper Indexing: Index frequently queried columns to improve performance.
✅ Use Primitive Types: Room doesn’t support custom objects directly. Use @TypeConverter
if needed.
🚀 Conclusion
Room Entities form the foundation of Android’s database layer, allowing structured and efficient data management. By following best practices, you ensure scalable and maintainable database architectures.
Next we will explore Data Access Objects, Stay tuned for the next article in this series! 🚀
Final Thoughts
This is my journey in building an offline-first app. I’d love to hear your feedback, suggestions, or questions!
Feel free to connect with me on:
📩 Email
🌍 Website
💫 LinkedIn-Post for comments and feedbacks
🔖 Previous Article in this Series 🚀 Stay tuned for Part 5! 🚀