STOP Returning DTOs From Your Repository - You’re Leaking Implementation Details
If your ViewModel knows what your API response looks like, your architecture is already broken.
I’ve reviewed hundreds of Android codebases over the past decade. And one of the most common yet quietly destructive mistakes I see is this: repositories that return DTOs directly to the domain or presentation layer.
It looks harmless. It saves you a few lines of code. But it creates a coupling so tight that a single backend change can ripple through your entire app.
Let me show you what I mean, why it matters, and how to fix it.
The Problem: What Most Codebases Look Like
Here’s a pattern I see repeated in almost every project I audit:
// The DTO — shaped exactly like the API response
data class UserDto(
@SerializedName("user_id")
val userId: String,
@SerializedName("full_name")
val fullName: String,
@SerializedName("email_addr")
val emailAddr: String,
@SerializedName("is_active")
val isActive: Int // 1 or 0, because the backend team loves integers
)// The Repository
class UserRepositoryImpl(
private val api: UserApi
) : UserRepository {
override suspend fun getUser(id: String): UserDto {
return api.fetchUser(id)
}
}// The ViewModel
class ProfileViewModel(
private val userRepository: UserRepository
) : ViewModel() {
fun loadUser(id: String) {
viewModelScope.launch {
val user = userRepository.getUser(id)
// Now the ViewModel has to know:
// - that the name field is called "fullName"
// - that "isActive" is an Int, not a Boolean
// - that the email field has a weird suffix
_uiState.value = ProfileUiState(
name = user.fullName,
email = user.emailAddr,
isActive = user.isActive == 1
)
}
}
}This “works.” But let me walk you through everything wrong with it.
Why This Is a Real Problem
1. Your UI Layer Is Married to Your API Contract
The moment your ViewModel references fullName, emailAddr, or interprets isActive == 1, it has taken on a responsibility that doesn't belong to it. It now knows the shape of your network response.
When the backend team renames email_addr to email, and they will you're not fixing one file. You're fixing every ViewModel, every use case, and every test that ever touched that DTO.
2. The Repository Interface Is a Lie
interface UserRepository {
suspend fun getUser(id: String): UserDto
}This interface claims to be an abstraction. But it returns a concrete data transfer object tied to a specific Retrofit serialization. Any module that depends on UserRepository now also depends on your network layer's model. That's not abstraction that's a dressed-up implementation detail.
3. You Can’t Swap Data Sources
Imagine tomorrow you need to load user data from a local Room database instead of the API. Your UserEntity from Room has a completely different shape than UserDto. But your ViewModel expects UserDto.
Now what? You either force your local layer to produce fake DTOs (absurd), or you rewrite everything downstream. Neither is acceptable.
4. Testing Becomes Unnecessarily Painful
Every unit test for your ViewModel now needs to construct full UserDto objects, including fields irrelevant to the test, complete with serialization annotations and backend-specific quirks like integer booleans.
The Fix: Map at the Boundary
The repository is a boundary. It sits between the outside world (APIs, databases, sensors) and your app’s core logic. Its job is to translate external data into something your app understands.
Step 1: Define a domain model.
data class User(
val id: String,
val name: String,
val email: String,
val isActive: Boolean
)No annotations. No serialization logic. No backend naming quirks. This is what your app actually cares about.
Step 2: Map inside the repository.
class UserRepositoryImpl(
private val api: UserApi
) : UserRepository {
override suspend fun getUser(id: String): User {
val dto = api.fetchUser(id)
return dto.toDomain()
}
}fun UserDto.toDomain(): User {
return User(
id = userId,
name = fullName,
email = emailAddr,
isActive = isActive == 1
)
}Step 3: The repository interface exposes only the domain model.
interface UserRepository {
suspend fun getUser(id: String): User
}Now your ViewModel, your use cases, and your tests never see a DTO. They never know or care whether the data came from Retrofit, Room, a flat file, or a carrier pigeon.
“But It’s Extra Code For No Reason”
I hear this constantly. Let me address it directly.
Get Himanshugaur’s stories in your inbox
Join Medium for free to get updates from this writer.
Yes, it’s a few more lines. Here’s what those lines buy you:
Those “extra lines” are the cheapest insurance policy in your codebase.
What About Multiple Data Sources?
This pattern really proves its value when you have both remote and local sources:
class UserRepositoryImpl(
private val api: UserApi,
private val dao: UserDao
) : UserRepository {
override suspend fun getUser(id: String): User {
return try {
val dto = api.fetchUser(id)
val user = dto.toDomain()
dao.insert(user.toEntity()) // cache it
user
} catch (e: IOException) {
dao.getById(id)?.toDomain()
?: throw UserNotFoundException(id)
}
}
}fun UserEntity.toDomain(): User {
return User(
id = id,
name = name,
email = email,
isActive = isActive
)
}
fun User.toEntity(): UserEntity {
return UserEntity(
id = id,
name = name,
email = email,
isActive = isActive
)
}Both UserDto (network) and UserEntity (database) map to the same User domain model. The ViewModel never knows the difference. Swap Retrofit for Ktor, swap Room for SQLDelight nothing outside the data layer changes.
Where Should the Mappers Live?
Keep them in the data layer, close to the models they transform. A structure that works well:
data/
├── remote/
│ ├── UserApi.kt
│ ├── UserDto.kt
│ └── UserDtoMapper.kt
├── local/
│ ├── UserDao.kt
│ ├── UserEntity.kt
│ └── UserEntityMapper.kt
└── repository/
└── UserRepositoryImpl.kt
domain/
├── model/
│ └── User.kt
└── repository/
└── UserRepository.ktThe domain module has zero dependencies on the data layer. The arrow of dependency points inward exactly as Clean Architecture prescribes.
The Rule Is Simple
DTOs belong to the data layer. Domain models belong to your app. The repository is the translator between the two.
If your repository interface mentions a DTO, your abstraction has failed. If your ViewModel parses API quirks, your separation of concerns has failed.
Map at the boundary. Every time. No exceptions.
TL;DR
- DTOs are shaped by external systems. Your app shouldn’t conform to them.
- Repositories should return domain models, not DTOs.
- Write mapper functions (extension functions work great in Kotlin).
- Keep DTOs confined to the data layer.
- The cost is a few lines of mapping. The payoff is a codebase that doesn’t shatter when the backend changes a field name.
Your future self and every developer who inherits your code will thank you.
If this changed how you think about your repository layer, consider sharing it with your team. Architectural mistakes compound silently the best time to fix them is before the next feature lands.
👋 Let’s Connect
If you enjoy content about Android development, Jetpack Compose, Kotlin, software architecture, and engineering best practices, I’d love to stay connected.
📺 YouTube
In-depth Android tutorials, real-world projects, architecture discussions, and practical development tips.
💼 LinkedIn
Professional updates, technical insights, articles, and lessons from my software engineering journey.
✍️ Medium
More deep dives into Android development, clean architecture, testing, performance optimization, and modern engineering practices.
📚 Want to Go Deeper?
If you’re looking for structured, comprehensive learning resources, check out my Android development books, where I cover concepts in much greater depth than a typical article.
☕ Support My Work
Creating free technical content, books, tutorials, and open educational resources takes considerable time and effort. If this article helped you learn something valuable, you can support my work by:
• Purchasing one of my books
• Sharing my articles with others
• Buying me a coffee
Every bit of support helps me continue creating high-quality content for the Android community.
SOLID Principles for Android Developers — Learn how to write maintainable, scalable, and testable Android applications using SOLID principles with real-world examples.
👉 Explore all my books: Book Link








