skip to content
Cover Image

Decoupling Business Logic in Android Projects

This is Part 1 in Transmission Project Series

Click here to read Medium version

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 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 design 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 through computation, we can create different Effects. 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 * *Signal**s 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 offers flexible building blocks and lets you build your communication network without coupling different sets of business logic containers in one place. I am still experimenting with this idea. 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!