Skip to content

Commit ebd9a28

Browse files
bmc08gtclaude
andauthored
docs: add concurrency & feature-flags architecture docs (#962)
Extends the docs/architecture/ suite with the two remaining topics: - 17-concurrency: dispatcher injection (DispatcherProvider), scope ownership (viewModelScope vs IO+SupervisorJob singletons, ProcessLifecycleOwner / DefaultLifecycleObserver), the StateFlow/SharedFlow + stateIn(WhileSubscribed) + callbackFlow/shareIn conventions, and lifecycle-aware collection. - 18-feature-flags: FeatureFlag<T> @FeatureFlagMarker definitions + KSP-generated entries, FeatureFlagController/LocalFeatureFlags, DataStore storage, beta override + staff gating, and the feature-flags-vs-user-flags distinction. Trims doc 08's async section to a summary linking to 17, corrects its build-config key list (Coinbase on-ramp, not Fingerprint), and adds 17/18 to the README index and reading paths. Claude-Session: https://claude.ai/code/session_01JRVfsXp4HDrDMy7Pbmw9fD Co-authored-by: Claude <noreply@anthropic.com>
1 parent 74d8495 commit ebd9a28

4 files changed

Lines changed: 302 additions & 13 deletions

File tree

docs/architecture/08-cross-cutting-concerns.md

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -83,22 +83,21 @@ backgrounds), and applies a short cooldown so it doesn't re-prompt on every resu
8383

8484
## Async model
8585

86-
- **`DispatcherProvider`** (`libs/coroutines`) abstracts `Default` / `Main` / `IO`
87-
dispatchers and is injected (rather than referencing `Dispatchers.*` directly) so
88-
code is testable.
89-
- **Controllers** hold a `CoroutineScope(Dispatchers.IO + SupervisorJob())` and
90-
expose state as `StateFlow`.
91-
- **Networking** uses `suspend` + `Result<T>` for unary calls and `Flow<T>` for
92-
streams.
93-
- **Compose** drives side effects with `LaunchedEffect` / `rememberCoroutineScope`
94-
and collects state with `collectAsStateWithLifecycle()`.
86+
The app is **Coroutines + Flow** throughout (no RxJava): `DispatcherProvider`
87+
(`libs/coroutines`) is injected instead of touching `Dispatchers.*`, singletons own
88+
an `IO + SupervisorJob` scope and expose `StateFlow`, networking uses `suspend` +
89+
`Result<T>` (and `Flow<T>` for streams), and Compose collects with
90+
`collectAsStateWithLifecycle()`. The full conventions — scope ownership, the
91+
Flow-exposure pattern, and lifecycle-aware streaming — are in
92+
[17 — Concurrency](17-concurrency.md).
9593

9694
## Build configuration
9795

9896
`compileSdk 36`, `minSdk 29`, **Java/Kotlin 21**, all set by the convention plugins
99-
(see [01](01-modules-and-boundaries.md)). API keys (Bugsnag, Fingerprint, Mixpanel,
100-
Google Cloud project number) are read from `local.properties` and surfaced through
101-
`BuildConfig`; `google-services.json` lives under `apps/flipcash/app/src/`.
97+
(see [01](01-modules-and-boundaries.md)). API keys (Bugsnag, Mixpanel, Coinbase
98+
on-ramp, Google Cloud project number) are read from `local.properties` and surfaced
99+
through `BuildConfig`; `google-services.json` lives under `apps/flipcash/app/src/`.
100+
See [10 — Build & run](10-build-and-run.md) for the full list.
102101

103102
## Why this matters
104103

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# 17 — Concurrency
2+
3+
Flipcash is **Coroutines + Flow** end to end (no RxJava). This doc covers the
4+
conventions that keep that manageable: who owns a `CoroutineScope`, when to inject
5+
a dispatcher vs. use `viewModelScope`, how state is exposed as `Flow`, and how
6+
collection stays lifecycle-aware.
7+
8+
```mermaid
9+
graph TD
10+
DP["DispatcherProvider (Default / Main / IO)<br/>injected via DispatcherModule"]
11+
VM["ViewModel<br/>viewModelScope (auto-cancel)"]
12+
Coord["Coordinator / Controller (@Singleton)<br/>CoroutineScope(IO + SupervisorJob)"]
13+
PLO["ProcessLifecycleOwner + DefaultLifecycleObserver<br/>onStart/onStop start/cancel stream Jobs"]
14+
Flow["StateFlow / SharedFlow<br/>stateIn(WhileSubscribed) · callbackFlow+shareIn"]
15+
UI["Compose<br/>collectAsStateWithLifecycle()"]
16+
17+
DP --> VM
18+
DP --> Coord
19+
Coord --> PLO
20+
VM --> Flow
21+
Coord --> Flow
22+
Flow --> UI
23+
```
24+
25+
## Dispatchers are injected, not referenced
26+
27+
Code does not call `Dispatchers.IO`/`Default`/`Main` directly; it depends on
28+
`DispatcherProvider`
29+
([`libs/coroutines/.../DispatcherProvider.kt`](../../libs/coroutines/src/main/kotlin/com/flipcash/libs/coroutines/DispatcherProvider.kt)):
30+
31+
```kotlin
32+
interface DispatcherProvider {
33+
val Default: CoroutineDispatcher
34+
val Main: CoroutineDispatcher
35+
val IO: CoroutineDispatcher
36+
}
37+
```
38+
39+
The production binding is `DefaultDispatcherProvider` (just delegates to
40+
`Dispatchers.*`), provided `@Singleton` by `DispatcherModule`
41+
([`apps/flipcash/app/.../inject/DispatcherModule.kt`](../../apps/flipcash/app/src/main/kotlin/com/flipcash/app/inject/DispatcherModule.kt)).
42+
Injecting it is what makes coroutine code deterministic in tests — swap in
43+
`TestDispatchers` ([12 — Testing](12-testing.md)).
44+
45+
- **CPU-bound work**`dispatchers.Default` (also the default for `BaseViewModel`
46+
event dispatch, [02](02-state-and-dependency-injection.md)).
47+
- **Network / disk / DataStore / Room**`dispatchers.IO`.
48+
- **UI-touching work**`dispatchers.Main` (rarely needed explicitly; Compose
49+
collection is already on the main thread).
50+
51+
## Scope ownership
52+
53+
Every coroutine belongs to a scope owned by exactly one thing:
54+
55+
| Owner | Scope | Cancellation |
56+
|-------|-------|--------------|
57+
| **ViewModel** | `viewModelScope` | Automatic on `onCleared()`. |
58+
| **Coordinator / Controller** (`@Singleton`) | `CoroutineScope(Dispatchers.IO + SupervisorJob())` | Lives for the process; child failures are isolated by `SupervisorJob`. |
59+
| **Composable** | `rememberCoroutineScope()` / `LaunchedEffect` | Tied to composition. |
60+
61+
ViewModels never create their own scope — they use `viewModelScope` so work dies
62+
with the screen. Long-lived singletons (e.g. `TransactionController`,
63+
`TokenCoordinator`, `ChatCoordinator`, `UserFlagsCoordinator`) own one
64+
`IO + SupervisorJob` scope and launch their flows into it.
65+
66+
### App-lifecycle awareness
67+
68+
There is **no custom `@ApplicationScope`**. Instead, singletons that stream from the
69+
backend implement `DefaultLifecycleObserver` and register with the process lifecycle,
70+
starting/cancelling streaming `Job`s as the app foregrounds/backgrounds:
71+
72+
```kotlin
73+
@Singleton
74+
class TokenCoordinator @Inject constructor(/* ... */) :
75+
SessionListener, DefaultLifecycleObserver, /* ... */ {
76+
77+
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
78+
private var streamReserveStateJob: Job? = null
79+
80+
init { ProcessLifecycleOwner.get().lifecycle.addObserver(this) }
81+
82+
override fun onStart(owner: LifecycleOwner) { streamReserveStates() } // foreground
83+
override fun onStop(owner: LifecycleOwner) { streamReserveStateJob?.cancel() } // background
84+
}
85+
```
86+
87+
This keeps background work off when the app isn't visible, and resumes (re-syncing)
88+
on return. Session transitions (login/logout) are handled the same way via
89+
`SessionListener` — see the coordinator role in
90+
[02](02-state-and-dependency-injection.md#roles-coordinators-controllers-managers-services).
91+
92+
## Flow conventions
93+
94+
State is exposed as **read-only `Flow`**, mutated only internally:
95+
96+
- Hold internal state in `MutableStateFlow` / `MutableSharedFlow`; expose it as
97+
`StateFlow` / `SharedFlow` via `asStateFlow()` / `asSharedFlow()` (the
98+
`BaseViewModel` pattern, [02](02-state-and-dependency-injection.md)).
99+
- Derive hot state with
100+
`stateIn(scope, SharingStarted.WhileSubscribed(5_000), default)` — shares one
101+
upstream across collectors and stops it shortly after the last unsubscribes.
102+
`SharingStarted.Eagerly` is used for always-on state (e.g. feature-flag overrides).
103+
- Bridge callback APIs with `callbackFlow { … awaitClose { unregister } }`, then
104+
`debounce` / `distinctUntilChanged` / `shareIn`. The connectivity observer
105+
([`Api24NetworkObserver`](../../libs/network/connectivity/impl/src/main/kotlin/com/getcode/utils/network/Api24NetworkObserver.kt))
106+
is the canonical example.
107+
- Combine and switch with `combine`, `flatMapLatest`, `mapNotNull`,
108+
`filterIsInstance`; attach side effects with `…onEach { }.launchIn(scope)`.
109+
110+
## Lifecycle-aware collection
111+
112+
UI collects with **`collectAsStateWithLifecycle()`** so collection pauses below
113+
`STARTED` and resumes without re-subscribing churn:
114+
115+
```kotlin
116+
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
117+
```
118+
119+
For non-state work that must run only while a lifecycle is at a given state, use the
120+
`RepeatOnLifecycle` helper
121+
([`ui/navigation/.../utils/lifecycle/RepeatOnLifecycle.kt`](../../ui/navigation/src/main/kotlin/com/getcode/navigation/utils/lifecycle/RepeatOnLifecycle.kt)),
122+
which wraps `repeatOnLifecycle`. One-shot effects (navigation, showing a bill) are
123+
collected off `eventFlow` inside a `LaunchedEffect` ([02](02-state-and-dependency-injection.md)).
124+
125+
## Testing
126+
127+
Because dispatchers are injected, tests pass `TestDispatchers` (all of
128+
`IO`/`Main`/`Default` mapped to one `StandardTestDispatcher`) and use
129+
`MainCoroutineRule` to drive `viewModelScope`. Assert on flows with Turbine inside
130+
`runTest`. See [12 — Testing](12-testing.md).
131+
132+
## Guidance
133+
134+
- **Own your scope.** ViewModel → `viewModelScope`; singleton → one
135+
`IO + SupervisorJob` scope; never leak a `GlobalScope`.
136+
- **Inject `DispatcherProvider`** instead of touching `Dispatchers.*` — it's the
137+
testability seam.
138+
- **Expose `StateFlow`/`SharedFlow`, never the mutable type.**
139+
- **Gate streaming on lifecycle** (`ProcessLifecycleOwner` + `onStart`/`onStop`) so
140+
background work stops.
141+
- **Collect with `collectAsStateWithLifecycle()`** in Compose.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# 18 — Feature flags
2+
3+
Flipcash has **two** independent toggle systems, and conflating them is the most
4+
common mistake:
5+
6+
- **Feature flags***client-side* toggles for rolling out / experimenting with app
7+
features. Defined in code, stored locally, flipped by developers/staff.
8+
- **User flags***server-driven, per-account* configuration and entitlements
9+
(e.g. `isStaff`). Delivered with the account, optionally overridden locally.
10+
11+
This doc covers feature flags first, then the distinction.
12+
13+
```mermaid
14+
graph TD
15+
Def["FeatureFlag<T> @FeatureFlagMarker data objects"]
16+
KSP["KSP FeatureFlagProcessor -> FeatureFlag.entries"]
17+
Ctrl["FeatureFlagController (InternalFeatureFlagController)"]
18+
DS["DataStore 'beta-flags'"]
19+
Local["LocalFeatureFlags (Compose)"]
20+
Feat["Feature reads observe(flag)"]
21+
User["UserFlags (server) via UserManager / UserFlagsCoordinator"]
22+
Gate["combine(flag, userFlag) -> enabled"]
23+
24+
Def --> KSP --> Ctrl
25+
Ctrl --> DS
26+
Ctrl --> Local --> Feat
27+
Ctrl --> Gate
28+
User --> Gate
29+
```
30+
31+
## What a feature flag is
32+
33+
A feature flag is a typed toggle behind the `FeatureFlagController` interface
34+
([`apps/flipcash/shared/featureflags/.../FeatureFlagController.kt`](../../apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlagController.kt)):
35+
36+
```kotlin
37+
interface FeatureFlagController {
38+
fun observe(): StateFlow<List<BetaFeature>>
39+
fun observe(flag: FeatureFlag<*>): StateFlow<Boolean>
40+
suspend fun get(flag: FeatureFlag<*>): Boolean
41+
fun set(flag: FeatureFlag<*>, value: Boolean)
42+
fun setOption(flag: FeatureFlag<*>, optionKey: String)
43+
fun getOption(flag: FeatureFlag<*>): StateFlow<String>
44+
fun observeOverride(): StateFlow<Boolean> // global beta unlock
45+
fun enableBetaFeatures(); fun disableBetaFeatures()
46+
fun reset(flag: FeatureFlag<*>); fun reset()
47+
}
48+
```
49+
50+
The implementation `InternalFeatureFlagController` persists values in a DataStore
51+
(`beta-flags`). It's provided `@Singleton` by `FeatureFlagModule` and surfaced to
52+
Compose as `LocalFeatureFlags` (provided in `MainActivity`,
53+
[02](02-state-and-dependency-injection.md)); outside a provider the default is the
54+
inert `NoOpFeatureFlagController`.
55+
56+
## Defining / adding a flag
57+
58+
Flags are `@FeatureFlagMarker data object`s implementing `FeatureFlag<T>`
59+
([`FeatureFlag.kt`](../../apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt)):
60+
61+
```kotlin
62+
@FeatureFlagMarker
63+
data object CredentialManager : FeatureFlag<Boolean> {
64+
override val key = "credential_manager_enabled" // DataStore key
65+
override val default = false
66+
override val launched = false // true => fully shipped, no longer toggleable
67+
override val visible = true // shows in debug menus
68+
override val persistLogOut = true // survives logout
69+
}
70+
```
71+
72+
Boolean flags are the norm (`VibrateOnScan`, `BillCustomizer`, `CurrencyCreator`,
73+
`Messenger`, …); option flags carry a list of choices (e.g. `BackgroundReset`). A
74+
**KSP processor** (`FeatureFlagProcessor`, `apps/flipcash/shared/ksp/`) discovers
75+
every `@FeatureFlagMarker` and generates the `FeatureFlag.entries` registry the
76+
debug UI iterates.
77+
78+
**To add a flag:** declare a new `@FeatureFlagMarker data object` in `FeatureFlag.kt`
79+
with a unique `key` and `default` (plus title/description text alongside the other
80+
flags). KSP regenerates `entries`; no manual registration. Read it where needed.
81+
82+
## Reading / observing a flag
83+
84+
```kotlin
85+
// In a coordinator/controller (inject FeatureFlagController):
86+
featureFlags.observe(FeatureFlag.BillTextures)
87+
.onEach { enabled -> _state.update { it.copy(enabled = enabled) } }
88+
.launchIn(scope)
89+
90+
// In Compose:
91+
val flags = LocalFeatureFlags.current
92+
```
93+
94+
Prefer `observe(flag)` (reactive `StateFlow`) so the UI updates when a flag flips;
95+
use `get(flag)` for a one-shot read.
96+
97+
## Beta override & staff gating
98+
99+
Flags that aren't `launched` are hidden unless **beta features** are unlocked, via
100+
either `observeOverride()` (a debug-menu toggle) or staff status from the user's
101+
account flags. Features combine the two:
102+
103+
```kotlin
104+
combine(
105+
featureFlagController.observeOverride(),
106+
userManager.state.map { it.flags?.isStaff == true },
107+
) { override, isStaff -> override || isStaff }
108+
.onEach { dispatchEvent(Event.OnBetaFeaturesUnlocked(it)) }
109+
.launchIn(viewModelScope)
110+
```
111+
112+
(See `MyAccountScreenViewModel` and `LabsScreenViewModel`.)
113+
114+
## Feature flags vs user flags
115+
116+
| | Feature flags | User flags |
117+
|---|---------------|-----------|
118+
| **Module** | `:apps:flipcash:shared:featureflags` | `:apps:flipcash:shared:userflags` |
119+
| **Owner** | `FeatureFlagController` | `UserFlagsCoordinator` |
120+
| **Source** | Client; defined in `FeatureFlag.kt` | **Server**, per account (`UserFlags`) |
121+
| **Storage** | Local DataStore (`beta-flags`) | Account state + local override DataStore |
122+
| **Purpose** | Feature rollout / experiments | Entitlements & account config (`isStaff`, `enablePhoneNumberSend`, `preferredOnRampProvider`) |
123+
| **Read via** | `observe(flag)` / `LocalFeatureFlags` | `userManager.state.flags` / `UserFlagsCoordinator.resolvedFlags` |
124+
125+
`UserFlags` lives in
126+
[`services/flipcash/.../models/UserFlags.kt`](../../services/flipcash/src/main/kotlin/com/flipcash/services/models/UserFlags.kt);
127+
`UserFlagsCoordinator` resolves each as a `ResolvedFlag` whose `effectiveValue` is the
128+
server value unless a local override is set (staff/debug). A feature often gates on
129+
**either** signal — e.g. `ChatCoordinator` enables messaging when
130+
`FeatureFlag.PhoneNumberSend` **or** the server's `enablePhoneNumberSend` is on:
131+
132+
```kotlin
133+
combine(
134+
featureFlags.observe(FeatureFlag.PhoneNumberSend),
135+
userManager.state.map { it.flags?.enablePhoneNumberSend == true },
136+
) { feature, server -> feature || server }
137+
```
138+
139+
## Guidance
140+
141+
- **Pick the right system:** a client rollout toggle → feature flag; an
142+
account-level entitlement from the backend → user flag.
143+
- **Add flags in `FeatureFlag.kt`** and let KSP register them; don't maintain a
144+
manual list.
145+
- **Observe, don't poll**`observe(flag)` so UI reacts to changes.
146+
- **Mark a flag `launched`** once it ships to retire the toggle.

docs/architecture/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,15 @@ depend on app modules.* See [01 — Modules & boundaries](01-modules-and-boundar
8080
| 14 | [Error handling](14-error-handling.md) | `Result<T>`, typed sealed errors, `NotifiableError`, `retryable` |
8181
| 15 | [CI & release](15-ci-and-release.md) | The CI check, Fastlane lanes, release workflows, helper skills |
8282
| 16 | [Agents & skills](16-agents-and-skills.md) | The repo's Claude Code agents/skills and which task each one fits |
83+
| 17 | [Concurrency](17-concurrency.md) | Dispatchers, scope ownership, Flow conventions, lifecycle-aware collection |
84+
| 18 | [Feature flags](18-feature-flags.md) | Defining/observing flags, beta & staff gating, feature flags vs user flags |
8385
|| [Glossary](glossary.md) | Domain & architecture terms (USDF, intent, timelock, coordinator, …) |
8486

8587
## Suggested reading paths
8688

8789
- **New to the codebase** → 10 (build & run) → README → 01 → 02 → 09, then the feature catalog.
88-
- **Building a feature** → 11 (adding a feature) → feature catalog → 03 → 07 → 02.
90+
- **Building a feature** → 11 (adding a feature) → feature catalog → 03 → 07 → 02 → 18 (feature flags).
91+
- **Writing async code** → 17 (concurrency) → 02 → 12 (testing).
8992
- **Writing tests** → 12 (testing) → [Compose UI Testing Guide](../compose-ui-testing.md).
9093
- **Working on payments** → 06 → 04 → 05 → 14 (error handling).
9194
- **Backend / proto changes** → 13 (protobuf & codegen) → 04 → 05 → 14.

0 commit comments

Comments
 (0)