name: data-state-pattern description: 'DataState and UiState workflow guide for repositories and data sources. Use when implementing or reviewing data flow, refresh/retry behavior, and repository return contracts.'
Skill: DataState / UiState Pattern
Overview
DataState<T> is the concrete data-layer specialization of the domain UiState<T> contract. It
pairs a Flow of the requested model with a Flow of loading/error status, and provides built-in
refresh and retry support.
The read guidelines below apply only to non-mutation source shapes. Mutation-only variants are covered in the mutation section and are exempt from the Room-first read contract.
Key files to read
data/android/src/main/kotlin/co/anitrend/data/android/- base data-source implementationsdata/src/main/kotlin/co/anitrend/data/tag/repository/TagRepository.kt- baseline example of a repository returningDataStatedomain/src/main/kotlin/co/anitrend/domain/tag/repository/ITagRepository.kt- matching domain interface that declares theDataState-typed contractdata/src/main/kotlin/co/anitrend/data/media/- read-heavy module showing alias-based repositories and interactors forDetail,Paged, andNetworkdomain/src/main/kotlin/co/anitrend/domain/medialist/anddata/src/main/kotlin/co/anitrend/data/medialist/- hybrid query + mutation flow with operation-specific repository contracts, aliases, and concrete interactorsdomain/src/main/kotlin/co/anitrend/domain/review/anddata/src/main/kotlin/co/anitrend/data/review/- paged/detail queries plus rate/save/deletedomain/src/main/kotlin/co/anitrend/domain/favourite/anddata/src/main/kotlin/co/anitrend/data/favourite/- compact mutation-only toggle flowtask/medialist/src/main/kotlin/co/anitrend/task/medialist/andtask/review/src/main/kotlin/co/anitrend/task/review/- workers waiting for terminalloadStateafter invoking mutation interactorsapp/core/src/main/kotlin/co/anitrend/core/koin/Modules.kt- howStateLayoutConfigand dispatchers are registered so UI can bind toDataStatestreams
Usage pattern
ViewModel / Presenter / Worker
-> data.*Interactor alias
-> domain use case
-> domain repository contract
-> data repository
-> source create source(params) // infix helper from support-arch
-> data source / controller / store
- Domain layer - repository contracts and abstract use cases are generic over
UiState<T>. - Data layer -
Types.ktaliases specialize those contracts toDataState<T>, and the concrete repository callssource create source(params)to wrap a source into aDataState. - Entry layers - feature ViewModels or common presenters post/observe the
DataState, while task workers typically await a terminalloadStatebefore returningResult.success()orResult.failure().
Mutation-specific pattern
- Keep repository contracts in
:domain, even for a single toggle or save/delete mutation. - Keep abstract use cases in
:domainasXxxUseCase/XxxInteractorbase classes. - Keep each module's
Types.ktlean: use it for controller aliases, specialized repository aliases, and interactor aliases only. - Put the concrete data-layer use-case bridge in the module's
usecase/package. Modules that expose one operation should use a singleXxxUseCaseImpl. Modules that expose two or more distinct operations should use nestedXxxInteractorclasses such asMediaListInteractor,ReviewInteractor, orFavouriteInteractor. - Query sources usually emit
Flow<Model>,Flow<List<Model>>, orFlow<PagedList<Model>>; mutation sources often emitFlow<Boolean?>or a persisted model and then rely on the repository wrapper to expose the finalDataState.
Offline-first non-paged read pattern
Use this pattern for single-entity reads or fixed-size collections that should render from Room first and refresh from the network opportunistically.
- The source contract should extend
AbstractCoreDataSourceand exposeobservable(): Flow<Model>orobservable(): Flow<List<Model>>. - The source
invoke(...)operator should store any query context it needs, callcachePolicy(...)with the source cache identity, and returnobservable()immediately. observable()should read local Room state and project it into the domain model.- Include
filterNotNull()when the Room query can emit null before the first insert. - Include
distinctUntilChanged()when downstream collectors act on repeated emissions. - Omit
distinctUntilChanged()when the UI layer already performs change detection or the source changes infrequently.
Source contract
- The source contract should extend
AbstractCoreDataSourceand exposeobservable(): Flow<Model>orobservable(): Flow<List<Model>>.
invoke() contract
- The source
invoke(...)operator should store any query context it needs, callcachePolicy(...)with the source cache identity, and returnobservable()immediately.
observable() pipeline
observable()should read local Room state and project it into the domain model.- Include
filterNotNull()when the Room query can emit null before the first insert. - Include
distinctUntilChanged()when downstream collectors act on repeated emissions. - Omit
distinctUntilChanged()when the UI layer already performs change detection or the source changes infrequently.
Persistence
get*()methods should only orchestrate the remote refresh path through the controller and returnBooleansuccess socachePolicy(...)can update the last request timestamp.- Persistence still belongs to the controller and mapper chain. Do not manually merge cached rows,
build domain models from remote payloads inline, or make
observable()depend on network work.
clearDataSource()
clearDataSource(...)for read flows should invalidate the relevant cache identity and clear the local rows that backobservable().
Non-paged source shapes
- Use
Flow<List<Model>>for immutable or fixed-size collections such asTagSourceandGenreSource. - Use
Flow<Model>for singleton or detail reads such asEdgeConfigSource,MediaSource.Detail, andReviewSource.Entry. - For entity families with multiple read contexts, define separate source variants for each contract. A family is multi-context when the same entity type is fetched through at least two structurally different query shapes, such as a detail lookup by id and a paged list filtered by criteria.
Ordering
- If the same parent resource is requested with a filter or limit that materially changes the expected result count, use a variant cache key while continuing to read from the same local table.
- Use the same cache key when only sort order or display format differs without affecting which rows are fetched.
Fixed-size detail reads
Non-paged child collections and aggregate detail reads should still follow the same Room-first contract.
- Persist fixed-size collections in dedicated connection or detail tables keyed by the parent id.
- Store a stable display order such as
sort_indexso every cached route renders consistently. - Keep the parent context needed for persistence inside the mapped response model. The source should trigger refreshes, not rebuild local rows from remote models itself.
- Clear parent-scoped rows from
clearDataSource()before forcing the next refresh. - Do not use
MutableStateFlowas the primary backing store when a Room table already exists for the same read.
Red flags
- Mapper
persist(...)methods left empty for a read that claims to be offline-first. - Source contracts that clear
MutableStateFlowand fire the network on everyinvoke(...). - Local tables without ordering columns for remote relationship collections.
- Response models that omit parent identity and force mutable source or mapper request state to rebuild persistence context.
- Cache identities keyed too broadly for request variants that intentionally fetch different result sizes.
Contrast with mutation-only variants
- Mutation sources such as
ReviewSource.Rate/Delete/SaveandUserSource.ToggleFollow/Updateare not offline-first read baselines. - They commonly expose terminal state streams such as
MutableStateFlow<Boolean?>or reuse a persisted entity stream after the controller mutates local state. - Keep their request orchestration and
observable()contracts separate from the non-paged read pattern so read guidance does not get conflated with mutation-only flows.
Offline-first paged read pattern
Use this pattern whenever the UI should page over locally persisted data and refresh from the network opportunistically.
- The source contract should extend
AbstractPagingSource<T>and exposeobservable(): Flow<PagedList<T>>. observable()should be built from a localDataSource.FactoryusingFlowPagedListBuilder, with a converter mapping local entities or views into domain models.- The source should orchestrate refresh timing only: initial cache-policy gating and
cacheIdentity(...)paging callbacks for append or zero-item refreshes. - If Room is the source of truth, the controller generic should resolve to the persisted entity shape, not the final domain model. Let the mapper produce local entities and persist them.
- Do not perform domain-model assembly, local-page merging, or ad hoc cache replacement inside the source implementation. Those responsibilities belong to converters and mappers.
Relationship collection variant
Some paged reads are not a top-level entity table but a relationship collection, for example a media detail screen exposing characters or staff.
- Persist those rows in dedicated connection tables keyed by the parent id plus the related id.
- Store explicit ordering information such as
sort_indexso local paging reproduces the remote list order. - Allow the mapper to receive request context whenever the persistence operation must scope writes to a specific parent entity or determine which existing rows to clear before appending new ones.
- Convert connection entities back to the domain model in a local converter that the source uses
when building the
FlowPagedListBuilderpipeline.
Mapper sidecar persistence
When a parent mapper needs to persist related local-only rows as part of the same response, keep
those writes behind EmbedMapper helpers such as UserMapper.GeneralOptionEmbed or
UserMapper.NotificationEmbed.
- Prefer introducing a new
XxxEmbedmapper over injecting an additional DAO directly into the parent mapper. - Keep placeholder or sidecar persistence rules inside that embed helper, even when the helper uses
custom DAO methods instead of a plain
upsert. - Let the parent mapper coordinate embeds with
onEmbedded(...)andpersistEmbedded()so mapper wiring stays consistent across query and mutation flows.
Rules
The following rules are authoritative; where they overlap with pattern-section guidance, the rule below governs.
- Never return a raw value or
LiveDatafrom a non-paged repository; always returnDataState. - Paged repository contracts may return
Flow<PagingData<T>>through the existing paging interactor aliases. Do not convert paging flows toDataStateunless the existing module pattern already does so. - Use
DataState.refresh()/DataState.retry()to trigger re-fetches; do not re-create the entireDataState. - Dispatching: the support-arch base classes already schedule network/DB work on
Dispatchers.IO; avoid wrapping calls in an extrawithContextunless the base class is not used. - An import such as
co.anitrend.data.review.GetReviewPagedInteractorinfeatureortaskcode is acceptable because it aliases a domain use case. ImportingReviewRepository,ReviewSourceImpl, orReviewMapperinto those layers is not. - For DB-backed non-paged reads, prefer
AbstractCoreDataSourceplus a cache-policy-gated local observable flow. Do not model them as paging sources or network-only live-data sources. - For DB-backed paged reads, do not choose
SupportPagingLiveDataSourceunless the flow is truly network-only. If a local source exists, preferAbstractPagingSourceplus a local observable flow. - When
get*()catches a network or parsing exception, emit the error into the source'sloadStateflow via the base-class error helper rather than returningfalsesilently. Returningfalseis reserved for expected cache-miss or empty-result conditions. - Non-paged repository streams return
DataState<T>.