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 ScreenThe 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:
| Type | Role |
|---|---|
@HiltAndroidApp SoarApp | Application graph and eager startup hooks |
@AndroidEntryPoint MainActivity | Injects controllers used by the root Compose tree |
@HiltViewModel | Injects repositories/controllers into screen view models |
@Singleton controllers/repositories | Shared app services such as sessions, purchases, DataStore-backed controllers, analytics, toasts |
core/di/ currently contains:
| Module | Provides or binds |
|---|---|
CoroutineModule | @ApplicationScope CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) |
DataStoreModule | Preferences DataStore stored as soar_preferences |
RepositoryModule | Supabase remote data source interfaces and Google Sign-In controller binding |
SessionModule | Flow<UserInfo?> from SessionController.currentUser |
BiometricModule | BiometricSettings 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
-> ViewModelUnit 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.
Navigation
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 -> ConsentGatecomposition.- The outer
NavHostwithMainShell, auth graph, paywall, refer, upload, subscription, and redeem-code destinations. - A private
MainNavGraphcomposable 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:
| Work | Scope |
|---|---|
| UI actions and screen loading | viewModelScope |
| Long-lived app controllers | @ApplicationScope from CoroutineModule |
| DataStore-backed controllers | stateIn(applicationScope, SharingStarted.Eagerly, ...) |
| Root UI callbacks | rememberCoroutineScope() from the hosting composable |
Examples:
SessionControllerand entitlement/update/locale/theme controllers keep app-wideStateFlows in application scope.TodosViewModelcancels and replaces its load job when the signed-in user changes.NotificationsRepositorykeeps the push token and inbox as singletonStateFlows because FCM events can arrive outside a visible screen.
Testing
Commands:
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTestCurrent conventions:
- Unit tests live under
app/src/test. - Coroutine tests use
kotlinx-coroutines-test,runTest, and the sharedMainDispatcherRule. - Repository tests use fake remote data sources.
- ViewModel tests call constructors directly with fakes.
- Instrumented tests live under
app/src/androidTestand require a connected device or emulator.
Add a feature
- Add a model under
data/modelif the feature has persisted data. - Add a remote data source interface plus Supabase implementation.
- Bind the implementation in
core/di/RepositoryModule. - Add a repository that returns
AppResult. - Add a
@HiltViewModelwith a single UI state. - Add a route composable that collects the state and a stateless screen composable that receives callbacks.
- Register typed routes in
Routes.ktor a feature-local graph. - Add fake-backed unit tests before wiring real service behavior.
