Skip to content
All posts
September 13, 20266 min read

Android Room Relations: One-to-Many and Many-to-Many

Learn how to model one‑to‑many and many‑to‑many relationships in Android Room with Kotlin, including entity definitions, relationship annotations, and query examples.

AndroidKotlinArchitecture
Share:

The Pain of Flat Data

When I started building a note‑taking app, I stored each note as an independent row and each comment as another row without any link between them. The result was a nightmare of manual joins in

code
LiveData
and endless
code
if
checks to match parent IDs. In a few weeks the code became a tangled web of raw SQL strings that no junior developer could follow. The solution? Embrace Room’s built‑in relationship support and let the ORM handle the joins for you.

Why Relationships Matter

Android apps rarely deal with isolated entities. A user creates multiple orders, a blog has many posts, and tags can be attached to several articles. Ignoring these connections forces you to:

  • Duplicate data across tables, inflating your database size.
  • Write and maintain complex raw queries that break when schema changes.
  • Lose the safety net of compile‑time checks that Room provides.

By defining relationships directly in the entities, you get type‑safe DAOs, automatic eager or lazy loading, and cleaner ViewModel code. The trade‑off is a bit of upfront design, but the payoff is a maintainable data layer that scales with your app.

One‑to‑Many Relationships

A one‑to‑many relationship is the most common pattern in Android apps. Think of a

code
User
entity that owns several
code
Post
entities. Room uses the
code
@ForeignKey
annotation to link the child table back to the parent.

Entity Definitions

kotlin
// User.kt
@Entity(tableName = "users")
data class User(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val name: String,
    val email: String
)

// Post.kt
@Entity(
    tableName = "posts",
    foreignKeys = [
        @ForeignKey(
            entity = User::class,
            parentColumns = ["id"],
            childColumns = ["userId"],
            onDelete = ForeignKey.CASCADE
        )
    ]
)
data class Post(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val title: String,
    val body: String,
    val userId: Long   // <-- foreign key column
)

DAO with Relationship Queries

Room provides two ways to access related data: eager (via

code
InnerJoin
) and lazy (via
code
Association
methods). The eager approach is handy when you need the parent in every row, while lazy loading keeps the query simple and lets you load the parent later.

kotlin
// PostDao.kt
@Dao
interface PostDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertPost(post: Post)

    @Query("SELECT * FROM posts WHERE userId = :userId")
    fun getPostsByUser(userId: Long): Flow<List<Post>>

    // Eager load: join posts with user in a single query
    @Query("""
        SELECT * FROM posts
        INNER JOIN users ON posts.userId = users.id
        WHERE posts.userId = :userId
    """)
    fun getPostsWithUser(userId: Long): Flow<List<PostWithUser>>
}

// Data class for the eager join result
data class PostWithUser(
    val postId: Long,
    val title: String,
    val body: String,
    val userId: Long,
    val userName: String,
    val userEmail: String
)

[!TIP] Use eager joins sparingly. They return denormalized rows that can be confusing to map. Prefer lazy loading (

code
getPostsByUser
) and fetch the
code
User
only when needed.

Database Setup

kotlin
// AppDatabase.kt
@Database(entities = [User::class, Post::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
    abstract fun postDao(): PostDao

    companion object {
        @Volatile private var INSTANCE: AppDatabase? = null

        fun getDatabase(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "app_db"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

Many‑to‑Many Relationships

A many‑to‑many relationship cannot be represented directly in a single table. You need a junction entity (also called an association table) that holds foreign keys to both sides. For example, an

code
Article
can have multiple
code
Tag
s, and a
code
Tag
can belong to many
code
Article
s.

Entity Definitions

kotlin
// Article.kt
@Entity(tableName = "articles")
data class Article(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val title: String,
    val content: String
)

// Tag.kt
@Entity(tableName = "tags")
data class Tag(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val name: String
)

// ArticleTagCrossRef.kt – the junction entity
@Entity(
    tableName = "article_tag",
    primaryKeys = ["articleId", "tagId"],
    foreignKeys = [
        @ForeignKey(
            entity = Article::class,
            parentColumns = ["id"],
            childColumns = ["articleId"],
            onDelete = ForeignKey.CASCADE
        ),
        @ForeignKey(
            entity = Tag::class,
            parentColumns = ["id"],
            childColumns = ["tagId"],
            onDelete = ForeignKey.CASCADE
        )
    ]
)
data class ArticleTagCrossRef(
    val articleId: Long,
    val tagId: Long
)

DAO for Many‑to‑Many Operations

With the cross‑ref table you can insert, delete, and query tags per article. Room also lets you define association methods that let you work with the related objects directly.

kotlin
// ArticleDao.kt
@Dao
interface ArticleDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertArticle(article: Article)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertTag(tag: Tag)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertCrossRef(ref: ArticleTagCrossRef)

    // Get all tags for a specific article
    @Query("SELECT * FROM tags WHERE id IN (SELECT tagId FROM article_tag WHERE articleId = :articleId)")
    fun getTagsForArticle(articleId: Long): Flow<List<Tag>>

    // Get all articles that use a given tag
    @Query("SELECT * FROM articles WHERE id IN (SELECT articleId FROM article_tag WHERE tagId = :tagId)")
    fun getArticlesForTag(tagId: Long): Flow<List<Article>>
}

[!NOTE] Composite primary keys in the junction entity guarantee that the same article‑tag pair cannot be inserted twice, which simplifies deduplication logic.

Example Usage in a Repository

kotlin
// ArticleRepository.kt
class ArticleRepository(private val dao: ArticleDao) {
    fun observeArticlesForTag(tagId: Long) = dao.getArticlesForTag(tagId)

    suspend fun addTagToArticle(articleId: Long, tag: Tag) {
        // First ensure the tag exists
        val existing = dao.getTagsForArticle(articleId).firstOrNull { it.name == tag.name }
        val tagId = existing?.id ?: run {
            dao.insertTag(tag)
            tag.id   // assuming auto‑generated id is returned
        }
        val crossRef = ArticleTagCrossRef(articleId, tagId)
        dao.insertCrossRef(crossRef)
    }
}

Querying and Using Relationships in ViewModels and UI

Once the data layer is ready, you can expose

code
StateFlow
or
code
LiveData
in a ViewModel that combines parent and child data. This keeps the UI stateless and reactive.

kotlin
// ArticleViewModel.kt
class ArticleViewModel(private val repository: ArticleRepository) : ViewModel() {
    private val _uiState = MutableStateFlow<ArticleUiState>(ArticleUiState.Loading)
    val uiState: StateFlow<ArticleUiState> = _uiState.asStateFlow()

    init {
        loadArticlesForTag(42)   // example tag ID
    }

    private fun loadArticlesForTag(tagId: Long) {
        viewModelScope.launch {
            repository.observeArticlesForTag(tagId)
                .collect { articles ->
                    _uiState.value = ArticleUiState.Success(articles)
                }
        }
    }
}

// ArticleUiState.kt
sealed class ArticleUiState {
    object Loading : ArticleUiState()
    data class Success(val articles: List<Article>) : ArticleUiState()
}

[!WARNING] Avoid loading all related data at once if a single parent has dozens of children. Use pagination or lazy loading in the UI to keep memory usage low.

Comparison: One‑to‑Many vs Many‑to‑Many

AspectOne‑to‑ManyMany‑to‑Many
TablesParent + Child (single FK)Parent + Child + Junction
Foreign KeysOne FK in child tableTwo FKs in junction table
Query ComplexitySimple
code
WHERE parentId = ?
Sub‑query or join across three tables
Data DuplicationNone (if using lazy loading)None (junction holds mapping)
Best Use CaseComments per post, orders per userTags per article, roles per user

Key Takeaways

  • Model relationships directly in Room entities using
    code
    @ForeignKey
    for one‑to‑many and a junction entity for many‑to‑many.
  • Prefer lazy loading (
    code
    WHERE userId = :id
    ) to keep queries simple and avoid inflated result sets.
  • Use composite primary keys in the junction table to enforce uniqueness and simplify deduplication logic.
  • Keep UI state reactive by exposing
    code
    StateFlow
    /
    code
    LiveData
    from a repository that abstracts the DAO layer, making testing and composition easier.
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this