How to fix 3 issues of MVx architectures?
Intro
So last time I described three issues that all MVx architectures and even some non-MVx architectures are suffering from. In short, these are:
- The remainder issue — when dividing a feature into architecture components, there is either an “indivisible” part of the feature that generates new components, or simply unnecessary architecture components
- The scalability issue — when expanding a feature, architecture components begin to bloat, making further maintenance more complicated
- The logic gaps issue — when interacting with UI, the logic becomes fragmented leading to unpredictable behaviour and difficulties with testing
So what could be done about this?
Remainder issue
How to deal with the remainder issue? It’s simple — take a smaller divisor. The smaller the divisor, the smaller the remainder. However, this approach does not work with MVx architectures because our divisor is usually a set of specific components, and introducing new ones means changing the architecture. You have probably encountered this when introducing mappers, delegates, interactors (the ones that are repositories of repositories), and so on. Did it help? Not for me. The best I’ve seen are Flux- and ELM-like architectures that declare a “pure” function as the unit of logic decomposition, but with all the resulting conveniences and side ”effects”.
And the solution to the remainder issue, even if we had it, does not solve the scalability issue.
Scalability issue
Last time, I talked about the “intuitive” approach to solving the scalability issue and explained why it doesn’t work. But what about the non-intuitive approach?
In fact, this is an old and already solved problem. Examples of solutions can be seen in how some theorems are proven in computation theory by embedding one Turing machine into another, or how elegantly this problem is solved in processors, where more complex components are simply compositions of simpler ones.
The same applies to the MVx architectures: we should implement changes separately, and combine them with the existing feature afterwards, instead of making changes to it. And the most interesting thing is that we’re already doing this for Data layers, but for some reason, the closer we get to the UI layer, the more we start to modify instead of combining. And most likely, you are familiar with such symptoms as endless rewriting of existing component tests, or a Presenter, ViewModel, Controller the size of the universe, which is scary to touch because something will definitely break.
Yes, such an approach, where we prefer composition over modifications, will really be non-intuitive, because it will require implementing improvements as a separate feature. I mean that we will have to implement all the layers of the chosen architectural approach and then write another feature that will be their composition. And yes, I already anticipate comments on such changes like “why is it so complicated”, “there is too much unnecessary code”, “nothing is clear”, and so on.
Also, let me remind you about the Remainder issue, which will lead us to a situation where this approach will not work, because we will have to embed our changes into the “middle” of a component from an existing feature, which means that we will have to split the existing components into smaller ones. The larger the divisor, the larger the remainder, right?
In the end, even using a “non-intuitive” approach to scaling, we still couldn’t fully solve the Scalability issue. And by the way, the Gaps issue also still remains.
Gaps issue
And here it becomes interesting. It’s all because of “Hello World”. What architecture does it have?
Hello World
I have seen examples of Hello World in different languages, frameworks, and architectures (except for OpenGL, of course), and they didn’t have any issues with its implementation. If we don’t consider the effort required to set up the initial template as an issue. But if the result is the same, doesn’t it imply that the only difference is how much boilerpate is needed for Hello World to work? And do we really need it? What do all these implementations of Hello World in different architectures have in common? The algorithm. And it is ridiculously trivial.
Interestingly, the architecture in which the algorithm should be implemented is not explicitly stated anywhere. But this is Hello World. As I said, it is overly simple.
More interesting examples
Let’s take a look at the next educational example called “Hello %username%.” It faces the same problem with architectures, yet has a common algorithm.
Where is the MV-something in this algorithm? Moreover, if we generalize the “Hello World” algorithm a bit by separating the display part from the “Hello World” string, we’ll notice that it appears twice in this example.
Still too simple, isn’t it? The next educational example is working with data structures. In its simplest form, it involves CRUD operations plus “show all” with storage in a list. This example, which could be not interesting in terms of implementation, is introducing composition to the previous algorithm. Essentially, here we encounter the need to create five independent programs and then combine them under the control of the sixth one. Additionally, these six programs share the same block of memory — the list of structures. It starts resembling a solution to one of our problems, doesn’t it?
Appearance of the Gaps issue
But what happens even with these simple programs when we try to transfer them to a UI environment?
“Hello World” is the easiest, as it simply gets wrapped with a bunch of components that help it “live” in the new environment. It’s not even interesting.
However, “Hello %username%” becomes much more challenging. It gets spread across different components of the system or architecture: in one place, we listen for inputting the name, in another, we display the greeting, and in yet another, we define the reaction to the entered name.
I’m even afraid to mention what happens with the CRUD example. Depending on the design given to us, we would end up writing completely different applications. Imagine being asked to create a program with multiple screens and then being asked to change it so that it becomes a single screen. With the commonly used approach of decomposing it, where one screen corresponds to one set of MVx components, we would have a sleepless night rewriting the code because our logic is split and scattered throughout the implementation.
But initially, there were no gaps and the algorithm remained the same. Why did everything turn out so poorly?
The reason is asynchrony
Some of you have already found the answer to this question and written it in the comments. The reason is asynchrony.
Many, if not all, GUI systems are built around an event loop because we need to simultaneously render the screen and listen for user input. In order to incorporate our algorithm into this event loop, we need to divide it in a way that integrates well with the event loop.
I’m not even mentioning the fact that we usually need to interact with other asynchronous systems. But in most cases, there are no such issues when we are dealing with them. Why?
The solution is closing the Gaps
Let’s focus on the graph-like image I used to explain the Gaps issue last time.
And let’s recall how things were, and this time I won’t deceive you: the path of our logic starts in one of the callbacks. As we progress through the logic, we go deeper into the call stack, executing functions one after another. At the topmost point of our graph, we access a data source: a backend, a file or some storage system. And what is usually found here?
Usually, it’s a call to some “asynchronous” function: coroutines, async- or suspend-functions (excuse my Kotlin jargon), a function with a callback, or a function returning a Future, Promise, or Single. Now, here’s the question: when we call this function with a callback, how often do we consider that this operation may never return to that callback? Personally, until recently, I believed that control would always be passed to our callback, except for cases of cancellation, of course. But where does this guarantee come from? Could it be the implementation of the subsystem? Let’s look under the hood and see what really happens.
Our function creates our database query, network request, or something similar. In general, it prepares some context in which the request must be executed, and along with the callback to which the result must be returned, it sends it to another thread, where the execution takes place. After completion, that thread invokes the callback, passing the result into it, which appears to us developers as a kind of return to the calling point. Thus, the logic appears more cohesive, without the kind of discontinuity we encounter with UI.
So here’s the question: why don’t we replicate this same trick with the UI?
In the image, it would look like a parallel shift: we simply raise our line, and here, at the bottom point, instead of breaking the logic and separately defining some UI state and callbacks, we create a function that sends a request to the UI and waits for its response. This way, we close the gaps, and now our logic appears as whole.
It’s not complicated at all; we’re not inventing anything new. But this approach helps us interact with the UI as we would with any other external system, rather than treating it as something special. And this is my vision of how it could work and solve the problems described above.
Proposal
Let’s write functions…
Yes, it may sound illogical, especially since I have already mentioned that it doesn’t help Flux- and ELM-like architectures, but let me explain. Let’s start with the Remainder issue.
The solution to the Reminder issue
Let me add a small analogy from math. As I mentioned before, “architecture” is a divisor. And in order to have no remainder in division, we need to find the greatest common divisor. This is exactly what a function represents because if we look at all the implementations of our “architectures”, we will see that they are essentially just different ways of combining and specializing functions (we all agreed that a method is a function that sometimes implicitly takes an additional argument, right?).
Now let’s move on to the Scalability issue.
The solution to the Scalability issue
Functions scale very well because, from an implementation perspective, they can contain any set of instructions, including calls to other functions. And from a user’s perspective, functions are always invoked in the same manner: name, parameters, result, regardless of how they are implemented.
But what about the Gaps issue?
The solution to the Gaps issue
As long as we maintain our logic as a composition of functions, the issue of gaps or discontinuities will naturally be pushed outward, beyond the boundaries of our logic. This, in turn, will guarantee us execution order, testability, and, as a result, stability.
Concept
So what am I proposing?
First of all, we should implement logic as functions and their composition, rather than as components of some architecture. This will allow us to guarantee and maintain the execution order, which will positively impact the scalability and testability of our code, and hopefully make it easier to understand.
Secondly, this function (function composition) should have the ability to work independently of “external” systems (asynchronously, if you will). Therefore, we need to move it to its own thread, so that it can execute smoothly, block when necessary, parallelize, and so on. Let it be “synchronous.” Often, we don’t need the logic to continue execution when it’s waiting for a resource. And if such behavior is needed, why not describe it explicitly? We could call this thread “Main,” but the name is already taken. =)
Thirdly, although this is the most important point, and I seem to have a tendency to leave the important things for later, we need to focus on implementing application logic, not screens. As you can see with such an approach there is no more difference between data layer and UI layer. There’re only our deterministic logic and external systems, which are sharing data through it. The logic simply “walks” between them and provides them with a context for decision-making and a set of possible actions, while the external systems, in turn, “respond” to our logic based on the presented context. This is similar to playing chess, for example, where the external systems are the players, and the logic is the board with pieces and the rules of the game.
The logic of our features often doesn’t depend on the presentation; it’s actually the opposite. The presentation is a way that helps the logic interact with the user in a specific environment, like an adapter. And such an adapter should be a plugin to the logic, not the determining factor.
Finally, by applying this concept, I want to help all of us design and develop cross-platform applications in the broadest sense of the word: by developing and implementing end-to-end algorithms that seamlessly transition between backend and frontend, which don’t see differences between different frontends, whether it’s Web, iOS, Android, or different presentations like UI, CLI, or TalkBack.
But for now, this is just a concept and a vision. It’s probably time to look at some code, but that will be for next time. For now, take some time to reflect on this topic. Read it again. Let it sink in. Ask questions. And maybe you can write the code before I publish the continuation. Are you up for the challenge?
See you soon.
Oh, and if you like this, consider to subscribe to my Patreon. I’m still not sure if it is a good idea, but I would really appreciate to see a people interested enough. Together we will be able to reshape the future.