Arun Pandian M

Arun Pandian M

Android Dev | Full-Stack & AI Learner

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:

  • there is a neutral starting value
  • results can be safely combined
  • the order of grouping doesn’t break correctness
  • 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:

  • validations split across modules
  • dynamic validations (feature flags)
  • server-driven forms
  • reuse between backend & mobile
  • parallel execution
  • 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?

    https://storage.googleapis.com/lambdabricks-cd393.firebasestorage.app/monoid_imp_dec_validation.svg?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=firebase-adminsdk-fbsvc%40lambdabricks-cd393.iam.gserviceaccount.com%2F20260225%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20260225T015017Z&X-Goog-Expires=3600&X-Goog-SignedHeaders=host&X-Goog-Signature=46e30c0c29a1060c377c5fb2c1026b895c80a4cb73fb132230a2872d4eff8264b937bf4e675dfb56a451706b99e82cf8776b43c6abc65d8e9500e2ac43f9389abc3a951c4a6d7bf0aaf0e4a5a4a39b3f34b3235010c90d7a0b124612d0aeb17af26e8c300a7c44570d515625777defc3be2eb3d4d86fdec7333baaa820e29235bdcca23a60b6b1ce788f5049d608c7bd9ba2fa24e0297511c2f519476ed8955f5c24aee506c757716e1d20b68431a3bef28ea75444d0e372b87d9eb31efe5cec5062ef05fc070557fd5801899e92bfb7e5fec8d79d6f71bc1acd31108bac93be62e940f31642be780bf978580e6059ec10860e5735ca3d2609921e8f6c1a07fc

    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 → works

    You 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 = null

    Problems:

  • order dependent
  • race conditions
  • hard to replay
  • difficult debugging
  • 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) -> UiState

    Define 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:

  • replayable
  • testable
  • reorder safe
  • deterministic
  • We are not mutating state. We are composing behaviors.

    https://storage.googleapis.com/lambdabricks-cd393.firebasestorage.app/monoid_uistate_sample.svg?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=firebase-adminsdk-fbsvc%40lambdabricks-cd393.iam.gserviceaccount.com%2F20260225%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20260225T015017Z&X-Goog-Expires=3600&X-Goog-SignedHeaders=host&X-Goog-Signature=2873e4e3c0f2f279ce58bd90ac97fc49505aec9e5c7c509686e18226e7fc9018311a0e5fab42262c800022388579f9bc28c11c1cfd5ccca83d380255b1ce557a890a59717a1ae57717fe0e076b201b16ac3cc029ee16182b9fdb504c50742b5e1caf912be09be4f026c527508569e7f664e4d55ecb69286c5b0f9d64d965360d7a9148d2a99798f709c4a1d0fe32c9ef031a737d33c35b1c033aa60d759f3a842a61600054696a4b7e5026db8d5ca2548ab9c3781bae16b5e5697c803aad818662830a63dcfa8ae609e6eb7a7846262e259afef335ae4866a84bf98f339e46518853898442407c0333eafd9a0bc43cf6a2297cde7739629e29b83c95921f0578

    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.

    #MathForDevelopers#FunctionalProgramming#Immutability#BuildInPublic#FPFoundations#ProgrammingConcepts#KotlinFP#Monoid#Composability#SoftwareDesign#CleanArchitecture#DeclarativeProgramming#StateTransformation#ReducerPattern#PredictableState