Reactive programming has gained significant popularity over the past decade. Frameworks and libraries such as RxJava, Project Reactor, and even JavaScript’s RxJS have become cornerstones of many developers’ toolkits. In the Android realm, modern reactive programming is represented by the Kotlin Flow framework. While reactive programming is undoubtedly a powerful technique, there are compelling reasons to question whether its drawbacks outweigh its benefits.
Reactive Construct vs Reactive Programming
Consider this example:
class Observable { private val _sharedFlow = MutableSharedFlow<String?>() val sharedFlow: SharedFlow<String?> get() = _sharedFlow ... private suspend fun emitValue(value: String?) { _sharedFlow.emit(value?) } } class Observer(observable: Observable) { init { CoroutineScope(Dispatchers.Default).launch { observable.sharedFlow.filterNotNull().collect { value -> ... } } } }
Here, I use SharedFlow
to establish communication between two classes. SharedFlow
is a reactive construct, but I would argue that this example doesn’t necessarily represent the reactive programming paradigm. Essentially, this code illustrates a classical Observer design pattern with some syntactic sugar on top. This example is not what I’ll be discussing when addressing the downsides of reactive programming. In my view, this is a completely valid way to implement your Observers.
Now, let’s extend our example:
class Observable(service1: Service1, service2: Service2, service3: Service3) { private val _sharedFlow = combine(service1.flow1, service2.flow2, service3.flow3, ::ObservableIntermediate) .filter { data -> data.intValue > 0 && data.boolValue } .flatMapConcat { data -> flowOf("${data.stringValue}-${data.intValue}-${data.boolValue}") } .distinctUntilChanged() .shareIn(CoroutineScope(Dispatchers.Default), SharingStarted.Eagerly) val sharedFlow: SharedFlow<String> get() = _sharedFlow private data class ObservableIntermediate(val intValue: Int, val stringValue: String, val boolValue: Boolean) } class Observer(observable: Observable, service4: Service4) { private val combinedFlow = observable.sharedFlow .combine(service4.flow4) { observableValue, doubleValue -> ObserverIntermediate(observableValue, doubleValue) } .flatMapLatest { intermediateData -> processIntermediateData(intermediateData) } init { CoroutineScope(Dispatchers.Default).launch { combinedFlow.collect { result -> ... } } } private fun processIntermediateData(data: ObserverIntermediate): Flow<String> { return flowOf(...) } private data class ObserverIntermediate(val observableValue: String, val flow4Value: Double) }
This is what we’ll call reactive programming and discuss in this article.
Clearly, the two examples are related: they represent two points on a continuous reactive programming “scale.” Therefore, when I say that I don’t mind the first approach but will advocate against the second, I suggest that, up to a certain point on that scale, you can use “reactive constructs.” Beyond that point, you’re in the realm of reactive programming.
I can’t formally define the exact threshold of reactive programming, but, in my experience, problems begin when you start combining flows, using flatMap
and other advanced operators, or accumulate long reactive chains spanning multiple components.
Benefits of Reactive Programming
Reactive programming is a powerful paradigm with numerous strengths.
- The operators provided by reactive frameworks are concise and allow you to handle many standard programming tasks with ease. From handy shortcuts like
filter
,map
, anddistinctUntilChanged
to versatileflatMap
and others, you can implement complex requirements with just a few lines of code. - Reactive frameworks are well-suited to dealing with real-time streams and managing backpressure.
- Frameworks like Project Reactor and Spring WebFlux can leverage non-blocking I/O, reducing load in highly concurrent, I/O-bound systems (such as high-throughput servers).
These are real benefits, although the last two are irrelevant to most software systems in general, and to the vast majority of Android applications specifically. Yet, I’d argue that the downsides of reactive programming still outweigh the benefits.
Increased Complexity
Martin Fowler famously said:
Any fool can write code that a computer can understand. Good programmers write code that humans can understand.
Martin Fowler
Code written by good developers reads like well-written prose. It is expressive, straightforward, and leverages properly named abstractions, allowing the reader to focus on individual features in isolation. When junior developers see such code, they are not impressed and are fully convinced they could write it themselves.
The best developers write the simplest code.
Reactive programming introduces a paradigm shift that requires developers to think in terms of streams, observables, operators, and event propagation. While this may be second nature to some, for most developers, this shift represents a steep learning curve. And I’m not talking about junior developers— even experts can’t understand reactive code without formal training in the technique. Reactive programming isn’t something you can pick up from context.
In essence, the reader of fully reactive code must have the same level of skill as its author. This inverts the standard author-reader skill relationship.
This coupling of skill levels may not be an issue for a single-developer project, but on any sufficiently large project with multiple developers, it leads to problems due to skill mismatches. It also makes onboarding new developers harder and more costly.
In my opinion, this drawback alone outweighs all the benefits of reactive programming. But it’s not the only one.
Architectural Lock-In
Many developers don’t perceive reactive programming as part of their app’s architecture, but it is.
According to Grady Booch:
Architecture represents the significant design decisions that shape a system, where significant is measured by cost of change.
Grady Booch
Reactive programming is an alternative, complex, and very different programming paradigm. Adopting reactive programming essentially couples everything in your application to a specific framework. Since it is so different and intrusive, reversing this decision becomes prohibitively expensive. In practice, migrating a fully reactive application back to a traditional approach often amounts to a complete rewrite.
To emphasize this point further, contrast reactive programming with another architectural pattern: dependency injection.
If you implement proper dependency injection in your app, you generally don’t see it or need to think about it. If you encounter a bug in your logic, you can investigate and fix it without interacting with the dependency injection infrastructure. In contrast, if all components in your app are reactive, you face the reactive programming framework at every step.
Switching dependency injection frameworks can be challenging but manageable. These frameworks typically live on the periphery of your code, so they aren’t deeply coupled to your application’s logic. On the other hand, swapping one reactive programming framework for another likely requires refactoring most classes in your project. This is a massive undertaking, and debugging the resulting subtle bugs will be an uphill battle.
Reactive programming introduces a very strong architectural lock-in.
Debugging and Tooling Challenges
Reactive programming heavily relies on powerful operators, which can be a double-edged sword. While these operators let you implement complex requirements with ease, they also obscure details, making debugging exceptionally difficult. You can no longer place breakpoints wherever you want, and logging becomes cumbersome. Errors in reactive streams often surface far from their origin, and stack traces frequently lack actionable information.
Additionally, the tooling ecosystem for reactive programming is still catching up. While progress has been made, most IDEs and debugging tools struggle to offer the same level of support for reactive code as they do for traditional code.
Conclusion
Reactive programming is not the silver bullet it is often made out to be. Its complexities and trade-offs make it a poor fit for most common use cases, and its benefits are frequently overshadowed by the challenges it introduces. While there are specific situations where reactive programming can shine, adopting this paradigm across entire projects rarely makes sense.
In most cases, traditional approaches are more than sufficient to achieve the desired results without the downsides of reactive programming. Instead of jumping on the reactive bandwagon, developers should consider the long-term maintainability, readability, and simplicity of their code.