Starter Architecture

Start with a small monolith. Split later when the app has real feature pressure.

app/
  src/main/java/com/example/app/
    MainActivity.kt
    AppContainer.kt
    ui/
      App.kt
      theme/
      transactions/
        TransactionListScreen.kt
        TransactionListViewModel.kt
        TransactionListUiState.kt
    data/
      AppDatabase.kt
      transaction/
        TransactionEntity.kt
        TransactionDao.kt
        TransactionRepository.kt
    work/
      ExportBackupWorker.kt

Responsibilities

File Or PackageOwnsShould Not Own
MainActivityActivity lifecycle, setContent, top-level app containerBusiness rules, database calls, permission policy decisions
ui/*ScreenRender immutable state and send eventsDAO calls, parsing, background work scheduling details
ViewModelUI state, event handling, calling repositories, launching view-model scoped workAndroid view code or long-lived persistent jobs
RepositoryBusiness operations and data-source coordinationCompose state or Activity references
DaoSQL queries and database writesBusiness policy or UI formatting
WorkerReliable persistent background taskScreen-only async work or UI updates
AppContainerManual dependency wiringFeature logic

Manual DI First

Use a tiny AppContainer for v1:

class AppContainer(context: Context) {
    private val database = Room.databaseBuilder(
        context.applicationContext,
        AppDatabase::class.java,
        "app.db",
    ).build()

    val transactionRepository = TransactionRepository(
        transactionDao = database.transactionDao(),
    )
}

Move to Hilt when construction logic becomes repetitive, when workers need injected dependencies, or when tests are spending more time replacing dependencies than testing behavior.

Screen State Pattern

Expose one immutable state object and one event function per screen:

data class TransactionListUiState(
    val isLoading: Boolean = true,
    val transactions: List<TransactionRow> = emptyList(),
    val errorMessage: String? = null,
)

sealed interface TransactionListEvent {
    data object Refresh : TransactionListEvent
    data class SearchChanged(val query: String) : TransactionListEvent
}

The composable reads state and sends events. The ViewModel decides what the event means.

Room First For Local Apps

For local-first apps, the Room database is the source of truth. The UI observes repository flows derived from DAOs. Imports, SMS scans, exports, and background jobs write through repositories, not around them.

WorkManager Only When Needed

Use coroutines for work tied to a visible screen. Use WorkManager for work that should finish after the user leaves the screen, after app restart, or after device reboot.

Gotcha: A WorkManager job that reads UI state directly is a design smell. Pass stable IDs or input data, then read durable state from Room inside the worker.