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 Package | Owns | Should Not Own |
|---|---|---|
MainActivity | Activity lifecycle, setContent, top-level app container | Business rules, database calls, permission policy decisions |
ui/*Screen | Render immutable state and send events | DAO calls, parsing, background work scheduling details |
ViewModel | UI state, event handling, calling repositories, launching view-model scoped work | Android view code or long-lived persistent jobs |
Repository | Business operations and data-source coordination | Compose state or Activity references |
Dao | SQL queries and database writes | Business policy or UI formatting |
Worker | Reliable persistent background task | Screen-only async work or UI updates |
AppContainer | Manual dependency wiring | Feature 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.