SuperAPI AI Directory

repository-flow-conventions

Coding & development

View repository

Updated 10-17 16:42

About this skill

Repository method shapes

Pick the shape by where the data comes from, not by what is convenient at the call site. Four shapes cover everything a repository over a database and a network API does:

Kind of method Shape Why this one
One-shot local read flow { emit(local.x()) }.flowOn(IO) one value, computed lazily on first collection, off the caller's thread
Live local read the database's own Flow, returned unchanged the database library already re-emits on every write and already confines the query
Remote read flow { api.x().onSuccess { emit(Success(map(it))) }.onFailure { emit(Error(msg)) } }.flowOn(IO) a call that can fail needs somewhere to put the failure
Write suspend fun … = withContext(IO) { local.x() } nothing to observe; the caller awaits completion
// adapted — service and entity names generalized, and two bodies deliberately corrected: the
// source wraps `getAlbumData` in an outer runCatching with no failure handler (the FIRST trap
// below) and writes `updateAlbumLiked` on the Main dispatcher (the THIRD). This is the shape to
// copy, not the shape you will find.
internal class AlbumRepositoryImpl(
    private val local: LocalDataSource,
    private val remote: CatalogApi,
) : AlbumRepository {

    override fun getAllAlbums(limit: Int): Flow<List<AlbumEntity>> =
        flow { emit(local.getAllAlbums(limit)) }.flowOn(Dispatchers.IO)

    override fun getAlbumAsFlow(id: String) = local.getAlbumAsFlow(id)      // live; no flowOn

    override suspend fun updateAlbumLiked(albumId: String, likeStatus: Int) =
        withContext(Dispatchers.IO) { local.updateAlbumLiked(likeStatus, albumId) }

    override fun getAlbumData(albumId: String): Flow<Resource<AlbumBrowse>> =
        flow {
            remote.album(albumId, withSongs = true)
                .onSuccess { emit(Resource.Success(parseAlbumData(it))) }
                .onFailure { emit(Resource.Error(it.message.toString())) }
        }.flowOn(Dispatchers.IO)
}

The success/error envelope itself — its states, and how callers unwrap it — is the subject of repository-resource-flow-pattern. This skill is only about which methods get one.

Traps

Wrapping the whole body in a catch-all turns a mapper crash into silence. The remote shape is often written as flow { runCatching { api.x().onSuccess { emit(…) }.onFailure { emit(…) } } }. That outer runCatching has no onFailure of its own, and the mapping call sits inside it — so when the parser throws on a response shape it did not expect, neither emit runs, the throwable is discarded, and the flow completes normally having emitted nothing. The collector's when over the envelope never executes and the screen keeps whatever state it had, which is the spinner. No crash, no log line, no error state. Either drop the outer catch and let the exception reach the collector, or give it an onFailure that emits an Error. Do not leave it half-written.

Two envelopes in one interface force callers to invent a third. When one paged read returns Flow<Resource<Pair<List<T>, String?>>> and its neighbour returns a bare Flow<Pair<List<T>?, String?>>, the bare one has nowhere to report failure — so its implementation emits Pair(null, null), and the caller ends up reading null as both "no more pages" and "the request failed". A caller that stops paging on null stops on the first network hiccup and calls it the end of the list. Pick one envelope for every method that can fail.

A write on the wrong dispatcher is invisible while the persistence library covers for you. Several update methods here read withContext(Dispatchers.Main) instead of IO. Nothing breaks today, because the underlying data-access functions are suspend and the persistence library moves the query onto its own executor regardless — so the hop to the main thread is a wasted round trip, not a stall. That is precisely why the drift survives review: correctness is coming from the library, not from the code, and the day one of those calls becomes a blocking one the convention that was supposed to prevent it is already gone. Fix them to IO and keep them there.

A one-shot read and a live read are different contracts with the same return type. Both are Flow<T?>; one emits once and completes, the other emits forever. A caller that uses .first() on the live one gets the current row and drops every later update; a caller that collects the one-shot one waiting for changes waits forever. Name them apart — the suffix on getAlbumAsFlow next to getAlbum is doing real work.

Do not mark a function suspend when it only builds a flow. One read here is suspend fun getAll(): Flow<List<T>>. The flow builder is cold, so the suspend buys nothing and costs the caller a coroutine just to obtain a flow it has not collected yet — which usually means the caller launches one, then collects inside it, and now owns two scopes for one read.

A two-state envelope makes every when over it exhaustive by accident. With only Success and Error, consumers legitimately write two-branch when expressions with no else. Adding a third state later is a compile error at every one of them — which is the good outcome, as long as you expect it. Check the blast radius before adding the state, not after.

Verifying it

Drift is per-method, so read it per-method. Count, do not skim — and quote the include glob, or a shell that expands globs itself will fail the command before grep ever sees it. Keep the glob loose at both ends (*RepositoryImpl*.kt*): a tight *RepositoryImpl.kt silently skips platform suffixes (…Impl.android.kt) and typo'd doubled extensions — this repository has an ArtistRepositoryImpl.kt.kt, and missing it undercounts both totals. A file you never see is indistinguishable from a file with nothing in it, which is the failure mode "count, do not skim" is supposed to prevent:

grep -rc 'withContext(Dispatchers.Main)' --include='*RepositoryImpl*.kt*' .
grep -rc 'withContext(Dispatchers.IO)'   --include='*RepositoryImpl*.kt*' .
grep -rn 'suspend fun .*: Flow<'         --include='*RepositoryImpl*.kt*' .

The first two together tell you the shape of the exception, not just that one exists: a handful of Main against dozens of IO is drift to fix; an even split means the convention was never agreed and writing it down is the first job. The third finds the suspend-returning-flow methods.

To confirm the silent-completion trap in your own code, collect a remote read with a deliberately broken mapper and assert that something was emitted. A test that only asserts "no exception thrown" passes on the broken version.