This is the second part in a series of tutorials about Room ORM for Android (part 1). In this article, I’ll show you how to decouple Room related logic from the rest of your application and explain the benefits and the drawbacks of this approach.
In a nutshell, my approach to Room integration can be summarized as “there should be only one module in the project that depends on Room, and it shouldn’t be the ‘main’ module”. Let me guide you through the steps towards this state.
Modularization Will Save the World
First, I extract a new standalone module called database
. Then I add all Room related dependencies into its build.gradle
file:
implementation "androidx.room:room-runtime:2.6.0" ksp "androidx.room:room-compiler:2.6.0" // kapt "androidx.room:room-compiler:2.6.0" // use this instead of ksp if you haven't migrated to KSP yet // apt "androidx.room:room-compiler:2.6.0" // use this in Java projects implementation "androidx.room:room-ktx:2.6.0" // needed if you use Coroutines
Just like in the first part of this tutorial, I define a simple Room entity to use as an example (unless stated otherwise, all the classes that I mention in this post go into database
module):
@Entity(tableName = "appMemoryInfo") data class AppMemoryInfo( @field:ColumnInfo(name = "id") @PrimaryKey(autoGenerate = true) val id: Long, @field:ColumnInfo(name = "consumedMemory") val consumedMemory: Float, )
Then add my subclass of RoomDatabase
, which already references AppMemoryInfoDao
:
@Database( entities = [AppMemoryInfo::class], version = 1 ) abstract class MyRoomDatabase : RoomDatabase() { abstract val appMemoryInfoDao: AppMemoryInfoDao }
The declaration of AppMemoryInfoDao
:
@Dao interface AppMemoryInfoDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(entity: AppMemoryInfo) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(entities: List<AppMemoryInfo>) @Query("SELECT * FROM appMemoryInfo") suspend fun fetchAll(): List<AppMemoryInfo> @Query("SELECT * FROM appMemoryInfo WHERE id = :id") suspend fun fetchById(id: Long): AppMemoryInfo @Query("DELETE FROM appMemoryInfo") suspend fun deleteAll() }
And that’s it! Now I have Room set up in a standalone module, so I’m done, right? Well, not so fast.
While I indeed transferred all Room related classes into database
module, any other module that would like to use MyRoomDatabase
class would still need to declare Room as a dependency. This violates the goal of having Room fully encapsulated in one module, so let’s keep going.
Hiding Room Classes from the External World
Now I’m going to create a wrapper for MyRoomDatabase
and call it MyDatabase
class:
class MyDatabase(context: Context) { private val myRoomDatabase: MyRoomDatabase init { myRoomDatabase = Room.databaseBuilder( context, MyRoomDatabase::class.java, DatabaseConstants.DATABASE_NAME ).build() } val appMemoryInfoDao get() = myRoomDatabase.appMemoryInfoDao }
Note that MyDatabase
doesn’t extend any of Room’s classes. This means that external clients can use MyDatabase
just like they’d use MyRoomDatabase
, but now they don’t need to depend on Room:
myDatabase.appMemoryInfoDao.upsert(AppMemoryInfo(0, consumedMemory))
Intuitively, you might say: “Wait a second! External clients need to use AppMemoryInfo
and AppMemoryInfoDao
classes, right? Both of them carry Room annotations, so the clients would still need to know something about Room!”. In practice, that’s not the case. These annotations don’t require compile-time dependencies and, since Room does all its magic at compile time, you won’t need to use these annotations at runtime in any way (you also can’t).
All in all, this simple trick with MyDatabase wrapper allowed us to encapsulate Room dependencies inside one module. You can even make MyRoomDatabase
internal
class.
Benefits of Encapsulated Room Dependency
I found the following benefits in the approach described in this article:
- Room is a KSP plugin (and an annotation processor), so, by encapsulating it in a module, I might avoid the need to use KSP in other (client) modules. Even if I can’t get rid of KSP in client modules due to other plugins, I’m still happy with having Room out of the picture there.
- Having all database related logic in one module makes my codebase safer. Now I know that if there are any changes in that module, they’re related to the database. In that case, I need to be very careful and review the changes for e.g. potential migration requirements.
- In larger projects, database related code can grow considerably. By placing it in a standalone module I can potentially make my builds faster.
Drawbacks of Encapsulated Room Dependency
As with every architectural decision, there are also drawbacks that need to be taken into account:
- Extracting standalone module for Room slightly increases the complexity of the project during the initial stages of development. As the codebase matures and grows, the additional complexity will become less pronounced.
- Since I put all Room entity classes inside
database
module, this module will have semantic dependency on all other app’s features that use database. This might makedatabase
module pretty big and also goes against the popular “module per feature” paradigm. - While not strictly necessary, this approach calls for Room entities inside
database
module to be used for serialization and deserialization exclusively. In other words, it would be an unfortunate idea to useAppMemoryInfo
data class in your app’s presentation logic. Instead, I usually define additional set of data structures for the general use in the app. This, of course, requires more effort and can lead to some confusion.
In the context of the third drawback I want to say this: while this approach indeed requires more work, I don’t consider the this drawback a “real drawback”. In fact, I consider it to be almost a best practice.
See, when you use the same class to define the database schema and also for a general use in the app, sooner or later, these two use cases will diverge. At that point, you’ll need to either create some Frankenstein data structure to accommodate both use cases (which will kill you eventually), or refactor to two data structures. I prefer to just bite the bullet right away and go with two sets of data structures from the onset. It adds work and looks like a waste initially, but, in my experience, pays for itself relatively quickly (several months).
Conclusion
The approach to Room integration I described in this article is simple and doesn’t require special skills. As such, this discussion might seem as a minor detail, but don’t be misled by its simplicity. In my experience, this aspect of Room integration is a very consequential architectural decision. So, think about that carefully and make an educated decision.
I use this method of Room integration in my open-sourced TechYourChance app. At the time of this writing, there is not much database related code there, but you can still see all the relevant pieces at work.
With that, thank you for reading and please consider subscribing to my email list if you liked this article.
Thanks for the article. As usual, very good content presented superbly!
Taking into account your previous articles (particularly this one about project package structures – https://www.techyourchance.com/popular-package-structures/), I’m a bit surprised you prefer having Room (or any other API) encapsulated in a module, since that reminds me to “Package by Layer”, which clashes with “Package by Feature”. If I’m not wrong, the latter was your preferred option when organising your projects.
The current encapsulation approach feels a bit weird, since, as you’ve said, the module can grow enormously and it will get rebuilt probably at all times, since any module change could potentially affect it.
So, could you please elaborate a bit more about your decision or maybe talk about an actual project you’ve found it useful for?
Hey,
Yes, this is indeed “modularize by layer” approach. As I wrote in the article, one of the drawbacks is that this layout will make it harder to implement full “modularize by feature” approach. It’s a trade-off. So far, it worked alright for me.
As for rebuilding, it’s the other way around. Since this module contains just the database-related stuff, it’ll need to be rebuilt only when database changes. I assume that this should be relatively rare occurence in a mature Android project, so, most of the time, this layout will give build time advantage.