Keep ViewModel Pure by Moving String Parsing to the UI Layer

Summary

A common architectural debate in Android development involves where type conversion should live: within the UI layer or inside the ViewModel/Domain layer. In the provided case, a ViewModel accepts a String from a Compose TextField, performs parsing, and handles validation logic. While this works for simple prototypes, it creates a leaky abstraction where the business logic is tethered to the input format of a specific UI component.

Root Cause

The root cause is a violation of the Single Responsibility Principle (SRP) and a failure to define a clear boundary between presentation and logic.

  • Type Pollution: The setAmount method is forced to handle String parsing, which is a UI/Input concern.
  • Hidden Side Effects: The method uses a “silent return” (return if parsing fails or value is negative), making it difficult to debug why a UI state isn’t updating.
  • Coupling: The ViewModel is no longer a pure representation of state; it is now a string parser, making it harder to unit test with raw numeric data.

Why This Happens in Real Systems

In rapid development cycles, engineers often take the “path of least resistance.”

  • UI Convenience: It is easier to pass the it argument from a TextField directly into a function than to wrap it in a conversion block.
  • Boilerplate Avoidance: Developers try to keep the UI code “clean” by moving all logic into the ViewModel, not realizing they are moving UI-specific logic (string parsing) into the Business Logic layer.
  • Improper Error Handling: Using return on a failed parse instead of propagating an error state leads to “ghost bugs” where the user types something and nothing happens.

Real-World Impact

  • Brittle Unit Tests: To test if a calculation works, you must now pass strings like "10.5" instead of the primitive 10.5.
  • Localization Failures: If the app expands to regions using commas as decimal separators (e.g., "10,5"), the toDoubleOrNull() logic inside the ViewModel might fail, requiring a change to the core logic layer instead of just the UI layer.
  • Testing Complexity: You cannot easily test the business logic’s response to invalid input without simulating string inputs.

Example or Code

// THE WRONG WAY: ViewModel is a string parser
class BadViewModel : ViewModel() {
    var srcAmount by mutableStateOf(0.0)

    fun setAmount(strNewAmount: String) {
        val newAmount = strNewAmount.toDoubleOrNull() ?: return
        if (newAmount = 0) {
            srcAmount = newAmount
        }
    }
}

// UI Layer handles the "dirty" work of String conversion
@Composable
fun AmountInput(viewModel: GoodViewModel) {
    OutlinedTextField(
        value = viewModel.srcAmount.toString(),
        onValueChange = { input ->
            val parsed = input.toDoubleOrNull()
            if (parsed != null) {
                viewModel.updateAmount(parsed)
            }
        }
    )
}

How Senior Engineers Fix It

Senior engineers enforce strict type boundaries at the edge of the system.

  • Define Clear Interfaces: The ViewModel should define its API in terms of Domain Models or Primitive Types (Double, Int, Long), not UI types (String).
  • Handle Conversion at the Edge: The UI layer (or a dedicated Mapper) is responsible for converting user input (Strings) into the types expected by the business logic.
  • Explicit Error States: Instead of a silent return, a senior engineer would implement an Error State (e.g., val error by mutableStateOf<String?>(null)) so the user knows why their input was rejected.
  • Dependency Inversion: If complex parsing is needed (like locale-aware decimals), that logic is moved to a Parser class injected into the UI or ViewModel, keeping the ViewModel testable.

Why Juniors Miss It

  • Focus on “Working” vs “Correct”: A junior sees the code works and moves on; a senior sees that the code is architecturally fragile.
  • Misunderstanding Abstraction: Juniors often think “moving code to the ViewModel” is synonymous with “good architecture,” failing to realize that the ViewModel should be agnostic of the input medium.
  • Ignoring Edge Cases: Juniors often overlook how input types change with Localization (L10n), assuming a period . is the only decimal separator.

Leave a Comment