skip to content
Cover Image

Transmission Library Fundamentals and Testing

This is Part 2 in Transmission Project Series

In the previous post, we introduced the central premise of the Transmission Library and its goals. Now, a couple of version updates later, the library’s core functionality is stable and, more importantly, testable. Let’s review the fundamentals of the new Transmission API terminology and explore how we can use the library!

Recap of Architecture

The Library is built upon a primitive called Transmission (Surprise). We create a communication network using * Transformers*. And TransmissionRouter coordinates the communication between Transformers.

Let’s explore each part by dissecting the Color Picker Component of our sample application and the library code. Some of the code snippets are added as screenshots for better readability. Nonetheless, you can explore the library code here. Let’s begin 👇🏻

Primitives

Here are the main building blocks of Transmission:

sealed interface Transmission {
    interface Signal : Transmission
    interface Effect : Transmission
    interface Data : Transmission
}
  • Data is the information we keep and use in the system. Anything can be transmission data. They are either generated inside Transformers on the fly, or Transformers can keep an instance of data and update its state as it processes incoming effects and signals.
  • Effects are the byproduct of signal (or effect) processing . They can contain anything we want.
  • Signal is the entry point of any execution logic we want to coordinate between transformers. We assume that they are created outside and enter the system. Where do we generate them? They are generated inside the UI Layer.

Let’s see the Color Picker Component Transmissions:

Here, ColorPickerSignal is the signal that is coming from outside. It is sent when the user selects a color from the ColorPicker Component. When we send this signal, the background and selected colors can be updated using the respective ColorPickerEffect. We use ColorPickerUiState to store and refer to this data.

The next stop is our Transformers.

How do we process Transmissions?

Transformers are the decoupled business logic containers the library proposes. Rules you build using the Transmission primitives are processed through Transformers. They can process incoming information and let other Transformers know about the side effects of their processing using the CommunicationScope functions.

Transformers process signals and effects using their respective Handlers. Below, we can see the Handler interface and its implementation for your ColorPicker Component primitives:

fun interface SignalHandler {
    suspend fun CommunicationScope.onSignal(signal: Transmission.Signal)
}

You can choose to handle any Signal or Effect, or you can choose to process a specific type. The library offers respective builder functions to make this step easy to implement.

Let’s use the buildGenericSignalHandler method and implement the signal processing inside the ColorPickerTransformer:

Processed signals and effects produce some state, which we need to reference and observe. For these, we use TransmissionDataHolder, which lets us store any Transmission data inside Transformers and publish their results automatically when we update them.

interface TransmissionDataHolder<T : Transmission.Data?> {
    fun getValue(): T
    fun update(updater: (T) -> @UnsafeVariance T)
}

We can add these to Transformers using the buildDataHolder extension function:

Now, we have primitives that Transformers can process, and we can store the result of these processes as the Transformers’ state. Their communication? TransmissionRouter handles it for us.

Ensuring the Delivery of Transmissions

Router is the heart of the communication network between Transformers. The only requirement for TransmissionRouter is a valid set of Transformers. On its initialization, it has four main tasks:

  • Signal collection
  • Effect processing
  • Data publishing
  • Query processing

Initialization happens in the init block, so once injected, the router is ready to accept signals and process them.

Delivery of Transmission is handled using Channels and SharedFlows. For convenience, the library internally provides a Broadcast<T> interface that accepts the incoming data through a channel and provides a SharedFlow to the consumers.

interface Broadcast<T> {
    val producer: SendChannel<T>
    val output: SharedFlow<T>
}

Using the createBroadcast extension function, TransmissionRouter creates all the needed broadcasts and passes them to the Transformers on initialization.

How do Transformers Communicate?

We explained what CommunicationScope does in the previous section. When Transformers process a signal or an effect, they can publish effects and the latest state of their data to the network using the functions provided in CommunicationScope.

But Transformers can’t process Transmission Data. Published data is directly sent outside the network through TransmissionRouter’s dataStream, whereas effects are sent using effectStream and published to other Transformers.

There are two ways Transformers can access each other’s data:

  • Querying the data holder directly using queryData
  • Querying the registered computation inside the Transformer using queryComputation

The difference between the Data and the Computation is that Data is the state we keep using the buildDataHolder extension function inside Transformers. When queried, we can get the latest data version inside the Transformer. Computation, on the other hand, registers a function we can run on demand. So when computation is queried, code is executed, and we get the result. We can also cache the result and invalidate it if we want.

Computation example from the sample application

Testing Transformers

Testing the interactions and ensuring the rules we designed are working deterministically is quintessential.

To not block the processing of a signal or an effect back to back, the Transformer creates a separate Coroutine each time the handlers are invoked. Otherwise, when we start to process a Signal, for example, the computation query we make takes a second to complete, and the whole processing is suspended.

No Coroutine Creation on Each Signal/EffectCoroutine Creation on Each Signal/Effect

Output and Multi-Output Transformers have computation queries that suspend the processing artificially. On the left, we can see that because of this suspension, they can’t update their background colors. On the right, each handler call is another Coroutine. So, suspended signal/effect processing does not block.

The first version makes the testing easier because the processing suspends, which makes transferring result data and effects to a testable stream easy.

However, with Coroutines, we must wait for the processing to finish to test everything we do on any effect/signal processing. The library offers a secondary test module to make tests easier and predictable. Here are the functions we can use for Transformers.

Using these functions, we can define the queried data and computations beforehand and test what the Transformers does deterministically.

Example test from OutputTransformerTest

What is Ahead?

Improving the overall API and testing was a high priority on the list. With these additions, we can properly test the integration of the library and see what is missing in the developer experience.

Apart from testing and improving the main functionality, the immediate next step is to update the project documentation to make it more user-friendly. After this, the next big update for this library could be the Kotlin Multiplatform! But that is a topic for another blog post. Stay tuned 🖖🏻


You can check out the Transmission Library here and read the first post here.