Explore the Advantages of Android Clean Architecture

Would you rather add a new function to a well-functioning app with a terrible design, or fix a bug in a well-designed but flawed Android app? The second option would be my choice. Adding even a small new feature can be quite challenging in an app with a lot of class dependencies. I recall a time when a project manager on one of my Android projects requested that I include a minor feature—downloading data and showing it on a new screen. The app, which was created by a former coworker, should have been simple to update with the feature in less than half a day. I was very hopeful…

Seven hours of figuring out how the app worked, what modules it contained, and how they interacted with one another, and I had a few trial implementations of the feature. It was a nightmare. The login screen had to undergo a significant redesign after a minor modification to the data model. Almost every screen and the GodOnlyKnowsWhatThisClassDoes class needed to have their implementation changed in order to add a network request. Weird behavior occurred while saving data to the database, as well as total app crashes, when button colors were changed. I told my project manager the following day, “We have two options for putting the feature into practice. First, I can work on it for three more days and eventually implement it in a very messy way, which will cause the implementation time for every subsequent feature or bug fix to increase exponentially. Alternatively, I can rewrite the app. This will take me two to three weeks, but it will save us time in the long run when we need to make changes to the app. He thankfully agreed to the second option. If I ever had any questions about the value of good software architecture in an app, even a very small one, this one completely dispelled them. But what Android architecture pattern ought we to employ to prevent such issues?

This article will demonstrate a clean architecture example in an Android app. The fundamental components of this pattern, however, are adaptable to any language or platform. A sound architecture should be platform-, language-, database system-, input-, and output-independent.

Example App

We will make a straightforward Android app to log our location, and it will have the features listed below:

  • The user has the option to open an account using a name.
  • The user can change the account name.
  • The user has the option of deleting the account.
  • The user can choose which account is active.
  • The user has the option of saving a location.
  • The user can view a list of locations associated with a user.
  • Users can be viewed in a list by the user.

Clean Architecture

The foundation of a clean architecture is its layers. In our app, we’ll employ three levels: presentation, domain, and model. Each layer ought to be distinct and oblivious to the others. It should be able to exist on its own and share a small interface for communication at most.

Layer responsibilities:

  • Domain: Includes the business guidelines for our app. It ought to offer use cases that reflect the functionality of our app.
  • Presentation: Presents data to the user and gathers information such as the username. This is a type of input/output.
  • Model: Gives our app data. It is in charge of gathering data from outside sources and storing it in a database, cloud server, etc.

Which layers ought to be aware of the others? The easiest way to find the answer is to consider changes. Let’s consider the presentation layer, where we’ll show the user something. Should we also modify a model layer if we modify something in the presentation? Imagine a “User” screen that displays the user’s name and most recent location. If we wanted to show the user’s last two locations instead of just one, our model should not be impacted. As a result, we adhere to the following tenet: The model layer is not known to the presentation layer.

And, conversely, should the model layer be aware of the presentation layer? Once more—no, because changing the data source from a database to a network, for instance, shouldn’t have an impact on the UI. (If you’re thinking about adding a loader here—yes, but we can also have a UI loader when using a database.) The two layers are therefore totally separate. Excellent!

What about the domain layer? It is the most significant because it houses all of our application’s fundamental business logic. This is where we want to process our data before sending it to the model layer or showing it to the user. It should be separate from any other layer—it doesn’t know anything about the database, the network, or the user interface. Since this is the core, other layers will only be able to communicate with this one. Why do we desire complete autonomy? Compared to UI designs or database or network storage configurations, business rules are likely to change less frequently. We will interact with this layer through a number of provided interfaces. It doesn’t use any particular model or user interface implementation. These are specifics, and details change. Details don’t restrict sound architecture.

That’s all the theory for now. Let’s begin coding! Because this article is code-centric, you should download it from GitHub and examine its contents for a better understanding. The three Git tags that have been created—architecture_v1, architecture_v2, and architecture_v3—relate to the sections of the article.

App Technology

The app uses Kotlin and Dagger 2 for dependency injection. Neither Kotlin nor Dagger 2 is required, but they do make things much simpler. You might be surprised that I don’t use RxJava or RxKotlin, but I didn’t find it to be useful here, and I don’t like using any library just because it’s popular and someone tells me it’s required. As I stated, libraries and languages are secondary considerations, so you are free to use whatever you like. Additionally, a few Android unit test libraries are used: Mockito, Robolectric, and JUnit.

Domain

The domain layer is the most important layer in the design of our Android application architecture. Let’s begin there. Here will be our business logic as well as the communication points with other layers. The main component is the UseCase, which shows how users can interact with our app. Let’s create an abstraction for them:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
abstract class UseCase<out Type, in Params> {

    private var job: Deferred<OneOf<Failure, Type>>? = null

    abstract suspend fun run(params: Params): OneOf<Failure, Type>

    fun execute(params: Params, onResult: (OneOf<Failure, Type>) -> Unit) {
        job?.cancel()
        job = async(CommonPool) { run(params) }
        launch(UI) {
            val result = job!!.await()
            onResult(result)
        }
    }

    open fun cancel() {
        job?.cancel()
    }

    open class NoParams
}

I made the decision to use Kotlin coroutines in this instance. To provide the data, each UseCase must implement a run method. This method is invoked on a background thread, and the UI thread receives the result once it is available. The return type is OneOf<F, T>, which allows us to return an error or success along with data:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
sealed class OneOf<out E, out S> {
    data class Error<out E>(val error: E) : OneOf<E, Nothing>()
    data class Success<out S>(val data: S) : OneOf<Nothing, S>()

    val isSuccess get() = this is Success<S>
    val isError get() = this is Error<E>

    fun <E> error(error: E) = Error(error)
    fun <S> success(data: S) = Success(data)

    fun oneOf(onError: (E) -> Any, onSuccess: (S) -> Any): Any =
            when (this) {
                is Error -> onError(error)
                is Success -> onSuccess(data)
            }
}

The domain layer needs its own entities, so let’s define them next. We currently have two entities: User and UserLocation:

1
2
3
data class User(var id: Int? = null, val name: String, var isActive: Boolean = false)

data class UserLocation(var id: Int? = null, val latitude: Double, val longitude: Double, val time: Long, val userId: Int)

We must now declare the interfaces for our data providers because we are aware of the data that must be returned. These will be IUsersRepository and ILocationsRepository. They must be implemented in the model layer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
interface IUsersRepository {
    fun setActiveUser(userId: Int): OneOf<Failure, User>
    fun getActiveUser(): OneOf<Failure, User?>
    fun createUser(user: User): OneOf<Failure, User>
    fun removeUser(userId: Int): OneOf<Failure, User?>
    fun editUser(user: User): OneOf<Failure, User>
    fun users(): OneOf<Failure, List<User>>
}

interface ILocationsRepository {
    fun locations(userId: Int): OneOf<Failure, List<UserLocation>>
    fun addLocation(location: UserLocation): OneOf<Failure, UserLocation>
}

This set of actions ought to be adequate to give the app the data it needs. We are not yet deciding how the data will be stored; this is a detail from which we want to remain independent. Our domain layer is not even currently aware that it is on Android. (Sort of, we’ll try to maintain this state. I’ll elaborate later.)

The next (or nearly final) step is to define implementations for our UseCases, which the presentation data will use. They are all very straightforward, just like our app and data, and their only functions are to call a specific repository method, for instance:

1
2
3
class GetLocations @Inject constructor(private val repository: ILocationsRepository) : UseCase<List<UserLocation>, UserIdParams>() {
    override suspend fun run(params: UserIdParams): OneOf<Failure, List<UserLocation>> = repository.locations(params.userId)
}

Because of the Repository abstraction, testing our UseCases is very simple; we don’t need to be concerned with a network or database. It can be mocked in any way, so our unit tests will concentrate on real-world use cases rather than other, unrelated classes. Our unit tests will be quick and simple as a result:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@RunWith(MockitoJUnitRunner::class)
class GetLocationsTests {
    private lateinit var getLocations: GetLocations
    private val locations = listOf(UserLocation(1, 1.0, 1.0, 1L, 1))

    @Mock
    private lateinit var locationsRepository: ILocationsRepository

    @Before
    fun setUp() {
        getLocations = GetLocations(locationsRepository)
    }

    @Test
    fun `should call getLocations locations`() {
        runBlocking { getLocations.run(UserIdParams(1)) }
        verify(locationsRepository, times(1)).locations(1)
    }

    @Test
    fun `should return locations obtained from locationsRepository`() {
        given { locationsRepository.locations(1) }.willReturn(OneOf.Success(locations))
        val returnedLocations = runBlocking { getLocations.run(UserIdParams(1)) }
        returnedLocations shouldEqual OneOf.Success(locations)
    }
}

The domain layer is now complete.

Model

You’ll probably choose Room, the new Android library for data storage, as an Android developer. However, let’s say your project manager requested that you postpone making a database decision because management is debating between Room, Realm, and a new, incredibly fast storage library. In order to begin working with the UI, we need some data.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class MemoryLocationsRepository @Inject constructor(): ILocationsRepository {
    private val locations = mutableListOf<UserLocation>()

    override fun locations(userId: Int): OneOf<Failure, List<UserLocation>> = OneOf.Success(locations.filter { it.userId == userId })

    override fun addLocation(location: UserLocation): OneOf<Failure, UserLocation> {
        val addedLocation = location.copy(id = locations.size + 1)
        locations.add(addedLocation)
        return OneOf.Success(addedLocation)
    }
}

Presentation

Two years ago, I penned an article about MVP as a highly effective app structure for Android. MVP is no longer necessary and can be replaced by MVVM now that Google has announced the fantastic Architecture Components, which have greatly simplified Android application development. However, some of the concepts from this pattern, like the one about dumb views, are still very helpful. They should be concerned solely with data display. We’ll use LiveData and ViewModel to accomplish this.

The design of our app is straightforward: a single activity with bottom navigation, where two menu options display either the locations or users fragments. We employ ViewModels in these views, which in turn employ the UseCases from the domain layer to maintain clear and simple communication. Take LocationsViewModel as an illustration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class LocationsViewModel @Inject constructor(private val getLocations: GetLocations,
                                             private val saveLocation: SaveLocation) : BaseViewModel() {
    var locations = MutableLiveData<List<UserLocation>>()

    fun loadLocations(userId: Int) {
        getLocations.execute(UserIdParams(userId)) { it.oneOf(::handleError, ::handleLocationsChange) }
    }

    fun saveLocation(location: UserLocation, onSaved: (UserLocation) -> Unit) {
        saveLocation.execute(UserLocationParams(location)) {
            it.oneOf(::handleError) { location -> handleLocationSave(location, onSaved) }
        }
    }

    private fun handleLocationSave(location: UserLocation, onSaved: (UserLocation) -> Unit) {
        val currentLocations = locations.value?.toMutableList() ?: mutableListOf()
        currentLocations.add(location)
        this.locations.value = currentLocations
        onSaved(location)
    }

    private fun handleLocationsChange(locations: List<UserLocation>) {
        this.locations.value = locations
    }
}

A brief explanation for those unfamiliar with ViewModels is that the locations variable stores our data. When we use the getLocations use case to retrieve data, it is transferred to the LiveData value. Observers will be informed of this change so they can respond and update their data. We include a data observer in a fragment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class LocationsFragment : BaseFragment() {

...

    private fun initLocationsViewModel() {
       locationsViewModel = ViewModelProviders.of(activity!!, viewModelFactory)[LocationsViewModel::class.java]
       locationsViewModel.locations.observe(this, Observer<List<UserLocation>> { showLocations(it ?: emptyList()) })
       locationsViewModel.error.observe(this, Observer<Failure> { handleError(it) })
    }

    private fun showLocations(locations: List<UserLocation>) {
        locationsAdapter.locations = locations
    }

    private fun handleError(error: Failure?) {
        toast(R.string.user_fetch_error).show()
    }

}

We simply pass the new data to an adapter attached to a recycler view whenever the location changes; this is how data is typically displayed in a recycler view in Android.

Because our views use ViewModel, it is also simple to test their functionality. We can simply mock the ViewModels without worrying about the data source, network, or other factors:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@RunWith(RobolectricTestRunner::class)
@Config(application = TestRegistryRobolectricApplication::class)
class LocationsFragmentTests {

    private var usersViewModel = mock(UsersViewModel::class.java)
    private var locationsViewModel = mock(LocationsViewModel::class.java)

    lateinit var fragment: LocationsFragment

    @Before
    fun setUp() {
        UsersViewModelMock.intializeMock(usersViewModel)
        LocationsViewModelMock.intializeMock(locationsViewModel)

        fragment = LocationsFragment()
        fragment.viewModelFactory = ViewModelUtils.createFactoryForViewModels(usersViewModel, locationsViewModel)
        startFragment(fragment)
    }


    @Test
    fun `should getActiveUser on start`() {
        Mockito.verify(usersViewModel).getActiveUser()
    }

    @Test
    fun `should load locations from active user`() {
        usersViewModel.activeUserId.value = 1
        Mockito.verify(locationsViewModel).loadLocations(1)
    }

    @Test
    fun `should display locations`() {
        val date = Date(1362919080000)//10-03-2013 13:38

        locationsViewModel.locations.value = listOf(UserLocation(1, 1.0, 2.0, date.time, 1))

        val recyclerView = fragment.find<RecyclerView>(R.id.locationsRecyclerView)
        recyclerView.measure(100, 100)
        recyclerView.layout(0,0, 100, 100)
        val adapter = recyclerView.adapter as LocationsListAdapter
        adapter.itemCount `should be` 1
        val viewHolder = recyclerView.findViewHolderForAdapterPosition(0) as LocationsListAdapter.LocationViewHolder
        viewHolder.latitude.text `should equal` "Lat: 1.0"
        viewHolder.longitude.text `should equal` "Lng: 2.0"
        viewHolder.locationDate.text `should equal` "10-03-2013 13:38"
    }
}

You might observe that the presentation layer is also divided into smaller layers with distinct boundaries. Views such as activities, fragments, ViewHolders, etc. are only in charge of presenting data. They only interact with and are aware of the ViewModel layer. The ViewModels handle communication with the domain. The same ViewModels are used for the view as UseCases are for the domain. To put it another way, clean architecture is similar to an onion in that it has layers, and those layers can also have layers.

Dependency Injection

We’ve created every class for our architecture, but there’s still one more thing to do: we need something to hold it all together. Although the presentation, domain, and model layers are kept clean, we need one module that is messy and understands everything. With this knowledge, it will be able to link our layers. Using one of the most popular design patterns—dependency injection—which produces the right objects for us and injects them into the required dependencies, is the best way to accomplish this (one of the SOLID’s defined clean code principles). I used Dagger 2 here (in the middle of the project, I switched to version 2.16, which uses less boilerplate), but you can use any method you like. I recently experimented with the Koin library, and I believe it is worthwhile to investigate. Although I wanted to use it here, I ran into a lot of issues mocking the ViewModels during testing. I’m hoping to find a solution soon so I can demonstrate the differences for this app when using Dagger 2 and Koin.

With the tag architecture_v1, you can view the app for this stage on GitHub.

Changes

We tested the app after finishing our layers, and everything is working! There is just one small issue: we still need to determine which database our PM wants to use. Assume they approached you and stated that management has decided to use Room, but they still want to be able to use the newest, fastest library in the future, so keep potential changes in mind. A stakeholder also inquired about the possibility of storing the data in a cloud and is interested in learning how much such a change would cost. This is the time to assess the quality of our architecture and determine whether we can swap out the data storage system without affecting the presentation or domain layers.

Change 1

Defining database entities is the first step when using Room. We already have a few: UserLocation and User. All that remains is to add annotations like @Entity and @PrimaryKey, and we can then use it in our model layer with a database. Wonderful! This is a fantastic way to violate all of the architectural guidelines we were attempting to uphold. In actuality, it is not possible to directly convert a domain entity to a database entity. Imagine that we also need to download the data from a network. We could manage the network responses by using more classes, which would convert our straightforward entities to function with a database and a network. That is the quickest path to disaster in the future (and cries of, “Who the hell wrote this code?”). Each type of data storage we use needs its own set of entity classes. Let’s define the Room entities properly because it doesn’t cost much:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@Entity
data class UserEntity(
        @PrimaryKey(autoGenerate = true) var id: Long?,
        @ColumnInfo(name = "name") var name: String,
        @ColumnInfo(name = "isActive") var isActive: Boolean = false
)

@Entity(foreignKeys = [
    ForeignKey(entity = UserEntity::class,
            parentColumns = [ "id" ],
            childColumns = [ "userId" ],
            onDelete = CASCADE)
])
data class UserLocationEntity(
    @PrimaryKey(autoGenerate = true) var id: Long?,
    @ColumnInfo(name = "latitude") var latitude: Double,
    @ColumnInfo(name = "longitude") var longitude: Double,
    @ColumnInfo(name = "time") var time: Long,
    @ColumnInfo(name = "userId") var userId: Long
)

As you can see, they are very similar to domain entities, so there is a strong temptation to combine them. This is merely a coincidence; as data becomes more complex, there will be less resemblance.

We must then implement the UserDAO and UserLocationsDAO, our AppDatabase, and finally—the implementations for IUsersRepository and ILocationsRepository. Here’s a minor issue: despite receiving a UserLocationEntity from the database, ILocationsRepository should return a UserLocation. Similar to User-related classes, this. We need Mappers between our domain and data entities to resolve this. One of my favorite Kotlin features—extensions—was used. I made a file called Mapper.kt and put all the methods for mapping between the classes there. (Of course, it’s in the model layer—the domain doesn’t require it):

1
2
3
4
fun User.toEntity() = UserEntity(id?.toLong(), name, isActive)
fun UserEntity.toUser() = User(this.id?.toInt(), name, isActive)
fun UserLocation.toEntity() = UserLocationEntity(id?.toLong(), latitude, longitude, time, userId.toLong())
fun UserLocationEntity.toUserLocation() = UserLocation(id?.toInt(), latitude, longitude, time, userId.toInt())

Regarding domain entities, I told a little white lie. I stated that they were unaware of Android, but this is not entirely accurate. By include the @Parcelize annotation in the User entity and extending Parcelable there, I made it possible to pass the entity to a fragment. For more complex structures, we should create our own data classes for the view layer and mappers similar to those between the domain and data models. I made the calculated decision to add Parcelable to the domain entity. If any User entity changes, I’ll create separate data classes for the presentation and remove Parcelable from the domain layer.

The last step is to update our dependency injection module to use the new Repository implementation rather than the earlier MemoryRepository. Once the app has been built and is operational, we can demonstrate the Room database to the PM. We can also let the PM know that adding a network won’t take long and that we are open to using any storage library that management prefers. You can see which files have changed—just the ones in the model layer. Our architecture is very neat! Simply extending our repositories and providing the necessary implementations will allow you to create any additional storage types in the same manner. Of course, it’s possible that we’ll require several data sources, such as a network and a database. What happens then? Nothing to it; we’d simply need to create three repository implementations—one for the network, one for the database, and a main one where the appropriate data source would be chosen (e.g., load from the network if we have one, otherwise load from a database).

On GitHub, use the tag architecture_v2 to view the app for this stage.

The day is almost over—you’re at your computer with a cup of coffee, the app is ready to go live on Google Play—when your project manager approaches you and inquires, “Could you add a feature that can save the user’s current location from the GPS?”

Change 2

Everything changes, especially software. For this reason, we need clean code and clean architecture. However, even the cleanest things can become contaminated if we code carelessly. When putting into practice getting a location from the GPS, the first step would be to add all the location-aware code to the activity, execute it in our SaveLocationDialogFragment, and then create a new UserLocation with the relevant data. This might be the quickest method. However, what if our insane PM requests that we switch from using GPS to determine location to using a different provider (e.g., Bluetooth or a network)? Soon, the changes would spiral out of control. How can we achieve this in a sanitary way?

The user’s location is data. And a UseCase is getting a location. As a result, I believe that our model and domain layers should also be included. We therefore have one more UseCase to put into practice: GetCurrentLocation. We also need a way to provide a location, an ILocationProvider interface, so that the UseCase is not dependent on specifics like the GPS sensor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
interface ILocationProvider {
    fun getLocation(): OneOf<Failure, SimpleLocation>
    fun cancel()    
}

class GetCurrentLocation @Inject constructor(private val locationProvider: ILocationProvider) : UseCase<SimpleLocation, UseCase.NoParams>() {
    override suspend fun run(params: NoParams): OneOf<Failure, SimpleLocation> =
            locationProvider.getLocation()

    override fun cancel() {
        super.cancel()
        locationProvider.cancel()
    }
}

As you can see, we have one extra method here: cancel. This is due to the fact that we need a way to stop GPS location updates. Here is the implementation of our Provider, which is defined in the model layer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class GPSLocationProvider constructor(var activity: Activity) : ILocationProvider {

    private var locationManager: LocationManager? = null
    private var locationListener: GPSLocationListener? = null

    override fun getLocation(): OneOf<Failure, SimpleLocation> = runBlocking {
        val grantedResult = getLocationPermissions()

        if (grantedResult.isError) {
            val error = (grantedResult as OneOf.Error<Failure>).error
            OneOf.Error(error)
        } else {
            getLocationFromGPS()
        }
    }

    private suspend fun getLocationPermissions(): OneOf<Failure, Boolean> = suspendCoroutine {
        Dexter.withActivity(activity)
                .withPermission(Manifest.permission.ACCESS_FINE_LOCATION)
                .withListener(PermissionsCallback(it))
                .check()
    }

    private suspend fun getLocationFromGPS(): OneOf<Failure, SimpleLocation> = suspendCoroutine {
        locationListener?.unsubscribe()
        locationManager = activity.getSystemService(Context.LOCATION_SERVICE) as LocationManager
        locationManager?.let { manager ->
            locationListener = GPSLocationListener(manager, it)
            launch(UI) {
                manager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0L, 0.0f, locationListener)
            }
        }
    }

    override fun cancel() {
        locationListener?.unsubscribe()
        locationListener = null
        locationManager = null
    }
}

This provider is designed to function with Kotlin coroutines. You may recall that the UseCases’ run method is invoked on a background thread; therefore, we must make sure to mark our threads correctly. As you can see, we must pass an activity here; this is essential for preventing memory leaks because we must cancel updates and unregister from listeners when we no longer require them. Because it implements ILocationProvider, we can easily swap it out for a different provider in the future. Additionally, we can easily test the handling of the current location (either automatically or manually), even without turning on the GPS on our phone, by simply swapping out the implementation to return a randomly generated location. We need to add the newly created UseCase to the LocationsViewModel in order for it to function. In turn, the ViewModel needs a new method, getCurrentLocation, which will actually call the use case. With just a few minor UI adjustments to call it and register the GPSProvider in Dagger—voila! Our app is complete!

Summary

I was trying to demonstrate how to create an Android app that is simple to test, maintain, and modify. It should also be simple to comprehend. If someone new joins your team, they shouldn’t have any trouble comprehending the structure or the data flow. They can be certain that UI changes won’t have an impact on the model and that adding a new feature won’t take longer than anticipated if they are aware that the architecture is clean. However, the adventure doesn’t end there. Even with a well-structured app, it’s simple to break it by making careless code changes “just for a moment, just to work.” Keep in mind that there is no “just for now” code. Every piece of code that violates our guidelines has the potential to remain in the codebase and cause larger problems in the future. If you return to that code even a week later, it will appear as though someone implemented some strong dependencies there, and you’ll need to search through many other parts of the app in order to fix it. A good code architecture is difficult not only at the beginning of a project, but also throughout the entire lifespan of an Android app. Every time a change is about to be made, thought and code review should be taken into account. You could, for instance, print out and post your Android architecture diagram to help you remember this. By dividing the layers into three Gradle modules—where the domain module is unaware of the others and the presentation and model modules do not interact—you can also slightly force the layers’ independence. However, not even this can make up for being aware that messy code in the app will come back to bite us when we least expect it.

Licensed under CC BY-NC-SA 4.0