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.
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.
On this page
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
LiveDataifAndroid 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:
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.
A one‑to‑many relationship is the most common pattern in Android apps. Think of a
UserPost@ForeignKey// 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
)Room provides two ways to access related data: eager (via
InnerJoinAssociation// 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 (
) and fetch thecodegetPostsByUseronly when needed.codeUser
// 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
}
}
}
}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
ArticleTagTagArticle// 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
)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.
// 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.
// 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)
}
}Once the data layer is ready, you can expose
StateFlowLiveData// 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.
| Aspect | One‑to‑Many | Many‑to‑Many |
|---|---|---|
| Tables | Parent + Child (single FK) | Parent + Child + Junction |
| Foreign Keys | One FK in child table | Two FKs in junction table |
| Query Complexity | Simple code | Sub‑query or join across three tables |
| Data Duplication | None (if using lazy loading) | None (junction holds mapping) |
| Best Use Case | Comments per post, orders per user | Tags per article, roles per user |
@ForeignKeyWHERE userId = :idStateFlowLiveDataSudarshan 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.
Related Posts
Related Apps
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 meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read