Reduce. Conquer. Repeat.
- About
- Overview
- Mathematical proof
- Comparison with popular patterns
- Clean Architecture
- Proof of concept
This repository contains a proof of concept of the Reduce & Conquer pattern built into the Clean Architecture, using the example of a cross-platform Pokédex application built using the Compose Multiplatform UI Framework.
Reduce & Conquer is an architectural pattern leveraging functional programming principles and pure functions to create predictable and testable functional components.
classDiagram
class Feature {
- initialState: State
- reducer: Reducer<Command, State, Event>
- _state: MutableStateFlow<State>
- _events: Channel<Event>
+ state: StateFlow<State>
+ events: Flow<Event>
+ featureScope: CoroutineScope
+ execute(command: Command): Boolean
+ invokeOnClose(block: () -> Unit)
+ close()
}
class Reducer {
+suspend reduce(state: State, command: Command): Transition<State, Event>
+transition(state: State, vararg event: Event): Transition<State, Event>
}
class Transition {
+state: State
+events: List<Event>
+mergeEvents(vararg event: Event): Transition<State, Event>
+mergeEvents(events: List<Event>): Transition<State, Event>
}
Feature --> Reducer
Feature --> Transition
Reducer --> Transition
Tip
The idempotent nature of deterministic state allows you to implement functionality such as rolling back the state to a previous version.
A class or object that describes the current state of the presentation.
A class or object that describes an action that entails updating state and/or raising events.
Note
It's not a side effect because reduce is a pure function that returns the same result for the same arguments.
A class or object that describes the "Fire and forget" event caused by the execution of a command and the reduction
of the presentation state.
May contain a payload.
An abstract class that takes three type parameters: Command
, State
and Event
.
A functional unit or aggregate of presentation logic within isolated functionality.
state
: A read-only state flow that exposes the current state.events
: A flow that exposes the events emitted by the feature.featureScope
: A coroutine scope that allows for asynchronous execution.
execute(command: Command)
: Executes a command and updates the state and emits corresponding events.close()
: Cancels ongoing operations and frees resources.
A functional interface that takes three generic type parameters: Command
, State
and Event
.
A stateless component responsible for reducing the input command to a new state and generating events.
reduce(state: State, command: Command)
: Reduces theState
with the givenCommand
and returns aTransition
transition(state: State, vararg event: Event)
: Constructs aTransition
with the givenState
and variadicEvent
.transition(state: State, events: List<Event> = emptyList())
: Constructs aTransition
with the givenState
and list ofEvent
s.
A data class that represents a state transition.
state
: The newState
.events
: A list ofEvent
s emitted during the transition, which can be empty.
mergeEvents(vararg event: Event)
: Takes a variadicEvent
and merges it with theEvent
s of a given transition.mergeEvents(events: List<Event>)
: Takes a list ofEvent
s and merges them with theEvent
s of a given transition.
Important
Events passed as an argument will be processed BEFORE current events.
This is due to the fact that mergeEvent
is used for already created events.
Let
We define a function
The function
-
Associativity: For all
$s \in S$ ,$c_1, c_2 \in C$ , we have:$$R(R(s, c_1), c_2) = R(s, [c_1, c_2])$$ where$[c_1, c_2]$ denotes the composition of commands$c_1$ and$c_2$ . -
Commutativity (under specific conditions): For all
$s \in S$ ,$c_1, c_2 \in C$ such that$c_1 \circ c_2 = c_2 \circ c_1$ , we have:$$R(s, c_1) = R(s, c_2)$$
Let
-
Apply Command
$c_1$ :$$R(s, c_1) = (s_1, e_1)$$ where$s_1$ is the new state and$e_1$ is the event generated by applying$c_1$ to state$s$ . -
Apply Command
$c_2$ to the New State$s_1$ :$$R(s_1, c_2) = (s_2, e_2)$$ where$s_2$ is the new state after applying$c_2$ to$s_1$ and$e_2$ is the event generated. -
Sequential Application of Commands
$c_1$ and$c_2$ :$$R(s, c_1 \circ c_2) = (s_2, e_1 \cup e_2)$$ where$c_1 \circ c_2$ denotes applying$c_1$ first, resulting in$s_1$ and$e_1$ , and then applying$c_2$ to$s_1$ , resulting in$s_2$ and$e_2$ .
Since both
This shows that the reduction function satisfies associativity in the context of command composition.
For commutativity under specific conditions where commands are commutative:
Let
-
Apply Command
$c_1$ and then$c_2$ :$$R(s, c_1) = (s_1, e_1)$$ $$R(s_1, c_2) = (s_2, e_2)$$ where$s_2$ is the state resulting from applying$c_2$ to$s_1$ and$e_2$ is the event generated. -
Apply Command
$c_2$ and then$c_1$ :$$R(s, c_2) = (s_1', e_1')$$ $$R(s_1', c_1) = (s_2', e_2')$$ where$s_2'$ is the state resulting from applying$c_1$ to$s_1'$ and$e_2'$ is the event generated.
Since
Thus, we have:
This demonstrates the commutativity of the reduction function under the specific condition of commutative commands.
We have successfully proved that the reduction function
The associativity property ensures that the order in which commands are applied does not affect the final state and events, while the commutativity property ensures that commands can be applied in any order without affecting the result under specific conditions. These properties provide a solid foundation for ensuring the correctness and reliability of the system, influencing its design and maintenance.
The MVC pattern separates concerns into three parts: Model
, View
, and Controller
.
The Model
represents the data, the View
represents the UI,
and the Controller
handles user input and updates the Model
.
In contrast, the Reduce & Conquer combines the Model
and Controller
into a single unit.
The MVP pattern is similar to MVC,
but it separates concerns into three parts: Model
, View
, andPresenter
.
The Presenter
acts as an intermediary between the Model
and View
, handling user input and updating
the Model
.
The Reduce & Conquer is more lightweight than MVP, as it does not require a separate Presenter
layer.
The MVVM pattern is similar to MVP,
but it uses a ViewModel
as an intermediary between the Model
and View
.
The ViewModel
exposes data and commands to the View
, which can then bind to them.
The Reduce & Conquer is more flexible than MVVM, as it does not require a separate ViewModel
layer.
The MVI pattern is similar to MVVM,
but it uses an Intent
as an intermediary between the Model
andView
.
The Intent
represents user input and intent, which is then used to update the Model
.
The Reduce & Conquer is more simple than MVI, as it does not require an Intent
layer.
The Redux pattern uses a global store to manage application state.
Actions are dispatched to update the store, which then triggers updates to connected components.
The Reduce & Conquer uses a local state flow instead of a global store,
which makes it more scalable for large applications.
The TEA pattern uses a functional programming approach to manage application state.
The architecture consists of four parts: Model
, Update
, View
, and Input
.
The Model
represents application state,
Update
functions update the Model
based on user input and commands,
View
functions render the Model
to the UI, and Input
functions handle user input.
The Reduce & Conquer uses a similar approach to TEA, but with a focus on reactive programming and
coroutines.
The EDA pattern involves processing events as they occur.
In this pattern, components are decoupled from each other, and events are used to communicate between components.
The Reduce & Conquer uses events to communicate between components,
but it also provides a more structured approach to managing state transitions.
The Reactive Architecture pattern involves using reactive programming to manage complex systems.
In this pattern, components are designed to react to changes in their inputs.
The Reduce & Conquer uses reactive programming to manage state transitions and emit events.
Clean Architecture is a software design pattern that separates the application's business logic into layers, each with its own responsibilities.
The main idea is to create a clear separation of concerns, making it easier to maintain, test, and scale the system.
graph LR
subgraph "Presentation Layer"
View["View"] --> Feature["Feature"]
Feature["Feature"] --> Reducer["Reducer"]
end
subgraph "Domain Layer"
UseCase["Use Case"] --> Repository["Repository"]
UseCase["Use Case"] --> Entity["Entity"]
end
subgraph "Infrastructure Layer"
direction TB
Dao["DAO"] --> Database["Database"]
Service["Service"] --> FileSystem["File System"]
Service["Service"] --> NetworkClient["Network Client"]
end
Reducer --> UseCase
Repository --> Dao
Repository --> Service
Clean Architecture can be represented as follows:
View(
Feature(
Reducer(
UseCase(
Repository(
Service
)
)
)
)
)
Tip
Organize your package structure by overall model or functionality rather than by purpose. This type of architecture is called "screaming".
Representing the business domain, such as users, products, or orders.
Defining the actions that can be performed on the entities, such as logging in, creating an order, or updating a user.
Handling communication between the application and external systems, such as databases, networks, or file systems.
Providing the necessary infrastructure for the application to run, such as web servers, databases, or operating systems.
Reduce & Conquer
is a part of Frameworks and Drivers
, as it is an architectural pattern that provides an
implementation of presentation.
Tip
Follow the Feature per View principle and achieve decomposition by dividing reducers into sub-reducers.
The Feature
class contains methods that implement the flow mechanism, but you can also implement your own
using the principles described below.
Let's say there is a command that calls a use case, which returns a flow
with data that needs to be stored in
the state.
As a container, flow
is only useful as long as it is collected, which means it can be classified as a one-time
payload.
As should be done with this kind of data, flow
must be processed using the appropriate mechanism - events,
which must begin to be collected before executing the command that returns the event containing flow
.
Thus, we can set an arbitrary flow
processing strategy, as well as manage the lifecycle of the collector using
coroutines, without going beyond the functional paradigm.
Here is an example implementation of flow collection:
data class User(val id: String)
interface UserRepository {
suspend fun getUsers(): Result<Flow<User>>
}
class GetUsers(private val userRepository: UserRepository) {
suspend fun execute() = userRepository.getUsers()
}
sealed interface UserCommand {
data class AddUser(val user: User) : UserCommand
data object GetUsers : UserCommand
}
data class UserState(
val users: List<User> = emptyList(),
)
sealed interface UserEvent : Event {
data class Error(val exception: Exception) : UserEvent
data class UserUpdates(val users: Flow<User>) : UserEvent
}
class UserReducer(
private val getUsers: GetUsers,
) : Reducer<UserCommand, UserState, UserEvent> {
override suspend fun reduce(state: UserState, command: UserCommand) = when (command) {
is UserCommand.AddUser -> transition(state.copy(users = state.users.plus(user)))
is UserCommand.GetUsers -> getUsers.execute().fold(
onSuccess = { users: Flow<User> ->
transition(state, UserEvent.UserUpdates(users = users))
},
onFailure = {
transition(state, UserEvent.Error(Exception(it)))
}
)
else -> transition(state)
}
}
class UserFeature(reducer: UserReducer) : Feature<UserCommand, UserState, UserEvent>(
initialState = UserState(),
reducer = reducer
) {
init {
events.filterIsInstance<UserEvent.UserUpdates>().map { event: UserEvent.UserUpdates ->
event.users.collect { user: User ->
execute(UserCommand.AddUser(user = user))
}
}.launchIn(featureScope)
featureScope.launch {
execute(UserCommand.GetUsers)
}
}
}
Due to the fact that we start the collection once, there is no need to manage the collection flow
,
job is not stored in a variable.
It is assumed that all the important logic is contained in the Reducer
, which means that the testing pipeline can be
roughly represented as follows:
val (actualState, actualEvents) = feature.execute(command)
assertEquals(expectedState, actualState)
assertEquals(expectedEvents, actualEvents)
A cross-platform Pokédex application built using the Compose Multiplatform UI Framework.
graph TD
subgraph "Use Case"
GetMaxAttributeValue["Get Max Attribute Value"]
GetDailyPokemon["Get Daily Pokemon"]
GetPokemons["Get Pokemons"]
InitializeFilters["Initialize Filters"]
GetFilters["Get Filters"]
SelectFilter["Select Filter"]
UpdateFilter["Update Filter"]
ResetFilter["Reset Filter"]
ResetFilters["Reset Filters"]
CardsReducer["Cards Reducer"]
ChangeSort["Change Sort"]
end
subgraph "Navigation"
NavigationView["Navigation View"] --> NavigationFeature["Navigation Feature"]
NavigationFeature["Navigation Feature"] --> NavigationReducer["Navigation Reducer"]
end
NavigationReducer["Navigation Reducer"] --> NavigationCommand["Navigation Command"]
NavigationCommand["Navigation Command"] --> DailyView["Daily View"]
NavigationCommand["Navigation Command"] --> PokedexView["Pokedex View"]
subgraph "Daily"
DailyView["Daily View"] --> DailyFeature["Daily Feature"]
DailyFeature["Daily Feature"] --> DailyReducer["Daily Reducer"]
end
DailyReducer["Daily Reducer"] --> GetMaxAttributeValue["Get Max Attribute Value"]
DailyReducer["Daily Reducer"] --> GetDailyPokemon["Get Daily Pokemon"]
subgraph "Pokedex"
PokedexView["Pokedex View"] --> PokedexFeature["Pokedex Feature"]
PokedexFeature["Pokedex Feature"] --> PokedexReducer["Pokedex Reducer"]
PokedexReducer["Pokedex Reducer"] --> CardsReducer["Cards Reducer"]
PokedexReducer["Pokedex Reducer"] --> FilterReducer["Filter Reducer"]
PokedexReducer["Pokedex Reducer"] --> SortReducer["Sort Reducer"]
end
PokedexReducer["Pokedex Reducer"] --> CardsReducer["Cards Reducer"]
CardsReducer["Cards Reducer"] --> GetMaxAttributeValue["Get Max Attribute Value"]
CardsReducer["Cards Reducer"] --> GetPokemons["Get Pokemons"]
PokedexReducer["Pokedex Reducer"] --> FilterReducer["Filter Reducer"]
FilterReducer["Filter Reducer"] --> InitializeFilters["Initialize Filters"]
FilterReducer["Filter Reducer"] --> GetFilters["Get Filters"]
FilterReducer["Filter Reducer"] --> SelectFilter["Select Filter"]
FilterReducer["Filter Reducer"] --> UpdateFilter["Update Filter"]
FilterReducer["Filter Reducer"] --> ResetFilter["Reset Filter"]
FilterReducer["Filter Reducer"] --> ResetFilters["Reset Filters"]
PokedexReducer["Pokedex Reducer"] --> SortReducer["Sort Reducer"]
SortReducer["Sort Reducer"] --> CardsReducer["Cards Reducer"]
SortReducer["Sort Reducer"] --> ChangeSort["Change Sort"]
- Switching between Daily and Pokedex screens (functionality).
- Get a Pokemon of the Day card based on the current day's timestamp
- Getting a grid of Pokemon cards
- Search by name
- Multiple filtering by criteria
- Reset filtering
- Sorting by criteria
Note
The Pokemon card is a double-sided rotating card where
- front side contains name, image and type affiliation
- back side contains name and hexagonal skill graph
- Jetpack Compose Multiplatform
- Kotlin Coroutines
- Kotlin Flow
- Kotlin Datetime
- Kotlin Serialization Json
- Koin Dependency Injection
- Kotlin Multiplatform UUID
- Kotlin Coroutines Test
- Mockk