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:

ActionBehavior on Delete/Update
CASCADEDeletes/updates child rows automatically when the parent row is deleted/updated.
SET NULLSets the foreign key column in child rows to NULL when the parent row is deleted/updated.
SET DEFAULTSets the foreign key column in child rows to its default value when the parent row is deleted/updated.
RESTRICTPrevents deletion or update of the parent row if child rows exist (throws an error).
NO ACTIONSimilar 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

ActionWhen to Use?
CASCADEWhen child records must be removed/updated along with the parent.
SET NULLWhen child records should remain but lose their reference to the parent.
SET DEFAULTWhen child records should be assigned a default foreign key value (useful for fallback behaviors).
RESTRICTWhen you want to prevent deletion or modification of a parent that still has child records.
NO ACTIONSimilar 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:

  1. You have small nested objects (e.g., Position, Address, Metadata).
  2. You don’t need a separate table for the object.
  3. You want to avoid TypeConverters for simple objects.
  4. The embedded object is always used with the parent (it has no independent existence).

When to Avoid it:

  1. The embedded object is large or frequently updated → Use a separate table.
  2. The object has relationships with other entities → Use @Relation instead.
  3. 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! 🚀

🔖 Next Article in this Series