Domain to Enterprise Clean Architecture shift, Android case study

A brief case study for an incremental approach to achieve enterprise clean architecture starting from the domain level, in Android with Kotlin programming language

Klodian Kambo
8 min readMay 17, 2023

Introduction

In this brief topic I will share my personal experience on how we can shift our clean architecture from a domain-scoped to an enterprise-scoped one.

I’ll provide a starter architecture and briefly describe the module dependency diagram.

The main takeaways of this article are:

  1. One way (not The way) to organize code and modules with the Clean architecture.
  2. The granularity of the features / Usecases with a narrow Single Responsibility principle application.
  3. Composing features rather than doing inheritance between objects.
  4. Incremental approach to improve architecture during the evolution of the software and the company.

In this article I will omit implementation details and won’t discuss the principles and the criterias to make a clean architecture from scratch to keep this read as short as possible.

The Context

Let’s say that a fitness startup wants to start the development of new mobile Apps to help users improve their performances and health. At that time, the requirements were not quite clear. We only had some basic information.

Training Telemetry app

At first we were asked to build a simple mobile app for Android to monitor training parameters of the logged user during a workout. The app is connected to an HR monitor device via Bluetooth, receiving data every second. We will call this app Training Telemetry.

Constraints

  • Features and requirements are not clear or detailed enough.
  • Low resources (time, budget, employees)
  • Tight deadlines
  • A need to build an ecosystem (thus software must scale fast with low costs)

Architecture: 1st Iteration

Why the first iteration ?

Since we were missing major requirements and constraints from the business, we decided to start developing a solution in a simple but structured way, so we can scale better in later stages of the software.

Thus we started with the first version of the clean architecture, not thinking too much about where the enterprise boundaries are. We don’t know them, and investing too much time on that will might not pay off, causing delays or worse, business failure.

Modules diagram

figure 1: starter clean architecture
  1. Domain module: Set of all application specific logic. Contains a set of data class which define entities and set of functions / use cases to manipulate such entities.
  2. Data module: set of all the data related features like transport, storage, CRUD, etc… Contains all the implementation details to provide such features to the domain layer.
  3. Ui module: set of all the user interface related logic, ViewModels, UI elements from the framework, presentation, etc…

Domain ∈ Kotlin module (plain Kotlin/Java)
Data ∈ Android module
Ui ∈ Android module

The “app” module will be just a glue module, nothing more, containing only the mandatory imports for libraries in the gradle file.

Note: Ui can’t see Data and vice versa. That seems quite obvious, but if you use the “app” module as “ui” module then you can inject repositories ∈ Data into viewModels ∈ Ui which is not correct as the business logic (domain in this scenario) resides in the domain module, while Data contains only implementation details.

After defining modules, we can now start developing our first features / use cases.

The simple use case

Let’s analyze one feature, the others have the same approach:

As a logged user, I want to see the amount of effort percentage I’m doing during my workout

The formula for the instant effort perc is the following:

InstantEffortPerc = InstantHeartRate / MaxHeartRate

The InstantHeartRate will be provided by a Bluetooth band, whereas MaxHeartRate will be calculated using the Ball State University MaxHeartRate formula:

  • men: 214 — (0.8 x (age in years))
  • women: 209 — (0.7 x (age in years))

This feature looks very very simple, let’s see where this will be placed in the architecture.

The code: domain

For the code we will focus solely on the domain, both abstractions and concrete implementations. The other modules will not be treated to keep this short, anyways are not very interesting for the purpose of this topic.

enum class UserGender { M, F }

The use case that calculates the maximum heart rate by Ball State University formula:

interface CalculateMaxHRUseCase : (Int, UserGender) -> Int?

class CalculateMaxHRUseCaseImpl @Inject constructor() : CalculateMaxHRUseCase {
override fun invoke(age : Int, biologicalGender: UserGender): Int? {
if(age < 0) return null
return when(biologicalGender){
UserGender.M -> 214 - (0.8 * age)
UserGender.F -> 209 - (0.7 * age)
}.roundToInt()
}
}

Instant effort use case

interface CalculateInstantEffortPercUseCase : (Int, Int) -> Double?

class CalculateInstantEffortPercUseCaseImpl @Inject constructor() : CalculateInstantEffortPercUseCase {
override fun invoke(instantHr: Int, maxHr: Int): Double? {
if(instantHr < 1 || maxHr < 1) return null
return instantHr.toDouble() / maxHr.toDouble()
}
}

We assume we will have a repository that will provide the stream of the heart rate.

interface HeartRateRepository {
fun getInstantHrUpdates() : Flow<Int>
}


interface GetInstantHrStreamUseCase : () -> Flow<Int>
class GetInstantHrStreamUseCaseImpl
@Inject constructor(private val heartRateRepository: HeartRateRepository) : GetInstantHrStreamUseCase {
override fun invoke(): Flow<Int> = heartRateRepository.getInstantHrUpdates()
}

We assume we will have a repository that will provide the current user.

interface UserRepository {
suspend fun getCurrentUser() : PublicUser
}

interface GetCurrentUserUseCase : suspend () -> PublicUser

class GetCurrentUserUseCaseImpl
@Inject constructor(private val userRepository: UserRepository) : GetCurrentUserUseCase {
override suspend fun invoke(): PublicUser = userRepository.getCurrentUser()
}

Finally we have the stream of instant effort percentage.

interface GetCurrentUserInstantEffortPercStreamUseCase: suspend () -> Flow<Double?>

class GetCurrentUserInstantEffortPercStreamUseCaseImpl
@Inject constructor(
private val getInstantHrStreamUseCase: GetInstantHrStreamUseCase,
private val calculateInstantEffortPercUseCase: CalculateInstantEffortPercUseCase,
private val calculateMaxHRUseCase: CalculateMaxHRUseCase,
private val getCurrentUserUseCase: GetCurrentUserUseCase) : GetCurrentUserInstantEffortPercStreamUseCase {

override suspend fun invoke(): Flow<Double?> {
val currentUser = getCurrentUserUseCase()
val maxHr = calculateMaxHRUseCase(currentUser.age, currentUser.gender)
?.takeIf{ it > 0 } ?: return emptyFlow()

return getInstantHrStreamUseCase().map { instantHrValue ->
calculateInstantEffortPercUseCase(instantHrValue, maxHr)
}
}
}

This is the dependency state of the use cases we just defined

Note that we could have implemented just the GetCurrentUserInstantEfforPercStreamUseCase by injecting only repositories and do the calculations in the single use case but:

  1. it’s quite a rigid implementation, not scalable nor reusable.
  2. has embedded some features we might need application wide (e.g. fetch the current user).
  3. Harder to test.

For those reasons, I prefer to apply a narrow definition of the Single Responsibility principle, divide the features more atomically. With this level of granularity it’s easier to use composition to build and test the features .

Let me show how messy and rigid the code will be with the monolothic approach:

// Bad example: messy and rigid
class GetCurrentUserInstantEffortPercStreamUseCaseImpl @Inject constructor(
private val heartRateRepository: HeartRateRepository,
private val userRepository: UserRepository) : GetCurrentUserInstantEffortPercStreamUseCase {

override suspend fun invoke(): Flow<Double?> {
val currentUser = userRepository.getCurrentUser()

val maxHr = if(currentUser.age < 0) null
else when(currentUser.gender){
UserGender.M -> 214 - (0.8 * currentUser.age)
UserGender.F -> 209 - (0.7 * currentUser.age)
}.roundToInt()

maxHr?.takeIf{ it > 0 } ?: return emptyFlow()

return heartRateRepository.getInstantHrUpdates().map { instantHrValue ->
if(instantHrValue < 1) return@map null
instantHrValue.toDouble() / maxHr.toDouble()
}
}
}

The Enterprise grows

So we built the rest of the app following the same principles, delivering the MVP and having some feedback. The software will eventually evolve and more features will be added.

The enterprise managed to make some money out of the first software we built, and had some more ideas to develop the business further.

They gathered some ideas and decided to implement another software product, a Performance recap app.

New app, similar use case

This Performance recap app will show all the gathered data from other apps (also from our Training Telemetry app, bands, smart devices etc…). It’s basically a presentation app. Compared to the Training Telemetry app, we will now show only the maximum heart rate instead of the InstantEffortPerc.

So again, let’s analyze one feature, the others will apply the same approach:

As a logged user I want to see my maximum heart rate

We already have this feature, and since we made a granular implementation of the use cases, we can reuse them. Note that this would not have been possible by defining a non-composed GetCurrentUserInstantEfforPercStreamUseCase that does all the logic at the same time.

In order to reuse this feature we have to upgrade our architecture, with incremental steps.

Architecture: 2nd Iteration, Training Telemetry

Since we have this feature already developed and tested and we are very lazy coders, we don’t want to write it again, test it again etc… so what we decide to do is to move some files, just changing positions within modules. It’s time to create our Enterprise module.

The enterprise module contains all the enterprise-wide business logic. As for the domain module, the enterprise module it’s composed by a set = { data class, use case / function, interfaces }

Enterprise ∈ Kotlin module (plain Kotlin/Java)

In this instance CalculateMaxHRUseCase can be moved in the enterprise since the enterprise it’s basically a fitness company. This calculus will be used quite often, either to show the result as is or to combine it with other formulas.

Therefore we move the CalculateMaxHRUseCase (1) and all the tests (3) we already wrote on that use case. By moving this feature we also need to move the PublicUser (2) (and also the UserGender) into the Enterprise module.

We can apply the same idea to all the features we know that are business-wide, moving all the files involved in this process from domain to enterprise.

Since this extrapolation can take some time we firstly refactor just the Training Telemetry app, deploy, and next sprints we will also update the Performance recap app. The next step is to share enterprise between projects and software products.

Architecture: 3rd Iteration, shared Enterprise

To make the Enterprise module reusable across different projects we need to create a library and publish it into a remote repository. Once the library is deployed and imported on each app, the Enterprise level dependency graph looks like this:

given F as the feature for the domain we have

Training Telemetry

  • F: instantEffort% = instantHr / CalculateMaxHRUseCase

Performance recap

  • F: MaxHr = CalculateMaxHRUseCase
  • F: trendMaxHR = list of CalculateMaxHRUseCase

As you can see now you can move all that is in common for the two projects in the Enterprise module, with their tests also. So we managed to do some architecture shifts without breaking anything or adding code, and we started building the core features for the whole enterprise.

Conclusions

If you keep an ordered and structured approach, narrow the scope of the use cases as much as convenient, decoupling and applying some functional decomposition, then making architecture transitions can be easier.

It’s less work to do and safer for the software and the enterprise itself. In this way you extend your codebase with little cost, re-using what you have already implemented.

TL; DR.

  • Keep a narrow definition of the Single Responsibility principle.
  • Break down the code in independent chunks as much as convenient
  • Apply composition where required
  • Start easy but structured
  • Make incremental changes/refactors
  • Shift to Enterprise architecture if you have a business to share

Those indications are convenient throughout the whole software development, not just for this topic of architecture transition.

--

--

Klodian Kambo

Software Engineer 🎓 University of Padova, Electric and Information Engineering Department. 10+ in Android. Passion in Software Architecture and code structure