When Code Stops Mutating and Starts Composing - Understanding Monoids Through Real Kotlin Problems
Most bugs don’t come from complicated algorithms.
They come from small pieces of code changing shared state.
A flag flipped too early. A validation overwritten. A UI state updated in the wrong order.
We usually try to fix these problems with more conditions.
But there’s a quieter solution:
Stop updating things. Start combining results.
This idea leads to one of the most practical concepts from functional programming — the monoid.
A monoid simply means:
Instead of “doing steps”, we build meaning.
Let’s see how this changes real Kotlin code.
1.Validation — From Growing Condition Blocks to Composable Rules
At first, validation feels simple. We just check everything and collect errors.
Imperative Version (Collecting All Errors)
fun validate(user: User): List<String> {
val errors = mutableListOf<String>()
if (user.name.isBlank())
errors.add("Name empty")
if (user.age < 18)
errors.add("Underage")
if (!user.email.contains("@"))
errors.add("Invalid email")
return errors
}This already returns all errors. So what’s the problem? The problem appears when the system grows.
Where This Starts Breaking
Now imagine:
conditional validations
Now your function becomes:
validateUser()
validateAddress()
validatePayment()
validatePreferences()
validateDynamicRules()
validateBusinessRules()You must remember to call every function and remember the order and remember the dependencies
The validation logic becomes orchestration logic.
You are no longer writing rules — you are managing execution.
Monoid Perspective — Each Rule Is Independent
Instead of a function that validates everything, each rule validates only one thing.
It doesn’t know who called it. It doesn’t know what runs next.
It only produces a result.
data class Validation(val errors: List<String>) {
companion object {
val empty = Validation(emptyList())
}
}
fun combine(a: Validation, b: Validation) =
Validation(a.errors + b.errors)Now rules:
val nameRule: (User) -> Validation =
{ if (it.name.isBlank()) Validation(listOf("Name empty")) else Validation.empty }
val ageRule: (User) -> Validation =
{ if (it.age < 18) Validation(listOf("Underage")) else Validation.empty }
val emailRule: (User) -> Validation =
{ if (!it.email.contains("@")) Validation(listOf("Invalid email")) else Validation.empty }Compose rules instead of orchestrating calls:
val userValidator =
listOf(nameRule, ageRule, emailRule)
.map { it(user) }
.reduce(::combine)What Changed?
We removed the central controller. Instead of:
One function that knows all validations
We now have:
Independent rules that naturally combine
Now scaling becomes trivial:
+ add rule → works
+ reorder rules → works
+ parallel run → works
+ feature flag rules → works
+ remote rules → worksYou don’t modify validation. You extend it.
The Key Difference
Imperative validation scales by adding branches Monoid validation scales by adding rules
One grows in complexity One grows in composition
The Real Advantage
The benefit is not “collect all errors”.
It is:
Validation becomes data, not control flow
And once validation becomes data, it can be stored, merged, parallelized, or configured. That is the real power the monoid gives.
2.UI State — From Updates to Transformations
UI bugs often come from partial updates.
Imperative UI Updates
state.loading = true
state.data = response
state.error = nullProblems:
We are editing a shared object.
Monoid Style — Compose State Changes
Instead of mutating state, return a transformation.
```
// (A) -> A and (A) -> A → (A) -> A // composing endomorphisms
infix fun <A> ((A) -> A).andThen(next: (A) -> A): (A) -> A =
{ a -> next(this(a)) }
typealias UiChange = (UiState) -> UiStateDefine independent updates:
val showLoading: UiChange = { it.copy(loading = true) }
val showData: UiChange = { it.copy(data = response) }
val clearError: UiChange = { it.copy(error = null) }Combine them:
val update =
showLoading
.andThen(showData)
.andThen(clearError)
state = update(state)Now UI updates are:
We are not mutating state. We are composing behaviors.
What Both Examples Reveal
We didn’t optimize performance. We optimized meaning.
Instead of:
“Apply this step then that step”
We moved to:
“Combine results and apply once”
That shift is exactly what a monoid guarantees.
Final Thought
A monoid is not about numbers or lists.
It is about this promise:
If pieces can be combined safely, the system becomes predictable.
And predictability is the real scalability feature.
