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 respectiveColorPickerEffect
. We useColorPickerUiState
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/Effect | Coroutine 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.