In 2019, we held a meet up in which we presented our approach to architecture in mobile development. Here you can see the talk: https://youtu.be/nV2L2Ql_690

In summary, I will first try to explain the concept at a high level. The idea of this approach is to take the dependency inversion one step further and have both the view and the models exposed through interfaces. These interfaces are connected by a “true presenter” or “binder” whose only function is to link the view and model inputs and outputs to each other. The result of this is that both the view and the model don’t know each other, making it possible to ignore the view completely when creating tests for the model and the same when creating tests for the view.

Architecture implementation

At that time we used a combination of Rx and inheritance to achieve the exposed pattern. After the meet up we set out to improve this aspect to make it easier to implement this architecture, as well as to explain it to new additions to the team.

In order to make this architecture easier to implement, we implemented a library in Kotlin. The core is made without using Java or Android code, so that in the future it can be used in multiplatform projects. The rest of the article, as well as the examples, will be focused on Android, which is the platform where we have been able to use the library in a more extensive way.

To start we will introduce the basic concepts that we will handle with the library and then see how we can build an application with them.

  • Binder: is responsible for connecting the inputs and outputs of view and model.
  • Event: is an action or message that the view or model sends out.
  • Receiver: is in charge of receiving and managing the events.
  • Bindable: interface that defines that a class can be binded with another one as well as the methods to do it.

Binder Types

The binders are in their simplest and purest concept, a unique function that brings together Events and Receivers. These binders can be accessed by extending the following interfaces (Bindables) provided by the library. Right now there are three types of Bindables capable of providing these binders:

  • GlobalBind: it is a global binder without scope, its main function is to bind instances that are not linked to a life cycle. Mainly bind services with other services.
  • CoBindable: it is a CoroutineScope alias, it provides a binder linked to the life cycle of the coroutine scope.
  • ViewBindable: it is an alias of Android’s LifeCycleOwner, it provides a binder linked to that life cycle.

Example:

fun ViewBindable.bindDataList(view: DataListView, service: DataListService) = bind {
    view.displayList via service.dataListReceived
    view.onError via service.errorReceived
    view.onLoading via service.startedFetching
    view.requestList via service.loadData
}

Defining Binders

As you can see, we define the binder as a function that extends the bindable “ViewBindable”, when extending this interface we have at our disposal the bind method, which provides us a binder as a receiver. The method “via” provided by the binder is responsible for linking events and receivers of the same type between them.

Talking about Events and Receivers. This is how we define the interfaces that the previous binder receives:

interface DataListView {
    val requestList: Event
    val onError: Receiver
    val onLoading: ReceiverU
    val displayList: Receiver<List>
}

interface DataListService {
    val loadData: Receiver
    val errorReceived: Event
    val startedFetching: EventU
    val dataListReceived: Event<List>
}

Both Receivers and Events have a generic parameter, the type they receive or send respectively. To be able to bind an event with a receiver they must have the same type of parameter. EventU and ReceiverU, are provided for convenience in case you don’t need to send data with the event.

Interface Implementation 

So far we have seen how to define the interfaces of our views and our model, and also how to connect their inputs and outputs to each other. We are now going to see how we can implement these interfaces and how effectively their instances will not be known to each other.

class DataListFragment : Fragment(R.layout.fragment_main_list), DataListView {
   override val requestList: Event = event()

   override val onError: Receiver = receiver {
       ...
   }

   override val onLoading: ReceiverU = receiver {
       ...
   }
   override val displayList: Receiver<List> = receiver {
       ...
   }
}

As you can see, the implementation of an event is very simple. All you have to do is call the “event” function. Then in our code we can call the event as if it were a method to launch it and execute all the receivers that it has linked.

The receivers have a little more interest, when we call to the method “receiver” we should pass a lambda that has as arguments a single parameter of the same type as the receiver. This is the code that will be called when one of its linked events is launched.

If we take a look at how to implement the service we will see a couple of interesting details:

class NetworkDataListService(repository: NetworkDataRepository) : DataListService, Bindable {
    override val errorReceived: Event = event(retainValue = false)
    override val startedFetching: EventU = event(retainValue = false)
    override val dataListReceived: Event<List> = event()
    override val loadData: Receiver = suspendReceiver {
        startedFetching()
        runCatching { repository.getList(it) }.fold({
            dataListReceived(it)
        }, {
            errorReceived(Error(it))
        })
    }
}

First, our service must implement the Bindable interface to simplify the implementations we tend to make the interfaces that are not for views can implement it directly:

interface DataDetailsService : Bindable {
   (...)
}

So there there is no need to worry that whoever implements the service has to remember to add the interface in the implementations. But for this article it is interesting to see all the details.

We can see two more concepts apart from this detail:

  • On one side, in the event we pass the parameter retainValue = false  this means that this event does not retain values. By default the events behave similarly to a BehaviourSubject of Rx or a StateFlow of Kotlin, for instance, when a receiver connects to an event the receiver receives the last value sent by the event. When we pass retainValue = false, the event does not retain the last value sent and when it is linked to a receiver it will not receive any value until the event is launched again.
  • The other detail is that instead of receiver as in the view here we have used suspendReceiver this library comes with support for coroutines and within this lambda, besides the value sent by the event we have access to the scope of the binder, so we can launch coroutines or call suspending functions without problem.

In this last example we can also see that from inside the lambda of loadData we can call the events of the service itself to send information to whoever is listening.

As you can see, neither the view nor the service has visibility of each other. If we wanted to unit test this service we would only have to mock-up the network repository (which is the one we are really interested in) and check that the appropriate output events are launched in each case. From the view, we would only have to check that when a data arrives, the appropiate view is displayed, or that when the user performs an action, it triggers an event.

With this system we can also connect services to each other, for example by binding an ErrorLogger to the error events of other services.

interface ErrorLogger : Bindable {
   val onError: Receiver
}

When we bundle non-life-cycle services together, we usually do it using the GlobalBinder, since we usually want them to run during the whole application execution time.

GlobalBind.bindDataListErrors(get(), get())
GlobalBind.bindDataDetailsErrors(get(), get())

There are only two things left: Who calls the binder and how to write the tests. The library is agnostic to the dependency injection framework used, but we have to take into account Android’s peculiarity: we cannot create the instances of the views ourselves. How do we solve it? The “bind” method provided by the bindables returns the binder instance, so what we can do is inject the binder into the fragment. In our case we use koin so I am going to use it as an example:

private val binds = module {
   koinBind { dataList ->
       dataList.bindDataList(dataList, get())
   }
}

We define a binds module in which we will define our binders that depend on an Android view, which will receive it as a parameter.

koinBind is a helper method to make easier the task of defining the modules.

inline fun  Module.koinBind(noinline block: Scope.(T) -> Binder) {
   scope {
       scoped(named()) { (bindable: T) ->
           block(bindable)
       }
   }
}

Having defined the binders of the fragment in a scope and instance that carry its name we can easily collect the binders in the following way:

import org.koin.androidx.scope.lifecycleScope as koinScope
inline fun  T.applyBinds() = koinScope.get(named()) { parametersOf(this) }

The only thing left is to call the method in the fragment and we will have everything linked:

init {
   applyBinds()
}

This is all cool, but if we could not do the tests with the same or more ease it would not be of any use. For this reason we have created in the library a module with a small test framework to be able to create them as easily as possible:

@Test
    fun `load data dispatches fetch and error events if request fails`() {
        val error = Error()
        coEvery { repository.getList(any()) } throws error

        sut.loadData withParameter request shouldDispatch {
            sut.startedFetching withParameter Unit
            sut.errorReceived withType Error::class
            sut.errorReceived assertOverParameter {
                assertEquals(error, it.cause)
            }
        }
    }

As you can see it includes several methods with which to write tests in a fairly semantic way and with very little boilerplate. Apart from those shown here there are others like: withAny Paramenter, withType or never Dispatched.

This library is still in progress and we have opened it a little while ago so that anyone who wants to can collaborate or experiment with it. Here is the link to the Github repository where you can also see a sample where you can see everything in action: https://github.com/apiumhub/event-binder