DEV Community

loading...
Cover image for A case against the MVI architecture pattern

A case against the MVI architecture pattern

feresr profile image Raviola Updated on ・6 min read

My opinions are based on my own experience working with different implementations of MVI applied to both personal projects and at my day job.

To begin with, let me say that MVI looks very appealing at first sight. It combines patterns like Command and Observer and makes good use of functional reactive programming techniques. In short, MVI sort of defines a meta-architecture where every action the user can take is defined by an Input class and every state a view can be in must also be defined by a subclass of the State class.

I'm assuming you already know how this works, there are more classes like these in MVI including Actions, Results; among others, depending on each specific implementation.

The problem with MVI

My biggest pet peeve with this architecture is not how boiler-platy it can get, or how opinionated certain implementations can be.

No, the biggest problem I see with the architecture is how much it hurts overall code readability and in turn, developer happiness.

In my opinion, traditional MVI implementations artificially group ‘unrelated code’ and encourages ‘related code’ to be spread out among as many different classes as possible.
That's a big claim, let me explain what I mean.

When writing a new piece of code, you only care about the feature you are writing/reading and not much else. If you are fixing a bug, you are usually focused on a specific flow where the bug is happening, trying as hard as you can to reproduce it. The faster you can build a mental model of how this feature works the faster you'll fix the bug. So it helps if the code for the feature grouped together and isolated from any other feature (low coupling, high cohesion)

Unfortunately, this is usually how code ends up looking in MVI:

    fun intentToAction(intent: HomeIntent): HomeAction {
        return when (intent) {
            is HomeIntent.LoadAllCharacters -> HomeAction.AllCharacters
            is HomeIntent.ClearSearch -> HomeAction.AllCharacters
            is HomeIntent.SearchCharacter -> HomeAction.SearchCharacters(intent.name)
            is ...
        }
    }
Enter fullscreen mode Exit fullscreen mode

See how "your flow" is buried between other completely unrelated flows in that snippet? You probably only care about one single line in that method at a time.

We are never, ever, going to read that snippet of code from top to bottom (you know, like we usually read things). So why are we writing it like that? It's hard to write, it's hard to read.

You might be thinking "well, that doesn't looks so bad". Now imagine 10 or 20 more lines in the when statement, which basically only perform a trivial mapping from two very similar sounding classes FooInput -> FooAction.

It doesn't stop there though, in many implementations actions get mapped to results, and then results get mapped to states:

fun Result<List<Persona>>.reduce(isSearchMode: Boolean = false): HomeState {
    return when (this) {
        is Result.Success -> if (isSearchMode) HomeState.ResultSearch(data) else HomeState.ResultAllPersona(data)
        is Result.Error -> HomeState.Exception(exception)
        is Result.Loading -> HomeState.Loading
    }
}
Enter fullscreen mode Exit fullscreen mode

Take a moment to think how you would follow the code in your head while trying to find that nasty bug. You start from the View, jump to the ViewModel, and see the first mapper (inputs -> actions). From there there's not clear path to take (or IDE shortcut to use) to jump to where the flow continues (since the architecture usually hides the wiring from the clients). You basically need to do a cmd+f of the action name (or cmd+b to get a list of places where it's used/declared) to try and find where that action is being processed, that's usually in the VM as well, some implementations define a UseCase and place the mapper there, it really could be anywhere.
Ok, you found the Actions -> Results mapper, you found the one line you care about in that mapper, what now? Now, now you jump yet again! this time to the Reducer and you find... another mapper! At this point you forgot what you where doing entirely.

MVI makes reading code a very awkward experience. It’s analogous to reading a book where each next word is on a new page. You need to keep flipping pages (going to a different class) to know what comes next. You end up spending more time finding code, than reading code.

arch

All this added overhead makes it easy for bugs to hide in plain sight. This gets more tricky when you take into consideration Threading, RxJava/Coroutines, Asynchronous events, Lifecycle scopes, etc.

I understand where MVI is coming from here though, if multiple Inputs produce the same Action then we can simplify things a bit. We can break the dependency between Inputs, Actions and results. Unfortunately, in practice this is almost never the case. Your flows won't share Inputs, Actions, or Results. It's more likely that for every "flow" in your app, you'll have one of each.

Reducer is a leaky abstraction

Don't get me wrong. There are valid reasons for splitting code.
A good example of this are ViewModels. A ViewModel exists not because a particular architecture requires it, it exists because it makes sense. It's OS-agnostic, it serves as an interface from the view to the business logic, enables testing, etc. In other words, it has a propose that's beyond the architecture agenda. You could A/B test different views using the very same VM and everything would still work.
In the same way, UseCase exists because it's a reusable component that might be used from different VMs. Same goes for Repositories, a repository can stand on its own without the vm and be re-used from other places. All these classes exist with a clear goal and purpose. The logic is split sensibly.

On the other hand, let's look at MVI's Reducer. A Reducer is very specific to a particular VM (not reusable) why is it extracted out of the VM then?
"For testing purposes" I hear you say. That's not a good enough of a reason IMO.
Ask yourself this: "Should I really be extracting private methods into a separate class solely to be able to test them?" I personally don't think so, those methods are private for a reason, they are implementation details, they are not meant to be unit tested directly. If you are Ok with that, what's stopping you from extracting every single private method from every single class in your project and testing those too?

Extracting implementation logic into trivial classes only for testing proposes results into simplistic tests that become increasingly meaningless and add a ton of friction to your codebase. If you want to read more about why testing private methods is bad I highly recommend this read: Unit testing private methods by Vladimir Khorikov

Ending thoughts

There are many other reasons I dislike this architecture, It's very intrusive, it adds a ton of boilerplate, it can be ambiguous, etc. Those are not exclusive to MVI and I could live with some of those drawbacks. I'm sure MVI might fit some applications better than other, but in all cases, readability suffers.

All in all, MVI looks elegant from a theoretical standpoint but I have yet to see an implementation that actually works and scales well for big projects.

I'm very happy that Jetpack compose is just around the corner! Up until now I've been using nested custom views + coroutines and structured concurrency in my apps (something that kind of resembles the composable approach that Google is taking with Jetpack Compose). It's nice to know that his approach will soon become more mainstream.

Website

Twitter


EDIT:

I've been told not all implementations extract the reducer into its own class. I think that's a good thing! Other problems with the patter still remain though. Most MVI implementations unnecessarily force you to reduce ALL your features in the same function/class (resulting in giant when statements, mixing flows, grouping unrelated code, and making things hard to read overall).

I was also told that big when statements/mappers can be prevented by using composable "sub-view-models". IMO, splitting this when statements into smaller ones doesn't not fix the underlying problem, it only adds more boilerplate. More classes that just redirect you somewhere else. You are still reading code in a perpendicular way as to what you write. And again, you end up spending more time trying to find code than actually reading code.

Lastly, I've been presented with this architecture: https://github.com/orbit-mvi/orbit-mvi
Which I haven't tried but I like what I see, it throws away many MVI made up restrictions while keeping the ones that work. Feature flows read linearly and the state reduction happens contextually (as opposed to grouped into one big "reducer" function/class.

Discussion (10)

pic
Editor guide
Collapse
viktorpetrovski profile image
Viktor Petrovski

I definitely feel the same way about MVI, it looks good on paper but is so hard to read. I recently adopted a codebase that used MVI, personally I think they did a lot of things wrong in their MVI, but I can definitely related that I've spend majority of my time trying to understand what the heck is going on.

Collapse
emitchel profile image
Elliot Mitchell

I agree that it can be hard to follow the flow and I shared similar thoughts around boilerplate, ctrl+b eventually becomes your best friend - which it should be anyways!

Where we disagree is that MVI really comes to shine in larger code bases with complex UI interactions, and is overkill for simple UI. When you think about refactoring or adding on more functionality, MVI pinpoints the exact interactions that already occur and what their side effects are, and eventually results in the exact state expected - you know exactly what to expect and how to modify pre existing states. Without that unidirectional flow of intentions, the codebase only becomes increasingly more difficult to scale on top of and maintain.

Collapse
feresr profile image
Raviola Author • Edited

Hi! Thanks for reading, appreciate the feedback!

"MVI pinpoints the exact interactions that already occur and what their side effects are"

MVI does not do this by default though, it requires developers to model those interactions themselves. MVI only provides the framework to do so, just like any other architecture pattern. And MVI is really strict about this, it imposes a lot of artifacts and rules down your throat. Rules and constraints can be good, but in my opinion, the ones MVI encourages are not.

Without that unidirectional flow of intentions, the codebase only becomes increasingly more difficult to scale

The idea of the unidirectional flow is nice, you can still implement it without strictly adhering to all MVI ruleset or go with a different architecture altogether

Collapse
mochadwi profile image
Mochamad Iqbal Dwi Cahyo

Have you consider to take a look the pedroql.github.io/mvflow/? MVFlow eliminates those drawbacks (imho)

tl;dr: github.com/pedroql/mvflow/blob/mas...

Collapse
feresr profile image
Raviola Author • Edited

I don't see how that eliminates the drawbacks. Let's take a flow in your example: the Reset counter flow. To understand how it works you need to go to line 45 in the handler, read one single line, then jump to line 67 in the reducer, read another single line, and the jump again!. Both lines are surrounded with completely unrelated code.
Also, it's not clear where to jump exactly, no shortcut will take you where the code should continue, cmd+f/b is your best bet and even so it might take you a while.

In the end, you spend more time jumping around trying to find code than actually reading code.

Collapse
mochadwi profile image
Mochamad Iqbal Dwi Cahyo

for now the MVFlow is the simplest we can think of at the moment, also I'm fully agreed with you

Both lines are surrounded with completely unrelated code

any suggestion at the moment to optimize this instead?

spend more time jumping around trying to find code than actually reading code.

Thread Thread
feresr profile image
Raviola Author

any suggestion at the moment to optimize this instead?

Personally, I'd just switch to another Archtecture pattern (MVVM) or use an MVI library that does not impose strict rules regarding where you have to do the mapping, and where you have to perform state reduction.

This library for example avoids the (intent -> action) mappings altogether. When you call add you can follow the flow linearly and the state is reduced contextually.

orbitlibrary

Thread Thread
mochadwi profile image
Mochamad Iqbal Dwi Cahyo • Edited

thanks for the suggestion, the Orbit-MVI 3 seems fit our usecases and a simplified version! ❤️

Collapse
genaku profile image
Gennadiy Kuchergin • Edited

Thanks for the article! You inspired me to do it even easier than orbit-mvi.
github.com/genaku/Reduce#simplify

Collapse
ikakus profile image
ikakus

github.com/badoo/MVICore
This one is my favorite. It is very simple actually, and it IS used for years already in a huge project.