Visualizing a Reactive Android Application Through Modular Architecture
Designing a modular Android application can yield numerous benefits: improved build times, structured code separation, and enhanced scalability. As we venture through each module, we’ll relate it to our architectural image for a cohesive understanding, and we’ll mention the dependency of each module to better understand the graph and unidirectional dependency.
1. The Domain Module: Center of the World
At our architectural core lies the domain module, emblematic of the app’s business logic.
Module: :domain
Example:
// Entity
data class User(val id: Int, val name: String)
// UseCase
class GetUserUseCase(private val userRepository: UserRepository) {
suspend fun execute(userId: Int): User {
return userRepository.getUser(userId)
}
}
build.gradle.kts:
dependencies { //… other common dependencies}
2. Data Module: Enveloping the Domain
Next, our image reflects the data layer around the domain, harmonizing data from various sources.
Module: :data
class UserRepository @Inject constructor(
private val userLocalDataSource: UserLocalDataSource,
private val userRemoteDataSource: UserRemoteDataSource
) {
suspend fun getUser(userId: Int): User {
// Logic to decide whether to fetch from local or remote source
}
}
build.gradle.kts:
dependencies {
implementation(project(“:domain”))
implementation(project(“:datasource”))
implementation(project(“:network”))
}
3. DataSource Module: Storage and Retrieval
Further out, our image depicts data sources that facilitate actual storage interactions.
Module: :datasource
class UserLocalDataSource @Inject constructor(private val userDao: UserDao) : LocalDataSource {
override suspend fun getUser(id: Int) = userDao.getUser(id)
}
build.gradle.kts:
dependencies {
// Dependencies for Room and other local storage utilities
}
Let’s get back to data to expose our local data source functions, now data become agnostic from how data source work.
Module: :data
interface LocalDataSource { suspend fun getUserLocal(userId: Int): User? suspend fun saveUserLocal(user: User) // … any other local data functions }
4. Network Module: The Web Beyond
Approaching the image’s boundary, the network module manages our external data interactions.
Module: :network
// Retrofit setup
val retrofit = Retrofit.Builder()
.baseUrl(“https://api.example.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
interface UserApi {
@GET(“users/{id}”)
suspend fun getUser(@Path(“id”) id: Int): User
}
class UserRemoteDataSource @Inject constructor(private val userApi: UserApi) : RemoteDataSource {
override suspend fun getUser(id: Int) = userApi.getUser(id)
}
build.gradle.kts:
dependencies {
// Retrofit and other network dependencies
}
Let’s get back to data to expose our remote function call, with that interface, data module create abstraction and does not need to know about the actual remote data source, how it work and what is it as library: retrofit or something else.
Module: :data
interface RemoteDataSource { suspend fun getUserRemote(userId: Int): User? // … any other remote data functions }
5. Analytics Module: Gateways to Understanding
Sitting within our image’s “Gateways” layer, the analytics module serves as an interface to log and monitor app events.
Analytics module will depends to domain module to expose an interface that will be accessible for any other module, especially UI module that need to Log some events into the viewModel. So let’s get back to the domain module and add the interface, and the useCase consuming it.
In fact, at first domain need to depend to analytics because it is the core of our application, but this dependency will create a strong coupling, and break the dependency rule that external layer should depend only the inner layer.
That’s why we will inverse the dependency by exposing in interface into domain layer, and analytics module, does not matter which library it uses, will depends on domain and implements the domain interface.
Module: :analytics
Example:
class AnalyticsServiceImpl @Inject constructor(private val analyticsSdk: AnalyticsSdk) : AnalyticsService {
override fun logEvent(event: String) {
analyticsSdk.logEvent(event)
}
}
build.gradle.kts:
dependencies {
implementation(project(":domain"))
// External SDK or library for analytics
}
Module: :domain
interface AnalyticsService {
fun logEvent(event: String)
}
usecase:
// UseCase
class LogUseCase(private val analyticsService: AnalyticsService) {
suspend fun execute(event: String) {
return analyticsService.log(event)
}
}
6. Presentation Module: User’s Touchpoint
The outermost layer in our image embodies the UI, responding to user inputs and data transformations.
Module: :ui
@HiltViewModel
class UserViewModel @Inject constructor(
private val getUserUseCase: GetUserUseCase,
private val analyticsService: AnalyticsService
) : ViewModel() {
private val _state = MutableStateFlow<User?>(null)
val state: StateFlow<User?> get() = _state
fun getUser(userId: Int) {
viewModelScope.launch {
_state.value = getUserUseCase.execute(userId)
analyticsService.logEvent(“User data fetched for ID $userId”)
}
}
}
build.gradle.kts:
dependencies {
implementation(project(“:domain”))
//… Jetpack Compose and other UI dependencies
}
Conclusion
Mapping modules to our architectural image offers clarity, tying together abstract design and concrete code. Through a journey from core entities to the user interface, interspersed with external interactions and understanding via analytics, this architectural visualization fosters effective development and collaboration.