From Junior to Senior: Modularization in Clean Architecture Android projects

Morgane Soula
8 min readJul 4, 2024

--

This article follows on from my previous one The real way to implement Clean Architecture in Android.

Once again, thank you, Guillaume, for taking the time to review this article and to give me your insights. It is really much appreciated!

In that article, we used two dummy projects “Note app” to explore different aspects of Clean Architecture in Android. This time, we will discuss the modularization of a Clean Architecture project using my personal app HobbyMatchMaker. We will explore the modularization of the authentication process, shared between three modules: authentication, login, and session.

Chop-chop.

(Source: giphy.com)

Why You Should Modularize Your Project

Because your project is a puzzle, made up of multiple pieces that fit together. The magic? It is an infinite jigsaw puzzle. Modularization allows you to break your project into reusable pieces, that can be injected whenever needed. For example, if you want to display a list of movies to a user. The user could favorite any of these movies. You could then display their favorite movies on both their home screen and profil screen using the same piece of code and the same list of movies from one source only.

Authentication in mobile apps generally follows a similar process. The modules I have coded for my project can definitely be reused in any other project. Want to add Twitter as an authentication method? No problem. Just add it to the data module of the parent authentication module, and you’re good to go.

As for Clean Architecture, modularization allows you to develop, reuse, maintain and, test each part of your project independently. It also encourages the separation of concerns. By following these principles, your application will be easier to test and easier to add new features to.

What We Will Develop

(Sign in screen — Sign up screen)

As usual, this is my stack: Kotlin 1.9.22, Android Studio Koala, Jetpack Compose 1.6.8, Hilt 2.50 and Firebase 33.1.0

To authenticate my users, I have decided to use the good old email/password method, Google Sign-In, and Meta (aka Facebook). I am using Firebase Authentication as my backend. I am aware that Firebase no longer recommends using email/password, but I want to be inclusive, as not everyone should be forced to use social media.

As mentioned above, the authentication process is divided between three modules (all under the core parent module):

  • Authentication (data & domain): Handles the network calls needed for authentication
  • Login (domain & presentation): Manages the UI for sign-in, sign-up, reset password, etc…
  • Session (data & domain): Responsible for storing and retrieving session data such as user ID, user email, tokens, etc…

Let’s start with the UI !

Parent Module: Login

The login module is divided into two submodules: domain and presentation.

// From core.login.domain.use_cases.ValidateEmailUseCase

operator fun invoke(email: String): ValidationResult {
return if (email.matches("^[A-Za-z](.*)(@)(.+)(\\.)(.+)$".toRegex())) {
ValidationResult(true)
} else {
ValidationResult(false)
}
}

I have several use cases to check my business logic. In my case, the domain layer is used to validate the input of the forms.

Presentation has two main packages: sign-in and sign-up. Each package contains the screen and its associated ViewModel.

// from core.login.presentation.sign_up

@OptIn(FlowPreview::class)
@HiltViewModel
class SignUpViewModel @Inject constructor(
private val loginValidateFormUseCase: LoginValidateFormUseCase,
private val signUpUseCase: SignUpUseCase,
private val ioDispatcher: CoroutineDispatcher,
private val resourceProvider: StringResourcesProvider,
private val savedStateHandle: SavedStateHandle,
private val signUpNavigation: SignUpNavigation
) : ViewModel() {

// content

}

For the sign-up part, I handle the user’s input with my use cases mentioned above. Once the form is validated, I submit it using SignUpUseCase. SignUpUseCase is defined in the authentication module as it makes a network call through Firebase.

// from core.login.presentation.sign_up.SignUpViewModel

signUpUseCase(
formDataFlow.value.email,
formDataFlow.value.password
)
.onEach {
viewModelScope.launch {
abortCircularProgress()
}
}
.mapSuccess {
savedStateHandle.clearAll<SignUpStateModel>()
signUpNavigation.redirectToAppScreen()
}
.mapError { error ->
Log.e("HMM", "Could not create an account with error: $error")
val errorMessageToDisplay: String = when (error) {
is CreateUserError.EmailAlreadyExists -> resourceProvider.getString(
R.string.email_already_exists_error
)
is CreateUserError.UserDisabled -> resourceProvider.getString(
R.string.user_disabled_error
)
is CreateUserError.TooManyRequests -> resourceProvider.getString(
R.string.too_many_requests_error
)
is CreateUserError.InternalError -> resourceProvider.getString(
R.string.internal_error
)

else -> error.message
}

updateFormState { it.copy(signUpError = errorMessageToDisplay) }
error
}

Once I have the result of the call, I either navigate to the Main Screen of my application or update my UI with a snackbar displaying the tailed error message.

For the sign-in other part, I handle three login sources: email/password, Meta, and Google.

    // from core.login.presentation.sign_in.SignInScreen

val facebookLauncher =
rememberLauncherForActivityResult(
loginManager.createLogInActivityResultContract(callBackManager, null)
) {}

RegisterFacebookCallback(
loginManager = loginManager,
callBackManager = callBackManager,
coroutineScope = coroutineScope,
handleFacebookAccessToken = handleFacebookAccessToken,
context = context
)

For Meta, I first retrieve the credentials in my UI using a callback.

// from app.MainActivity

private val googleAuthClient = GoogleAuthClient(CredentialManager.create(this), this)

// from core.login.presentation.sign_in.SignInScreen

SocialMediaRowCustom(
onFacebookButtonClicked = {
facebookLauncher.launch(listOf("email", "public_profile"))
},
onGoogleButtonClicked = {
scope.launch {
val credential = googleAuthClient.launchGetCredential()
handleGoogleSignIn(credential, googleAuthClient)
}
}
)

For Google, I initialize my GoogleAuthClient in my MainActivity and use this client to call launchGetCredential to get the credentials.

// from core.login.presentation.sign_in.SignInViewModel

private fun handleSocialMediaLogin(
credential: AuthCredential,
onFailureEvent: (String) -> AuthenticationEvent,
errorMessage: String
) {
viewModelScope.launch(ioDispatcher) {
loginWithSocialMediaUseCase(credential)
.onEach {
viewModelScope.launch {
abortCircularProgress()
}
}
.mapSuccess {
saveIsConnected()
}
.mapError { error ->
Log.e("HMM", errorMessage)
viewModelScope.launch {
sendEvent(onFailureEvent(error.message))
}
error
}
}
}

Once I have these credentials, I use the appropriate use case from the authentication module. For email/password, the workflow is very similar to sign-up. Depending on the network response, I either redirect to the Main Screen or use a Channel to display an error. Now, let’s dive into the authentication module.

Parent Module: Authentication

The authentication module is divided into two submodules: data and domain.

I have several functions in my domain repository such as login (with credentials and with email/password), register, reset password, etc… These methods are called from the UI described above.

  // from core.authentication.data.data_sources.remote

override suspend fun signInWithEmailAndPassword(
email: String,
password: String
): Result<Boolean> {
return try {
auth.signInWithEmailAndPassword(email, password).await()
Result.Success(true)
} catch (e: Exception) {
Log.e("HMM", "Exception caught while logging user: ${e.message}")
if (e is FirebaseAuthException) {
when (e.errorCode) {
"ERROR_USER_DISABLED" -> Result.Failure(SignInError.UserDisabled)
"ERROR_USER_NOT_FOUND" -> Result.Failure(SignInError.UserNotFound)
"ERROR_WRONG_PASSWORD" -> Result.Failure(SignInError.WrongPassword)
"ERROR_TOO_MANY_REQUESTS" -> Result.Failure(SignInError.TooManyRequests)
else -> Result.Failure(SignInError.Other(""))
}
} else {
Result.Failure(SignInError.Other("Unexpected error with error message: ${e.message}"))
}
}
}

The authentication data module only handles the remote sources. I call the correct method from the Firebase instance signInWithEmailAndPassword for email/password and signInWithCredentials for Google and Meta. If the network returns an error, I catch it and update my returning Result according to the errorCode.

And… that’s it! Simple, right?

We interact with our app, we make our network calls, we update the UI, and then we store our data needed by the app. And that’s exactly what the next module does: session.

Parent module: Session

The session module is divided into two submodules: data and domain. This module is crucial for retrieving any authentication information across the app, especially for features — like a Settings screen.

// from core.session.domain.models.SessionUserDomainModel

data class SessionUserDomainModel(
val email: String,
val connexionMode: SessionConnexionModeDomainModel
)

// from core.session.domain.repositories.SessionRepository

class SessionRepository(private val sessionLocalDataSource: SessionLocalDataSource) {
suspend fun saveUser(user: SessionUserDomainModel) =
sessionLocalDataSource.saveUser(user)

suspend fun clearSessionData() =
sessionLocalDataSource.clearSessionData()

suspend fun setIsConnected(isConnected: Boolean) =
sessionLocalDataSource.setIsConnected(isConnected)

fun observeIsConnected(): Flow<Boolean> =
sessionLocalDataSource.observeIsConnected()

fun getConnexionMode(): Flow<String?> =
sessionLocalDataSource.getConnexionMode()
}

In the domain layer, the repository has methods like saveUser and retrieveUser. You may notice that these methods focus on local data operations only.

  // from core.session.data.data_sources.local.SessionLocalDataSourceImpl

override suspend fun saveUser(user: SessionUserDomainModel) {
dataStore.edit { preferences ->
preferences[EMAIL_KEY] = user.email
preferences[CONNEXION_MODE] = user.connexionMode.name
}
}

// Other local methods

In the data layer, I implement the methods from the domain’s SessionLocalDataSource.

We now know how our three modules interact with each other. Let’s see how to make them interact with the rest of the app.

Parent Module: App

We have two key points to address.

  1. Checking Connexion Status on App Launch
  // from app.presentation.AppViewModel

val authenticationState: StateFlow<AuthUiStateModel> by lazy {
observeAuthenticationStateUseCase()
.mapLatest { isConnected ->
if (isConnected) AuthUiStateModel.IsConnected else AuthUiStateModel.NotConnected
}
.flowOn(Dispatchers.Main)
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
AuthUiStateModel.CheckingState
)
}

// from core.session.data.data_sources.local.SessionLocalDataSourceImpl

override fun observeIsConnected(): Flow<Boolean> = dataStore.data
.catch { exception ->
if (exception is IOException) emit(emptyPreferences())
else throw exception
}
.map { preferences ->
preferences[IS_CONNECTED_KEY] ?: false
}

In AppViewModel (from the app module), we observe the connexion status stored in the local data source through observeIsConnected.

// extract from the method signIn() in core.login.presentation.sign_in.SignInViewModel

viewModelScope.launch(ioDispatcher) {
signInUseCase(formDataFlow.value.email, formDataFlow.value.password)
.onEach {
viewModelScope.launch {
abortCircularProgress()
}
}
.mapSuccess {
----> setIsConnected(true) <---- only on success, we save our connexion status
clearFormState()
withContext(Dispatchers.Main) {
redirectToAppScreen()
}
}
}

private suspend fun setIsConnected(isConnected: Boolean) {
setIsConnectedUseCase(isConnected)
}

// from core.session.domain.repositories

suspend fun setIsConnected(isConnected: Boolean) =
sessionLocalDataSource.setIsConnected(isConnected)

// from core.session.data.data_sources.local.SessionLocalDataSourceImpl

override suspend fun setIsConnected(isConnected: Boolean) {
dataStore.edit { preferences ->
preferences[IS_CONNECTED_KEY] = isConnected
}
}

I have a waiting state called CheckingState: whenever we update IS_CONNECTED_KEY, any listener to the authenticationState variable is triggered.

 // from app.navigation.HobbyMatchMakerNavHost

val authenticationState by appViewModel.authenticationState.collectAsStateWithLifecycle()

val startDestination = when (authenticationState) {
is AuthUiStateModel.CheckingState -> SplashScreenRoute.ROUTE
is AuthUiStateModel.NotConnected -> SignInScreenRoute.ROUTE
is AuthUiStateModel.IsConnected -> AppScreenRoute.ROUTE
}

Finally, in the NavHost, the startDestination is defined according to the connexion status. Once we launch the app, while we are checking observeIsConnected we display a SplashScreen. And once the authenticationState is updated, the navHost either redirect to the SignIn screen or App screen.

2. Determining the Connexion Mode for Logout

// from app.MainActivity

private fun setAuthenticationListener() {
authStateListener = AuthStateListener { firebaseAuth ->
val user = firebaseAuth.currentUser

user?.let { firebaseUser ->
val email = firebaseUser.email

val providers = firebaseUser.providerData.map { it.providerId }

val connexionMode = when {
providers.contains("google.com") -> ConnexionMode.GOOGLE
providers.contains("facebook.com") -> ConnexionMode.FACEBOOK
else -> ConnexionMode.EMAIL
}

lifecycleScope.launch(Dispatchers.IO) {
saveUserUseCase(UserDomainModel(email ?: "", connexionMode))
}
} ?: run {
lifecycleScope.launch(Dispatchers.IO) {
clearUserUseCase()
}

Log.d("HMM", "User is not connected")
}
}
}

// from core.session.domain.repositories

suspend fun saveUser(user: SessionUserDomainModel) =
sessionLocalDataSource.saveUser(user)

In MainActivity, we define the Firebase listener in the onCreate function. Do not forget to remove the listener in the onStop function. Firebase is reactive, and once we used any sign-in method, the listener is triggered. The User’s Firebase object has a list of providers. Once we get the right provider, we update the local data source.

// from app.presentation.AppViewModel

fun logOut() {
viewModelScope.launch(ioDispatcher) {
val connexionMode = getConnexionModeUseCase().first()
logOutUseCase(connexionMode ?: "EMAIL").mapSuccess {
setIsConnectedUseCase(false)
}
}
}

// from core.authentication.domain.repositories.AuthenticationRepository

suspend fun logOut(connexionMode: String): Result<Boolean> {
return try {
when (connexionMode) {
"FACEBOOK" -> remoteDataSource.loginManagerSignOut()
"GOOGLE" -> remoteDataSource.credentialManagerLogOut()
else -> remoteDataSource.authenticationSignOut()
}
Result.Success(true)
} catch (exception: Exception) {
Result.Failure(LogOutError(message = exception.message ?: "Error while logging out"))
}
}

Then we can retrieve the connectionMode stored in the session module, in order to apply the proper action, as, for instance, Google sign-ins don’t require any disconnection call if the user logged in with Facebook.

(source: giphy.com)

You made it to the end! By now, you should have a clear and solid understanding of how modularization enhances a Clean Architecture Android project. We’ve seen how clear separation of concerns within modules — such as session management and authentication — facilitates maintainability and scalability. Feel free to ask any questions, and I’ll do my best to answer them :)

You can find the whole project on my Github (do not forget to navigate through the branches).

Let’s connect on LinkedIn if you want to see more of this project or my work!

As always, keep coding, exploring new topics and, most importantly, keep enjoying it!

--

--