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
setAmountmethod is forced to handleStringparsing, which is a UI/Input concern. - Hidden Side Effects: The method uses a “silent return” (
returnif 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
itargument from aTextFielddirectly 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
returnon 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 primitive10.5. - Localization Failures: If the app expands to regions using commas as decimal separators (e.g.,
"10,5"), thetoDoubleOrNull()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
Parserclass 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.