-
Notifications
You must be signed in to change notification settings - Fork 0
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.buildGraph(this)
}
}
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.
As the app grew, feature modules accumulated both read-only dashboard logic and complex write-flow logic. This violated separation of concerns and slowed down Gradle builds. To address this, write-flows and management sub-features were extracted into standalone modules:
| Before | After |
|---|---|
:features:balances (read + contributions + withdrawals) |
:features:balances (read-only dashboard) |
:features:contributions (add contribution write-flow) |
|
:features:withdrawals (add cash withdrawal write-flow) |
|
:features:groups (lifecycle + subunit management) |
:features:groups (group lifecycle only) |
:features:subunits (subunit CRUD management) |
- Separation of Concerns: Read-only projections (balance dashboard) and complex write operations (multi-step wizards with validation) are fundamentally different responsibilities. Isolating them prevents changes to transactional logic from triggering recompilation of display logic.
- Build Parallelisation: Gradle builds modules in parallel. Extracting subunits, contributions, and withdrawals into their own modules enables concurrent compilation.
- Independent Entry Points: Write-flows can be reached from deep links, notifications, or other entry points without depending on the entire parent module.
Extracted non-tab modules don't appear in the bottom navigation bar — they are reachable only via LocalTabNavController from within an existing tab. Instead of implementing NavigationProvider, they implement TabGraphContributor:
// 1. Non-tab module defines its TabGraphContributor
class ContributionsTabGraphContributorImpl : TabGraphContributor {
override fun contributeGraph(builder: NavGraphBuilder) {
builder.contributionsGraph()
}
}
// 2. Non-tab module registers it in Koin
factory { ContributionsTabGraphContributorImpl() } bind TabGraphContributor::class
// 3. Host tab's NavigationProvider merges contributed routes at runtime
class BalancesNavigationProviderImpl(
private val graphContributors: List<TabGraphContributor> = emptyList()
) : NavigationProvider {
override fun buildGraph(builder: NavGraphBuilder) {
builder.balancesGraph()
graphContributors.forEach { it.contributeGraph(builder) }
}
}This allows runtime route merging without compile-time cross-feature dependencies. The host tab (:features:balances) never imports anything from :features:contributions — it only depends on the TabGraphContributor interface from :core:design-system.
| Module | Contributor | Host Tab |
|---|---|---|
:features:contributions |
ContributionsTabGraphContributorImpl |
:features:balances |
:features:withdrawals |
WithdrawalsTabGraphContributorImpl |
:features:balances |
:features:subunits |
SubunitsTabGraphContributorImpl |
:features:groups |
To leverage this architecture when creating a new module (e.g., :features:stats):
-
Create Module: Create the module and add
build.gradle.kts. -
Decide Pattern:
- If the feature is a bottom navigation tab → implement
NavigationProvider(provides icon, label, order,buildGraph()). - If the feature is a standalone write-flow reached from within an existing tab → implement
TabGraphContributorand have the host tab'sNavigationProvidermerge it.
- If the feature is a bottom navigation tab → implement
-
Implement Provider: Create
StatsNavigationProviderImpl : NavigationProviderorStatsTabGraphContributorImpl : TabGraphContributor. -
Setup DI: Create
StatsUiModuleand declare the provider:- Tab:
single { StatsNavigationProviderImpl() } bind NavigationProvider::class. - Non-tab:
factory { StatsTabGraphContributorImpl() } bind TabGraphContributor::class.
- Tab:
-
Register: Add the module to
includes()inAppModule.kt(or ensure it's loaded) andsettings.gradle.kts. - Done: Tab features automatically appear in the navigation. Non-tab features are reachable via their host tab's graph.