-
Notifications
You must be signed in to change notification settings - Fork 0
Deep Dive: Modularity & Scalability
This page explains the advanced architectural patterns used to keep SplitTrip loosely coupled. The core philosophy is that the :app module should be a "dumb assembler" that doesn't know the implementation details of the features it hosts.
In traditional apps, the AppNavHost hardcodes every feature:
// ❌ Traditional approach (Coupled)
NavHost(...) {
expensesGraph()
settingsGraph()
groupsGraph() // Every time you add a feature, you edit this file.
}
In SplitTrip, we use a Discovery Pattern powered by Koin.
-
The Contract: We define
NavigationProviderin:core:design-system. -
The Implementation: Each feature module (e.g.,
:features:groups) implements this interface to define its own graph and (optionally) its Bottom Navigation tab. - The Injection: The feature module declares this implementation in its Koin module:
// In GroupsUiModule.kt
single { GroupsNavigationProviderImpl() } bind NavigationProvider::class
-
The Discovery: The
MainScreenandAppNavHostsimply ask Koin for all providers:
// In AppNavHost.kt
val allFeatures: List<NavigationProvider> = koin.getAll()
NavHost(...) {
allFeatures.forEach { feature ->
feature.registerGraph(this, navController)
}
}
Benefit: You can add, remove, or disable entire feature modules without changing a single line of code in the :app module.
We organize Koin modules by Layer, not just by Screen. This ensures strict visibility rules.
| Module Type | Responsibility | Example Content |
|---|---|---|
| App Module | The Assembler | Collects all other modules and starts Koin. |
| Feature Module | Presentation Logic |
ViewModels, NavigationProviders, ScreenUiProviders. |
| Domain Module | Pure Business Logic |
UseCases, Repository Interfaces. |
| Data Module | Implementation |
RepositoryImpls, DataSources (Firebase/Room). |
Notice that Feature modules never see Data modules directly.
-
UI asks for a
UseCase(Domain). -
UseCase asks for a
RepositoryInterface (Domain). -
Koin binds the
RepositoryImpl(Data) to that Interface at runtime.
The app is designed to work offline using a Single Source of Truth (SSOT) pattern.
-
Read: The UI always observes the Local Database (Room) via
Flow. It never waits for the Network to show data. - Write: User actions (e.g., "Add Expense") are written to the Local Database first.
- Sync: A background operation (Repository or Worker) pushes the change to Firebase.
- Update: When Firebase confirms the save (or sends new data), we update the Local Database.
-
Refresh: The
Flowobserving the Local Database emits the new data automatically.
sequenceDiagram
participant UI as UI (Screen)
participant Local as Local DB (Room)
participant Repo as Repository
participant Cloud as Firebase
UI->>Local: Observe Data (Flow)
Local-->>UI: Emit Cached Data
UI->>Repo: Add Expense (Action)
Repo->>Local: Insert (Optimistic Update)
Local-->>UI: Flow Updates Immediately
Repo->>Cloud: Sync to Cloud
Cloud-->>Repo: Success/New Data
Repo->>Local: Update/Confirm
Local-->>UI: Flow Updates (Final State)
This ensures the app feels instant even on slow networks.
To leverage this architecture when creating a new module (e.g., :features:stats):
-
Create Module: Create the module and add
build.gradle.kts. -
Implement Provider: Create
StatsNavigationProviderImpl : NavigationProvider. -
Setup DI: Create
StatsUiModuleand declare the provider:single { StatsNavigationProviderImpl() } bind NavigationProvider::class. -
Register: Add the module to
includes()inAppModule.kt(or ensure it's loaded). - Done: The new screen will automatically appear in the app navigation.