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 / Functions

Most 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:

RepositoryResult boundary
AuthRepositoryAll auth commands return AppResult<Unit>
TodosRepositoryCRUD, clear-completed, and reorder return AppResult
ProfileRepositoryFetch and per-field updates return AppResult
SubscriptionRepositoryEntitlement read returns AppResult<Subscription?>
NotificationsRepositoryPush 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:

ModelTableFields used by the app
Todopublic.todosid, title, completed, sort_order, created_at
Profilepublic.profilesid, full_name, bio, phone, location, avatar_url, updated_at
Subscriptionpublic.subscriptionsProduct, 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:

InterfaceProduction implementationBackend surface
AuthRemoteDataSourceSupabaseAuthRemoteDataSourceSupabase Auth + PostgREST cleanup for delete-account
TodosRemoteDataSourceSupabaseTodosRemoteDataSourcepublic.todos
ProfileRemoteDataSourceSupabaseProfileRemoteDataSourcepublic.profiles
SubscriptionRemoteDataSourceSupabaseSubscriptionRemoteDataSourcepublic.subscriptions
DeviceTokenRemoteDataSourceSupabaseDeviceTokenRemoteDataSourcepublic.device_tokens
TestPushRemoteDataSourceSupabaseTestPushRemoteDataSourcesend-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:

TableClient operations
profilesSelect, insert, and update only the row where id = auth.uid()
todosSelect, insert, update, and delete only rows where user_id = auth.uid()
device_tokensSelect, insert, update, and delete only rows where user_id = auth.uid()
subscriptionsSelect only where user_id = auth.uid()
paymentsSelect 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.

On this page