Architecture

How MVVM, repositories, Hilt, navigation, and controllers fit together.

The Kotlin template uses a conventional Compose architecture: Hilt-created controllers and repositories, ViewModels exposing StateFlow, stateless screen composables, typed Navigation Compose routes, and small remote data sources around Supabase.

Shape

Composable Route
  -> @HiltViewModel
  -> repository/controller
  -> remote data source or platform SDK
  -> AppResult / StateFlow
  -> stateless Screen

The goal is not heavy abstraction. Each layer has a narrow job: screens render, view models coordinate UI state, repositories normalize data results, and controllers own app-wide services.

Dependency injection

Hilt entry points:

TypeRole
@HiltAndroidApp SoarAppApplication graph and eager startup hooks
@AndroidEntryPoint MainActivityInjects controllers used by the root Compose tree
@HiltViewModelInjects repositories/controllers into screen view models
@Singleton controllers/repositoriesShared app services such as sessions, purchases, DataStore-backed controllers, analytics, toasts

core/di/ currently contains:

ModuleProvides or binds
CoroutineModule@ApplicationScope CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
DataStoreModulePreferences DataStore stored as soar_preferences
RepositoryModuleSupabase remote data source interfaces and Google Sign-In controller binding
SessionModuleFlow<UserInfo?> from SessionController.currentUser
BiometricModuleBiometricSettings interface to BiometricController

SoarApp.onCreate() currently starts Sentry, PostHog, and the update controller. RevenueCat is injected there so its singleton is constructed and can observe session state.

MVVM and state

Feature folders use the same route/screen split:

@Composable
fun TodosRoute(viewModel: TodosViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsState()
    TodosScreen(state = state, onAdd = viewModel::add)
}

TodosViewModel is the reference pattern: a data class TodosUiState, private MutableStateFlow, public StateFlow, and event methods that launch in viewModelScope.

Current code uses both collectAsState() and collectAsStateWithLifecycle(). For new route composables, prefer collectAsStateWithLifecycle() when collecting ViewModel state; keep plain collectAsState() for simple root controllers where the existing code already does that intentionally.

Repository boundary

Repositories wrap backend calls in AppResult<T>:

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, maps common Supabase and network failures to friendly messages, and prevents exceptions leaking across the repository boundary.

The repository pattern is:

RemoteDataSource interface
  -> SupabaseRemoteDataSource implementation
  -> Repository
  -> ViewModel

Unit tests instantiate repositories with in-memory fake remote data sources. The project does not use Mockk for these tests and does not require Hilt in unit tests.

Routes are Kotlin serialization objects/data classes in nav/Routes.kt:

@Serializable
data object Paywall

@Serializable
data class LegalDocument(val docId: String)

AppNavGraph.kt owns:

  • UpdateGate -> OnboardingGate -> ConsentGate composition.
  • The outer NavHost with MainShell, auth graph, paywall, refer, upload, subscription, and redeem-code destinations.
  • A private MainNavGraph composable that consumes deep links and auth state.

MainShellScreen.kt owns the nested tab NavHost for Home, Component, Showcase, and Me. Tab changes use popUpTo(findStartDestination()), saveState = true, launchSingleTop = true, and restoreState = true, so each tab keeps its back stack.

Gates are composition wrappers, not route destinations. That makes it clear which code blocks the app before navigation and which code participates in navigation itself.

Concurrency

Use the smallest sensible scope:

WorkScope
UI actions and screen loadingviewModelScope
Long-lived app controllers@ApplicationScope from CoroutineModule
DataStore-backed controllersstateIn(applicationScope, SharingStarted.Eagerly, ...)
Root UI callbacksrememberCoroutineScope() from the hosting composable

Examples:

  • SessionController and entitlement/update/locale/theme controllers keep app-wide StateFlows in application scope.
  • TodosViewModel cancels and replaces its load job when the signed-in user changes.
  • NotificationsRepository keeps the push token and inbox as singleton StateFlows because FCM events can arrive outside a visible screen.

Testing

Commands:

./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest

Current conventions:

  • Unit tests live under app/src/test.
  • Coroutine tests use kotlinx-coroutines-test, runTest, and the shared MainDispatcherRule.
  • Repository tests use fake remote data sources.
  • ViewModel tests call constructors directly with fakes.
  • Instrumented tests live under app/src/androidTest and require a connected device or emulator.

Add a feature

  1. Add a model under data/model if the feature has persisted data.
  2. Add a remote data source interface plus Supabase implementation.
  3. Bind the implementation in core/di/RepositoryModule.
  4. Add a repository that returns AppResult.
  5. Add a @HiltViewModel with a single UI state.
  6. Add a route composable that collects the state and a stateless screen composable that receives callbacks.
  7. Register typed routes in Routes.kt or a feature-local graph.
  8. Add fake-backed unit tests before wiring real service behavior.

On this page