Unidirectional data-flow and the Zen of black box components

Zsolt Kocsi
Jul 10, 2019 · 11 min read

This is the third part in a series of articles on Android architecture at Badoo. Here are links to the first two:

  1. MVI beyond state reducers
  2. Building a system of reactive components with Kotlin

Don’t stop at loose coupling

It’s pretty much generally recognised that loose coupling is better than tight coupling. If you are relying only on interfaces rather than on concrete implementations, you make your components easier to replace. It’s easier to switch to a different implementation without having to rewrite bigger parts of your code, and it makes unit testing easier as well.

And that’s pretty much where we usually call it a day and say we’ve achieved what was possible in terms of decoupling.

Even so, this approach is far from optimal. Let’s say you have class A, which needs to use some functionality of three other classes: B, C, and D.

Even if B, C, and D are just referenced through interfaces, our class A becomes heavier with each one of them:

  • It knows about all the methods in all the above interfaces, their names and return types, even if it doesn’t use those
  • It requires setting up more mocks when testing A
  • It makes it more difficult to reuse A in a different context where we don’t have or don’t want to have B, C, D

Of course, it should be A defining the minimalistic interfaces that it needs (Interface Segregation Principle). But in practice probably everyone has seen (and done) countless cases where it was approached from the other side, simply for convenience’s sake: taking an existing class that implements some functionality, and extracting all of its public methods to an interface; then using that interface in other places where the class is needed. This method approaches the use of interfaces not from the perspective of what a component needs but rather based on what (another) component can provide.

Then it just tends to get worse with time. Each time we add new functionality, our classes become entangled in a web of newer and newer interfaces they need to know about, they grow in size, and testing them becomes ever more challenging.

And should you ever need to reuse them in a different context, it feels hopeless to move them around without all of the tangled mess they are connected to (even if through interfaces). You wanted the banana, but the banana is in the hands of a monkey, the monkey is holding on to a tree, so in the end you have to take a whole chunk of the jungle with it. The whole process takes ages and makes you feel pretty miserable, and soon you’re wondering why reuse always turns out to be such a pain in practice.

Black box components

If we want a component to be easy to reuse, what we really need is for it to have:

  • no knowledge of the context in which it is being used
  • no knowledge of other components that are not one of its internal implementation details.

It’s easy to see why: with no knowledge of the outside world, it can’t get coupled to it.

To achieve this, what we really want is for the component:

  • to define its own inputs and outputs
  • to not care where those inputs are coming from and where those outputs are going to
  • to be self-contained in that no knowledge of its internal workings should be required to make it work.

We might think of the component as a kind of a black box, or an integrated circuit. It has its inputs and outputs, you solder it in, and now it’s part of a system that it knows nothing about.

So far, the implicit assumption was that we were thinking in bidirectional data-flows: if class A needs something, it invokes a method on interface B, and gets the result as a return value of that function.

But this results in A knowing about B, which is just what we want to avoid.

This setup of course still makes sense for low-level internal implementation details. However, if we want a reusable component that operates as a self-contained black box, then we need to make sure that it assumes nothing about the outside world i.e. has no knowledge about external interfaces, method names, or return values.

Going unidirectional

Without interfaces and method names though, we can’t actually invoke anything! Our only choice is a unidirectional data-flow, where we just produce outputs, and consume inputs:

This might seem limiting at first, but actually it has multiple benefits, as we will see later.

We’ve seen in Part 1 that Features define their own input (Wish) and their own output (State). This allows them to not care where those Wishes are coming from and where those States are going to.

This is exactly what we need here! Features allow themselves to be placed wherever you can supply their inputs and you are free to do what you want with their outputs. And because they don’t talk directly to other external components this makes them completely decoupled, self-contained units.

So, in the same way, let’s take our View and try to design it so that it, too, can become a self-contained unit.

Firstly, it should be as dumb as possible, so that it only handles its internal concerns.

What are those? A View should be concerned with two things, and two things only:

  • rendering a ViewModel (input)
  • triggering ViewEvents based on user interaction (output)

Why use a ViewModel? Why not just render the State of the Feature directly?

  • Whether there is a Feature in the picture or not is an implementation detail. The View should also be able to render itself if the data is coming from different / multiple sources.
  • The complexities of the State should not be present in the View. A ViewModel should only contain the ready-to-show information needed by the View, so that it can stay simple.

Our View should also not care about:

  • where those ViewModels are coming from
  • what happens where those ViewEvents are triggered
  • any kind of business logic
  • analytics tracking
  • logging
  • Etc.

All the above are external concerns and the View should not be conflated with them. Let’s stop for a moment and enjoy the resulting simplicity of our View:

An implementation of this View in the case of Android would:

  1. find some Android views by id
  2. implement the accept method of the Consumer interface by setting values from the ViewModel
  3. set listeners on some other elements so that interaction with the UI triggers emitting the defined Events

For example:

Going beyond Features and Views, this is how any other component would look using this approach:

The pattern’s clear now!

Connect, connect, connect!

And what do we do when we have different components all with their own inputs and outputs?

We connect them!

Fortunately, this is pretty easy with the Binder — which also ensures proper scoping, as we saw in Part 2:

Benefit #1: Easy to extend without modification

Using decoupled black-box components that are only temporarily connected allows us to introduce new functionality to the system without having to modify our existing components.

Take for example this simple setup:

Here we have a simple (F)eature and a (V)iew connected to each other.

Their corresponding bindings would be:

Now, let’s say we want to add an analytics tracker to this system, to track some UI events.

The beautiful thing is that we can add this tracker just by simply reusing the existing output channel of the View for an additional purpose:

In code, this would look like as follows:

The new functionality is added simply through one line of an additional binding. Not only do we not have to modify a single line of the View, but the View doesn’t even know that its output is being used for an additional purpose.

Needless to say, this makes it a lot easier to avoid adding extra concerns and unneeded complexity to our components. They can remain simple, and the extra functionality in the system appears in the form of new components connected to existing ones.

Benefit #2: super-easy to reuse

Staying with the examples of Features and Views for a moment, it’s easy to see that we can plug different input sources/output sinks to them with single lines of bindings, which makes them super-easy to reuse in different environments.

This approach need not be restricted to single classes either. We can use interfaces like this to describe a self-contained, reactive component of any size.

By restricting ourselves to using its defined inputs/outputs, we relieve ourselves of the burden of having to know something about its internal workings. This makes it easy to avoid accidentally coupling the internals of a component to other parts of the system. Without coupling, reuse of the component should be smooth and straightforward.

We’ll come back to this topic later on in this series of articles, and look at some examples of applying this technique to connect higher level components.

Practical question #1: Where should you put your bindings?

  1. Pick your level of abstraction. Depending on your architecture, this might be an Activity, a Fragment or some ViewController. For the parts where you don’t have UI, you hopefully still have some level of abstraction, e.g. a certain scope in a dependency injection scope-tree.
  2. Create a separate class for your bindings, that exists on the same level as this piece of UI. So if you have FooActivity, or FooFragment, or FooViewController, you might want to put a FooBindings next to them.
  3. Make sure you inject FooBindings with the same instances of components that you use in the Activity/Fragment/etc, otherwise you will be connecting different instances from the ones you are triggering or subscribing to!
  4. Use the lifecycle of the Activity/Fragment for scoping your bindings. If your lifecycle is not tied to Android you can still create manual triggers, e.g. when you create/destroy your DI scope. See Part 2 of this series for more examples of scoping.

Practical question #2: Testing

Because the component knows nothing of other components, there are usually no (or very few) mocks we need to set up for them. Our tests become as simple as testing whether our component as a whole reacts to inputs in the correct way, and triggers its outputs when expected.

In the case of a Feature this means:

  • we can test if given a certain Wish (input) the expected State (output) is produced

In the case of a View:

  • we can test if giving it a ViewModel (input) results in the expected UI state
  • we can test if simulating interaction with the UI results in the expected ViewEvent being triggered (output).

Of course, interactions between components do not magically disappear, we just extracted those concerns out of the components. We still need to test them, but where?

The connecting pieces between components are the Binder bindings in our case:

There are two things our tests need to confirm here:

1. Transformers

Some connections have transformers, and we need to make sure that they transform elements correctly. A very simple unit test suffices here in most instances, as transformers are also usually very simple.

2. Connections

We need to make sure that the connections are set up correctly. What use is it if the individual components and element transformers work, but for some reason the connection between them is not made?

We can test this by setting up the binding environment with mocks, triggering sources, and veryfying that we receive expected outputs on the consumer sides:

While the overall amount of code needed for testing is around the same as for any other approach, self-contained components tend to make it easier to test the individual pieces, as concerns are neatly separated.

Food for thought

While describing your system as a connected graph of black box components might provide an easy-to-understand overview, this only works as long as you keep the size of it relatively small.

So, while 5–8 lines of bindings are still manageable, this is getting really busy:

We went through this phase, where we observed a growing number of connections (even more than shown in the code snippet above), and it started being painful. Not just because of the number of lines — you could probably group some bindings and extract them to multiple methods to help to some extent –, but because it’s getting harder and harder to even grasp what’s going on in the scope, let alone reason about it. And that’s never a good sign. When there’s a hundred different components on the same level, it’s difficult to even think about all their possible, combined interactions.

Does the problem stem from the use of using black box components?

Or from something else?

Clearly, if the scope what you are describing is inherently complex, no approach is going to save you from that, unless you break it down. Even without a huge list of bindings, the same complexity would still be present in the system, perhaps just in a less obvious form — and then it’s a lot better if it’s exposed than if it remains hidden. It’s much better to see a single, growing list of one-liner connections that reminds us of how many actual distinct components we have, than to have the same degree of interconnectedness hidden inside classes at different method calls.

Since the individual components themselves are simple (because being black boxes, they have no extra concerns inside), they can also be separated more easily, so it’s at least a step in the right direction. The complexity is shifted to a single location instead, i.e. the list of bindings, where one glance gives us a high-level overview, and we can start to ponder about how to get out of the mess we are in.

Because, yes, frankly we are still in a mess. We may have just sorted one level horizontally, but it’s still a mess vertically. We probably need to break it down. Somehow. But really, what to do with it?

I need to leave you on a bit of a cliffhanger here, as finding the answer to this question took us on a longer road. We will cover this in later articles in this series. Stay tuned!

Bumble Tech

This is the Bumble tech team blog focused on technology and…

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store