Data Layer
Repositories, remote data sources, models, AppResult, and Supabase PostgREST access.
The Android template keeps Supabase access out of composables. Screens talk to ViewModels, ViewModels talk to repositories, and repositories either call a remote data source or expose a local controller flow.
The shape
Composable screen
-> @HiltViewModel
-> Repository
-> RemoteDataSource interface
-> Supabase implementation
-> PostgREST / Auth / Storage / FunctionsMost feature repositories return AppResult<T> so ViewModels can render
success, loading, and failure states without catching Supabase exceptions in the
UI layer.
Key files
AppResult<T>
core/result/AppResult.kt defines:
sealed interface AppResult<out T> {
data class Success<T>(val data: T) : AppResult<T>
data class Failure(val error: AppError) : AppResult<Nothing>
}appSuspendRunCatching preserves coroutine cancellation and maps common
exceptions into friendly AppError.message values. Auth failures get special
handling for invalid credentials, unconfirmed email, expired OTPs, rate limits,
weak passwords, and disabled providers.
The repositories using this pattern today are:
| Repository | Result boundary |
|---|---|
AuthRepository | All auth commands return AppResult<Unit> |
TodosRepository | CRUD, clear-completed, and reorder return AppResult |
ProfileRepository | Fetch and per-field updates return AppResult |
SubscriptionRepository | Entitlement read returns AppResult<Subscription?> |
NotificationsRepository | Push registration and test push return AppResult |
UploadsRepository is the exception: it exposes Flow<UploadStatus> directly
because the UI needs per-job progress events from Supabase Storage.
Models
The Supabase rows are @Serializable Kotlin data classes:
| Model | Table | Fields used by the app |
|---|---|---|
Todo | public.todos | id, title, completed, sort_order, created_at |
Profile | public.profiles | id, full_name, bio, phone, location, avatar_url, updated_at |
Subscription | public.subscriptions | Product, provider, status, period, trial, cancellation, and ledger timestamps |
The Subscription model uses lenient enum serializers so unknown future enum
values decode as Unknown instead of crashing old clients.
Remote data sources
RepositoryModule binds interfaces to Supabase implementations:
| Interface | Production implementation | Backend surface |
|---|---|---|
AuthRemoteDataSource | SupabaseAuthRemoteDataSource | Supabase Auth + PostgREST cleanup for delete-account |
TodosRemoteDataSource | SupabaseTodosRemoteDataSource | public.todos |
ProfileRemoteDataSource | SupabaseProfileRemoteDataSource | public.profiles |
SubscriptionRemoteDataSource | SupabaseSubscriptionRemoteDataSource | public.subscriptions |
DeviceTokenRemoteDataSource | SupabaseDeviceTokenRemoteDataSource | public.device_tokens |
TestPushRemoteDataSource | SupabaseTestPushRemoteDataSource | send-test-push Edge Function |
Tests inject fakes at these seams. The template's unit tests use plain in-memory
fake classes with kotlinx-coroutines-test; no Mockk setup is required for the
repository and ViewModel tests.
PostgREST examples
SupabaseTodosRemoteDataSource.fetchAll() filters by owner and uses the same
ordering expected by the UI:
table.select {
filter { eq("user_id", userId) }
order("sort_order", Order.ASCENDING)
order("created_at", Order.DESCENDING)
}.decodeList<Todo>()TodosRepository.add() assigns a new item just before the current first item:
val nextOrder = currentItems.minOfOrNull { it.sortOrder }?.minus(1) ?: 0
remoteDataSource.add(userId, title.trim(), nextOrder)Profile updates normalize blank input to null before sending a single-field
patch:
val cleaned = value?.trim()?.takeIf { it.isNotEmpty() }
remoteDataSource.update(userId, mapOf(field to cleaned))Local state
AppPreferences wraps DataStore Preferences with typed flows for:
- Theme and language.
- Dynamic color toggle.
- Onboarding completion.
- Biometric lock enabled state.
- Accepted legal version.
- Dismissed soft-update version.
- Store-review counters and prompt history.
NetworkObserver wraps ConnectivityManager.NetworkCallback and exposes
StateFlow<Boolean> for the app's offline banner. It requires both internet and
validated network capabilities.
RLS expectations
The mobile client assumes owner-scoped RLS:
| Table | Client operations |
|---|---|
profiles | Select, insert, and update only the row where id = auth.uid() |
todos | Select, insert, update, and delete only rows where user_id = auth.uid() |
device_tokens | Select, insert, update, and delete only rows where user_id = auth.uid() |
subscriptions | Select only where user_id = auth.uid() |
payments | Select only where user_id = auth.uid() |
Subscriptions and payments intentionally have no client write policies. The
RevenueCat webhook writes those tables with the service-role key after checking
the webhook Authorization secret and the RevenueCat app_user_id.
Add your own feature
Create the schema and RLS first
Add a table keyed by user_id uuid references auth.users(id) unless the row is
one-to-one with the user. Give authenticated users only the operations the app
needs.
Add a serializable model
Put the row model in data/model/. Use @SerialName for snake_case columns so
the rest of the app stays idiomatic Kotlin.
Split repository from remote data source
Create a FeatureRemoteDataSource interface, a Supabase implementation, and a
FeatureRepository that maps failures into AppResult.
Bind the implementation with Hilt
Add a @Binds method to core/di/RepositoryModule.kt.
Keep ViewModels boring
Expose one StateFlow<UiState> from the ViewModel, launch work in
viewModelScope, and collect it from composables with lifecycle-aware Compose
collection.
