7 min read

Repositories: The Layer Most Teams Get Wrong

A repository's only job is owning the decision of where data comes from. If it forwards a call to Retrofit it isn't abstraction, it's indirection — here's the shape that survives a real app, and when to skip the layer entirely.

AndroidArchitectureRepository PatternKotlinClean Architecture
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — A repository exists to own the decision of where data comes from — cache or network, fresh or stale — so no caller has to. A class that forwards api.getUser() decides nothing and is pure indirection. The shape that survives: one repository per domain concept (not per endpoint), returning domain models (never DTOs or Room entities), exposing a single Flow read path from the database plus a refresh() that returns Result<Unit> and no data. And if a feature has one endpoint, no cache and no offline requirement, skip the layer — a pass-through class added to satisfy a diagram is a cost with no buyer.

The class that isn't a layer

Open almost any Android codebase and you'll find this:

class UserRepository(private val api: UserApi) {
    suspend fun getUser(id: String): UserResponse = api.getUser(id)
    suspend fun getOrders(id: String): List<OrderResponse> = api.getOrders(id)
}

Every method forwards. No caching decision, no source selection, no error translation, no mapping. Delete the class and inject UserApi into the ViewModel and the app behaves identically — which is the test.

It isn't harmless. It's a file to open, a binding to declare, a name in every stack trace, and a mock in every test, all in exchange for a diagram that looks like the one in the article your team read. It also teaches new joiners that "repository" means "the thing between the ViewModel and Retrofit", so the next forty follow the pattern.

What the layer is actually for

A repository owns the decision of where data comes from.

That's the whole definition, and it's worth being strict about because everything else follows. Is the cached copy fresh enough? Do we hit the network, and what happens when it fails — throw, or serve stale and reconcile later? Does a write go to the database immediately and sync in the background?

Those are real decisions with real trade-offs, and they must live somewhere that isn't a ViewModel — a ViewModel exists per screen, and cache policy that lives per screen means eight screens with eight different answers about how old the user's data may be.

If your class makes none of those decisions, it isn't the layer. It's the name of the layer.

Rule 1: one repository per domain concept

Not per endpoint, and definitely not per screen. UserRepository, OrderRepository, CatalogRepository — concepts your product manager would recognise.

The moment you see ProfileScreenRepository you have a class that will be deleted with its screen and duplicated by the next one, and the caching decision fragments along with it. The giveaway that you've gone per-endpoint: the repository has exactly as many methods as your API has routes, in the same order.

Rule 2: return domain models, never DTOs

Leaking UserResponse through the repository defeats the entire purpose in one line. Now every ViewModel knows the field is called created_at, that it's a String, and that it's nullable because a 2019 migration left some rows empty.

// data layer, internal
@Serializable
internal data class UserResponse(
    val id: String,
    @SerialName("created_at") val createdAt: String? = null,
)

// domain, public
data class User(val id: UserId, val createdAt: Instant?)

internal fun UserResponse.toDomain() = User(
    id = UserId(id),
    createdAt = createdAt?.let(Instant::parse),
)

That mapper looks like ceremony until the backend renames a field. Then it is the one file that changes, instead of a compile error in nineteen call sites — and, more usefully, it's the place where you decide what the app does about a value the server shouldn't have sent.

The same applies to Room entities. A @Entity is a storage detail; it should not be the type your Composables render.

Rule 3: one read path, and refresh returns no data

This is the rule that removes the most bugs, and it's visible entirely in the signature.

interface UserRepository {
    val user: Flow<User>                  // always the database
    suspend fun refresh(): Result<Unit>   // network → database, returns nothing
}

Compare it to the interface most apps have:

suspend fun getUser(): User          // sometimes cached?
fun observeUser(): Flow<User>        // …and this one?
suspend fun refreshUser(): User      // returns a User too, so callers use it directly

Three ways to ask one question. Callers pick differently, some screens observe and some fetch, and you get the classic bug: the data refreshed, the database updated, and this one screen still shows what it fetched on entry. You'll fix it, and it'll come back in another screen next quarter.

With a single Flow from the database as the only read path, that bug is not "fixed" — it's unrepresentable. Anything that lands in the DB reaches every observer, always.

override suspend fun refresh(): Result<Unit> = runCatching {
    val remote = api.getUser()      // may throw
    dao.upsert(remote.toEntity())   // single source of truth
}

Rule 4: stop writing an interface for every implementation

UserRepository + UserRepositoryImpl, "for testing", when a fake would do and there has never been a second implementation. Kotlin has no requirement that a test double implement an interface you wrote for its benefit — and if you do want one, a hand-written fake gives better tests than a mock either way.

There is a real reason to keep the interface: a module boundary. :core:data-api holding the interface and :core:data-impl holding the implementation lets Gradle compile consumers in parallel and stops feature modules depending on Retrofit. That's a build-graph argument with measurable output, and it's worth doing at scale. "For testing" isn't — it's an interface per class in exchange for a second file.

When to skip the layer entirely

A screen backed by one endpoint, with no caching, no offline behaviour and no second consumer, does not need a repository. Let the ViewModel call the API.

This is the part that gets objections in review, so be concrete about the trade: you are betting that the requirement won't arrive. If it does, extracting a repository from one ViewModel is an hour's work with the compiler helping. Adding the class up front costs a file per feature for a decision you never had to make. Take the hour later.

The rule

A repository that doesn't decide anything isn't abstraction — it's indirection. Give the layer one job (where does this data come from), one read path (a Flow off the source of truth), and one output type (your domain model). If you can delete the class and the app behaves the same, that's not a passing grade.

Keep reading