Decoupling Business Logic in Android Projects
Dealing with complexity is challenging. It starts as manageable. Then, it becomes harder to maintain, and finally, you rename your problem as tech debt and roll up your sleeves for refactoring.
Motivation
The breaking point in this story is when complexity becomes harder to maintain. Because then, we developers often compromise. Add a shortcut to bypass a somewhat arduous flow, and now, the architecture has a backdoor, and we start to exploit it little by little.
The business logic, the ruleset we build around our problem, and the data to solve that problem can increase dramatically in complexity if unchecked. We can introduce a variety of logic containers, such as UseCases, Deciders, Providers, and Builders, to structure the business logic. But they all still need to communicate with each other. The reactive nature of the data we present on the screen often prevents us from genuinely decoupling this logic.
This is where the Transmission library comes into the equation. It adds an additional layer to this complexity, intending to decouple the business logic blocks from each other. It is an experimental library, and I wouldn’t recommend using it if you start developing your application from scratch. But for screens with many intertwined business logic rules and God Class ViewModels, it might be a good alternative to try.
Let’s explore how it works.
Growing Complexity Problem
It is better to explain the problem first so that the solution makes sense.
Let’s go over a usual Android App feature implementation cycle:
- We get the user’s interaction from Composable or an XML View.
- We process that interaction in some way, fetching some data from an API, doing some post-processing on the data, and updating other related parts of the screen (optional).
- We finally put the data into some observable data holder like LiveData or StateFlow to be displayed on the screen (or not).
As the ViewModel becomes more complex, we tend to couple these bits of logic into use cases to make them testable and reusable if needed. Use cases are just a class. They accept the input (any kind of data we need) and produce some output.
As life goes on and new features start coming in, the number of these use cases also increases in the ViewModel. After this point, the logic contained inside the use cases might leak to the ViewModel.
This is a common scenario, and when it happens repeatedly, the class you use to hold all this logic — usually the ViewModel — starts becoming a God Class.
Mapping the Primitives
The Transmission library is built around this problem. The input
and Output
we use in logic containers are depicted as Signal and Data, respectively. This gives us a different representation of the problem.
- Signal is the type of transmission that comes from the UI. Either by requirement or by user Interaction.
- Data is the information we show on the screen. It could be stateful data, like the state of the screen, or one-shot events, like navigation or analytics. Their transformation is still done via some computation.
This abstracts the problem to a different terminology but does not solve the communication between business logic containers. To solve this, we introduce a Transmission type called Effect.
An Effect is an intermediary type of transmission. It should be the result of processing a Signal or another Effect. That means we can create different Effects through computation. They can also be used to create Data.
With this addition, our Transmission interactions look like this:
We define these primitives as an interface under a sealed interface called Transmission
. You can use them as is or define new building blocks for your application by extending these types.
sealed interface Transmission {
interface Signal : Transmission
interface Effect : Transmission
interface Data : Transmission
}
Additionally, if you follow a dependency inversion approach like API
- impl
module sets, each Transmission set can be placed inside the respective API
module. Different features' business logic updates can depend on each other by only processing related Effects.
Container for Computations
We need a structure to contain all of the Transmission conversions. Use cases and similar classes can still process Signals and produce Data, but we also need to process Effects.
The transformer class is responsible for processing all Transmission conversions. It accepts Signal or Effect and produces Effect or Data.
Its API includes two abstract functions to process incoming signals and effects. It also has an extension method you can add to your Transmission Data holder called reflectUpdates
.
protected fun <T : Transmission.Data?> MutableStateFlow<T>.reflectUpdates(): StateFlow<T> {
jobMap.update(JobType.DATA) {
coroutineScope.launch {
this@reflectUpdates.collect { sendData(it) }
}
}
return this.asStateFlow()
}
The transformer assumes you have stateful data that its state can be accessed at any point in the computation. Here is an example Transformer from the Sample Application:
class InputTransformer @Inject constructor() : Transformer() {
private val _inputState = MutableStateFlow(InputUiState())
private val inputState = _inputState.reflectUpdates()
override suspend fun onSignal(signal: Transmission.Signal) {
when (signal) {
is InputSignal.InputUpdate -> {
_inputState.update { it.copy(writtenText = signal.value) }
sendEffect(InputEffect.InputUpdate(signal.value))
}
}
}
override suspend fun onEffect(effect: Transmission.Effect) {
when (effect) {
is ColorPickerEffect.BackgroundColorUpdate -> {
_inputState.update { it.copy(backgroundColor = effect.color) }
}
}
}
}
Connecting Transformers
The last puzzle piece is a coordinator class that handles all communication between Transformers. The coordinator must also have an output channel for outgoing Data and Effects. Exposing effects might not be necessary. However, having the option to process effects in ViewModel also helps add this library with incremental changes. This coordinator class is called TransmissionRouter.
The most essential part is initializing the Transformers and the TransmissionRouter. You can initialize the TransmissionRouter in your viewModel by passing the onData
and onEffect
callbacks.
init {
viewModelScope.launch {
transmissionRouter.initialize(onData = ::onData, onEffect = ::onEffect)
}
}
Under the hood, the Router has Signal and Effect channels, which are converted into SharedFlows and a separate outgoing Data channel. On Initialization, effect and data channels are connected to callbacks, and each Transformer is initialized with the created channels.
// TransmissionRouter Initialization
suspend fun initialize(
onData: ((Transmission.Data) -> Unit),
onEffect: (Transmission.Effect) -> Unit = {},
) {
initializationJob = coroutineScope.launch {
launch { sharedIncomingEffects.onEach { onEffect(it) }.collect() }
launch { outGoingDataChannel.consumeAsFlow().onEach {
onData(it)
}.collect() }
launch {
transformerSet.forEach { transformer ->
transformer.initialize(
incomingSignal = sharedIncomingSignals,
incomingEffect = sharedIncomingEffects,
outGoingData = outGoingDataChannel,
outGoingEffect = effectChannel,
)
}
}
}
}
Similarly, Transformers connect their signal and effect processing functions to the incoming SharedFlows and pass the outgoing data and effects to the outgoing TransmissionRouter channels.
// Transformer Initialization
suspend fun initialize(
incomingSignal: SharedFlow<Transmission.Signal>,
incomingEffect: SharedFlow<Transmission.Effect>,
outGoingData: SendChannel<Transmission.Data>,
outGoingEffect: SendChannel<Transmission.Effect>,
) {
jobMap.update(JobType("initialization")) {
coroutineScope.launch {
launch { incomingSignal.onEach { onSignal(it) }.collect() }
launch { incomingEffect.onEach { onEffect(it) }.collect() }
launch { dataChannel.receiveAsFlow().onEach {
outGoingData.trySend(it)
}.collect() }
launch {
effectChannel.receiveAsFlow().onEach {
outGoingEffect.trySend(it)
}.collect()
}
}
}
}
You can also check out the sample application in the Repository to see how each part interacts.
Summary
The Transmission library aims to offer flexible building blocks and lets you build your communication network without coupling different sets of business logic containers in one place. We are still experimenting with this idea and developing the library. The library’s roadmap includes improving the Transformer API and having the option to make effects more targeted to different sets of Transformers.
Any feedback for the library is appreciated, Thanks for reading!