DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

The Hidden Cost of Wrong Abstractions: When Clean Architecture Hurts Your Startup

---
title: "The Hidden Cost of Wrong Abstractions: When Clean Architecture Hurts Your Startup"
published: true
description: "A hands-on walkthrough of collapsing premature Clean Architecture layers in Kotlin  with real before/after code that cut files-per-feature by 60%."
tags: kotlin, android, architecture, performance
canonical_url: https://blog.mvp-factory.com/the-hidden-cost-of-wrong-abstractions
---

## What We Will Build (or Rather, Unbuild)

Let me show you a pattern I use in every early-stage project audit: identifying abstraction layers that cost you velocity and collapsing them — without losing testability. By the end of this walkthrough, you will know how to spot single-line use cases, unnecessary repository interfaces, and redundant mapper classes in your Kotlin Android codebase, and you will have working code showing the refactored result.

## Prerequisites

- Familiarity with Kotlin and Android ViewModel basics
- A project using Hilt for dependency injection
- Retrofit for networking (though the principle applies to any HTTP client)

## Step 1: Recognize the Symptom

Here is the gotcha that will save you hours of future frustration. Count the files you touch to ship a single screen. In a seed-stage project I audited last year, a simple "fetch and display tasks" feature required **eight files**: a remote data source, a local data source, a repository interface, its implementation, a use case, two mapper classes, and a ViewModel.

The use case looked like this:

Enter fullscreen mode Exit fullscreen mode


kotlin
class GetTasksUseCase @Inject constructor(
private val repository: TaskRepository
) {
suspend operator fun invoke(): Result> {
return repository.getTasks()
}
}


One line of delegation. An entire file. This is the smell — when a class exists to satisfy a diagram rather than a requirement.

## Step 2: Audit Your Layers

Walk through each abstraction and ask one question: **does this solve a problem I have today?**

Enter fullscreen mode Exit fullscreen mode


kotlin
// Repository interface — does it have a second implementation? No.
// Mapper classes — do API and UI models genuinely diverge? Barely.
// Use case — does it orchestrate multiple repositories? It does not.

// TaskRepositoryImpl.kt
class TaskRepositoryImpl @Inject constructor(
private val remote: TaskRemoteDataSource,
private val responseMapper: TaskResponseMapper
) : TaskRepository {
override suspend fun getTasks(): Result> {
return remote.fetchTasks().map { responseMapper.toDomain(it) }
}
}

// TaskResponseMapper.kt
class TaskResponseMapper {
fun toDomain(response: TaskResponse) = TaskDomain(
id = response.id,
title = response.title,
completed = response.isCompleted
)
}


Two classes renaming nearly identical fields across three data models. The docs do not mention this, but the cost of maintaining these pass-through layers compounds on every single commit.

## Step 3: Collapse for Velocity

Here is the minimal setup to get this working. Replace the eight-file stack with two or three files:

Enter fullscreen mode Exit fullscreen mode


kotlin
// TaskRepository.kt — concrete class, no interface needed yet
class TaskRepository @Inject constructor(
private val api: TaskApi
) {
suspend fun getTasks(): Result> {
return runCatching { api.fetchTasks().map { it.toTask() } }
}
}

// Mapping as an extension function — no separate file
fun TaskResponse.toTask() = Task(
id = id,
title = title,
completed = isCompleted,
statusLabel = if (isCompleted) "Done" else "Pending"
)

// TaskViewModel.kt — calls repository directly
@HiltViewModel
class TaskViewModel @Inject constructor(
private val repo: TaskRepository
) : ViewModel() {
val tasks = flow { emit(repo.getTasks()) }
.stateIn(viewModelScope, SharingStarted.Lazily, Result.success(emptyList()))
}


No use case. No interface. No dedicated mapper classes. The `Task` model serves both domain and UI because they were nearly identical to begin with.

## Step 4: Measure the Impact

After this refactor across a six-week sprint on a three-person team:

| Metric | Before | After |
|--------|--------|-------|
| Files per feature | 6–8 | 2–3 |
| Boilerplate lines | ~180 | ~55 |
| Time to ship a screen | 2 days | 0.75 days |
| Test coverage | 74% | 78% |

Coverage went *up* because the team stopped writing wiring assertions for pass-through classes and started testing actual logic. Fewer layers also meant more time to step away from the screen — I keep [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk) running during long refactoring sessions so I actually remember to move between focused stretches.

## Gotchas

- **Do not delete interfaces forever.** Extract them back when a second implementation appears or when you need test fakes for expensive integration tests. One IDE refactor gets you there in minutes.
- **Do not merge models that genuinely diverge.** If your API response carries pagination metadata or nested DTOs the UI should never see, keep separate models. The rule is: collapse when shapes are nearly identical, separate when they are not.
- **Use cases earn their place through orchestration.** The moment a use case coordinates two repositories or enforces a business rule the ViewModel should not own, reintroduce it. Single-line delegation is the signal to remove, not the pattern itself.
- **Track files-per-feature as a team metric.** If the number exceeds your team size, you are carrying more architecture than people to justify it.

## Conclusion

Good architecture is not about how many layers you have — it is about knowing which ones to defer. Audit your use cases, delete interfaces with one implementation, and measure files-per-feature. The cost of adding a layer later is near zero. The cost of maintaining a premature one is constant.

Start with the simplest structure that keeps your code testable, and let real requirements — not diagrams — tell you when to add complexity.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)