25 KiB
BankCBS — Mobile Application Architecture
A React Native mobile banking client for a Core Banking System (CBS) backend. It serves two distinct applications from one binary — a retail customer app and a field-agent app — selected at runtime by the authenticated user's role, with every navigable route, tab and control individually switchable through a hierarchical feature-flag engine.
| Package | bankcbs v1.0.0 (com.bankcbs.mobile) |
| Runtime | React Native 0.83.4, React 19.2, TypeScript 5.9 (strict, noUncheckedIndexedAccess) |
| State | Redux Toolkit 2.11 + RTK Query, react-redux 9.2 |
| Navigation | React Navigation 7 (native-stack + bottom-tabs) |
| Transport | Axios 1.14 wrapped by a custom RTK Query baseQuery |
| Crypto / secrets | node-forge (RSA-OAEP), react-native-keychain, react-native-biometrics |
| Scale | 381 source files · ~58,300 LOC · 103 screens · 17 modules · 182 feature keys |
1. Layering
Dependencies flow strictly downward. Nothing in a lower layer imports from a higher one.
┌───────────────────────────────────────────────────────────────────┐
│ App.tsx / index.js composition root, provider order │
├───────────────────────────────────────────────────────────────────┤
│ src/navigation/ role routing + feature-gated routes │
├───────────────────────────────────────────────────────────────────┤
│ src/modules/<domain>/ 17 self-contained domain modules │
│ screens · components · hooks · store · services · types · utils│
├───────────────────────────────────────────────────────────────────┤
│ src/features/ feature-flag engine (cross-cutting) │
│ src/components/ 16 shared presentational primitives │
│ src/theme/ design tokens + ThemeProvider │
│ src/hooks/ app-wide hooks (bootstrap, session…) │
├───────────────────────────────────────────────────────────────────┤
│ src/services/ api · security · storage · device … │
│ src/app/ store, rootReducer, typed hooks │
│ src/config/ constants/ types/ utils/ │
└───────────────────────────────────────────────────────────────────┘
Directory map
| Path | Responsibility |
|---|---|
| src/app/ | Store configuration, rootReducer, typed useAppDispatch/useAppSelector |
| src/navigation/ | Root/tab/stack navigators, param lists, route→feature maps |
| src/modules/ | Domain modules (see §7) |
| src/features/ | Feature-flag service, provider, gates, hooks |
| src/services/api/ | Axios client, interceptors, endpoint registry, error catalogue |
| src/services/security/ | Session manager, device identity, MPIN crypto, biometrics |
| src/services/storage/ | Keychain/AsyncStorage-backed secure storage |
| src/components/ | Shared UI primitives, barrel-exported |
| src/theme/ | FintechTheme token contract + context |
| src/constants/ | Feature keys, route names, RTK Query tags, translations, fixtures |
| src/types/ | api.ts (client errors), app.ts (domain), cbs.ts (wire types) |
2. Composition root
App.tsx fixes the provider order, and the order is load-bearing:
GestureHandlerRootView
└── SafeAreaProvider
└── redux Provider ← store must exist before features read session
└── FeatureProvider ← flags must resolve before navigators register routes
└── ThemeProvider
└── AppShell
├── useAppBootstrap()
└── ActivityTracker (touch capture → session keep-alive)
├── RootNavigator
└── ToastHost
index.js handles three pre-React concerns:
react-native-get-random-valuesis imported first —uuidbindscrypto.getRandomValuesat module load, and both the device ID and the MPIN nonce importuuid, so a later import throws at startup.notifee.onBackgroundEventis registered (required by Notifee even as a no-op).Text/TextInputdefaultPropsare patched to theNunito-Regularfont family.
Bootstrap sequence
src/hooks/useAppBootstrap.ts runs once on mount:
bootstrapAuthSession()— mints the device ID and validates any stored session against the server before trusting it.seedAllData()— populates the fixture-backed modules (profile, dashboard, beneficiaries, payees, deposits, service requests, loans, insurance, cards).markAppReady(), thenregisterForPushNotifications().- Registers a global unauthorized handler that dispatches
logoutSession()and raises a toast.
It also re-seeds on every sign-in transition, because logout wipes the entire
Redux tree (§4), and on sign-out re-reads only the sign-in context — a full
bootstrap would raise isHydrating and blank the app behind a loader on the way
out.
3. Navigation architecture
RootNavigator ← isHydrating? Loader : isAuthenticated?
├── AuthNavigator (unauthenticated)
│ Login · Register · VerifyOtp · CreateMpin · MpinLocked
├── MainTabNavigator (role: 'User')
│ ├── Home → HomeNavigator (+ nested LoanFlow, InsuranceFlow)
│ ├── Payments → PaymentsNavigator
│ ├── Services → ServicesNavigator (Hub; tabPress resets the stack)
│ ├── Cards → CardsNavigator
│ └── Profile → ProfileNavigator (lives in src/modules/profile/navigation/)
└── AgentNavigator (role: 'Agent')
├── AgentDashboard · AgentCustomers · AgentCollections
└── AgentLoans · AgentProfile (each a nested native stack)
Three deliberate decisions are documented in the code:
- Role switch, not a route. RootNavigator.tsx
picks the app stack from
auth.role. DisablingFeatureKey.AGENTretires the whole field-agent app; an already-signed-in agent falls back to the customer app rather than landing on an empty navigator. - Initial route computed, never navigated to.
AuthNavigator.tsx derives
initialRouteNamefrom a stored profile instead ofreplace()-ing in an effect — an effect-driven replace races navigation-container readiness and is silently dropped, and the warning that would reveal it is stripped from release builds.signInContextLoadedgates this so a null profile means "unknown" rather than "never registered" before the Keystore has been read. CreateMpinandMpinLockeddisable the back gesture. The setup token is single-use; leaving halfway means restarting registration.
Route-level feature gating
src/navigation/featureRoutes.ts maps every
route name to the FeatureKey that owns it, and each navigator wraps its
Screen in canRoute(...):
const canRoute = useRouteGuard(CARDS_ROUTE_FEATURES);
{canRoute('CardDetails') && <Stack.Screen name="CardDetails" component={CardDetailsScreen} />}
A disabled route is never registered, so it cannot be reached by
navigate(), a deep link, or restored navigation state. Hiding the menu entry
alone would not achieve that.
FeatureRoute.tsx is a secondary screen-level
guard for defence in depth.
4. State management
src/app/store.ts · src/app/rootReducer.ts
16 domain slices plus two RTK Query reducers (baseApi, schemesApi).
serializableCheck is disabled.
Logout wipes everything. rootReducer intercepts
auth/logoutSession/fulfilled and sets state = undefined, so no slice can
leak data across sessions. Re-seeding on sign-in (§2) is the counterpart to this.
Three coexisting data-access patterns:
| Pattern | Where | Used by |
|---|---|---|
baseApi.injectEndpoints (RTK Query over Axios) |
modules/*/services/*Api.ts |
agent, cards, deposit, home ×2, insurance, loan, payments, profile, services ×2, transfer |
createSlice + createAsyncThunk |
modules/*/store/ |
auth, cards, deposit, home, insurance, loan, payments, profile, services, transfer, ui |
Standalone createApi |
modules/schemes/services/schemesApi.ts |
schemes only — its own reducer path, fixture queryFns |
Cache invalidation runs on a central tag list —
src/constants/queryTags.ts, 25 tags — registered on
baseApi so any module can invalidate another's cache.
5. Data & API layer
The envelope
Every CBS response shares one wrapper (src/types/cbs.ts):
interface CbsEnvelope<T> {
success: boolean; message: string;
data?: T; // present on success; VALIDATION_ERROR carries a field map here
errorCode?: string; // only on failure — this is what the app switches on
timestamp: string;
}
Interceptors
src/services/api/interceptors.ts is where the contract is enforced:
- Request: attaches
Authorization: Bearer <token>fromsecureStorage. - Response: branches on
envelope.success, not the HTTP status — a failure can arrive with a 2xx, and treating it as a payload hands callersundefined. Failures are thrown as aCbsApiError; successes are unwrapped so callers readresponse.dataas the payload. - Session mirroring: any call carrying an
Authorizationheader callssessionManager.touch(), because that same call reset the server's idle timer. - Session loss: only
SESSION_INVALIDclears storage and fires the global unauthorized handlers.INVALID_CREDENTIALSshares the 401 but means a wrong MPIN on the sign-in form — bouncing the user out of a screen they aren't in yet would be nonsense.
Error catalogue
src/services/api/cbsErrors.ts enumerates the
31 backend error codes as a const tuple, plus 3 client-synthesised codes
(NETWORK_ERROR, REQUEST_TIMEOUT, UNKNOWN_ERROR). Each has authored
{ title, description, retryable } copy, so no failure can render as a blank
toast — asserted by cbsErrors.test.ts.
errorCode is treated as the stable contract; message is assumed re-wordable
server-side, so no branch reads the message or the status.
Endpoint registry & mock strategy
All paths live in one object, src/services/api/endpoints.ts, with parameterised routes as functions.
resolveWithMock in
serviceHelpers.ts returns fixtures when
appConfig.features.useMockServer is set. Per its own contract, endpoints the
CBS API actually implements must not go through it — they call apiClient
directly so failures surface rather than being papered over with demo data.
Live today: registration, sign-in, accounts/MMID, payees. Fixture-backed:
dashboard, cards, loans, insurance, deposits, service requests, the agent app.
6. Feature-flag engine
The most distinctive part of the architecture: 182 keys covering every module, screen, tab, menu row, button and action.
Keys encode the hierarchy
src/constants/featureKeys.ts declares a dot-notation enum; the service derives parent/child from the path itself, so the tree can never drift from the naming.
loans → loans.apply → loans.apply.submit
featureConfig.ts is a flat
Record<FeatureKey, FeatureDefinition> on purpose — the Record makes
TypeScript reject the file if any declared key lacks config, which a nested
object literal cannot guarantee.
Resolution
featureService.ts — one instance, holding a registry, a layer stack, a resolution cache, and a listener set:
default → environment → remote → tenant → user (lowest → highest priority)
A feature is enabled only if it exists, its own flag is on, and every ancestor
resolves enabled. Resolution is fail-closed: an unknown key, or a key with
missing config, is false — in a banking app an unrecognised flag must hide the
feature, never reveal it. validate() runs in __DEV__ and reports
missing-config, unknown-key and missing-parent issues, catching a typo like
loans.aply.submit that would otherwise become a root feature no parent could
switch off.
Consumption surface
| API | Use |
|---|---|
useFeature(key) / useFeatures([...]) |
reactive boolean(s); subscribes via useSyncExternalStore on a version counter |
<FeatureGate feature={...} mode="all|any"> |
renders children or nothing — deliberately no fallback prop, so a disabled feature leaves no trace: no greyed control, no "coming soon", no hidden-but-present node |
<DynamicFeatureGate feature="string"> |
JSON-driven UI where the key arrives as data |
useFeatureAction / guardFeature |
guards a handler some other path may still hold |
useRouteGuard(map) |
navigator-level route registration (§3) |
FeatureProvider |
resolves async remote/tenant layers at startup, exposes ready |
FeatureProvider swallows loader failures so the app always falls back to
compiled-in defaults rather than blocking startup. Remote and tenant loaders are
optional props — nothing is wired to Firebase Remote Config or a config API yet,
but the seam exists without touching a single UI component.
7. Module anatomy
Every module under src/modules/ follows the same internal shape, so any module is navigable once one of them is:
modules/<domain>/
├── screens/ route components
├── components/ domain-specific presentational pieces
├── hooks/ form/flow logic extracted from screens
├── store/ <domain>Slice.ts · <domain>Thunk.ts · <domain>Selectors.ts
├── services/ <domain>Api.ts (baseApi.injectEndpoints)
├── types/ domain types
├── utils/ calculators, validators, fixtures
├── constants/ domain constants (agent, awareness, schemes)
└── navigation/ only where the module owns its own navigator (agent, profile)
| Module | Screens | Components | Store | Services | Hooks | Files |
|---|---|---|---|---|---|---|
| agent | 18 | 9 | 2 | 1 | 5 | 41 |
| services | 13 | 6 | 3 | 2 | 3 | 30 |
| profile | 12 | 5 | 3 | 1 | 1 | 25 |
| insurance | 10 | 9 | 3 | 2 | 2 | 29 |
| loan | 10 | 11 | 3 | 1 | 4 | 33 |
| home | 7 | 7 | 3 | 2 | 1 | 22 |
| payments | 7 | 1 | 4 | 1 | 1 | 17 |
| schemes | 7 | 5 | 2 | 1 | 1 | 18 |
| auth | 5 | 7 | 3 | 1 | 1 | 19 |
| deposit | 5 | 4 | 3 | 1 | 2 | 19 |
| cards | 4 | 9 | 3 | 1 | 2 | 21 |
| awareness | 2 | 1 | — | — | 1 | 6 |
| notifications | 1 | — | 1 | — | — | 2 |
| search | 1 | — | — | — | — | 2 |
| employee | 1 | — | — | — | — | 1 |
| transfer | — | 1 | 3 | 1 | 1 | 8 |
| ui | — | — | 3 | — | 1 | 4 |
transfer is state-and-service only (its screens live under payments); ui
holds cross-cutting UI state (toasts, app-ready, session-expired).
8. Security & session architecture
MPIN never leaves the device in the clear
mpinCrypto.ts encrypts
{ mpin, nonce, ts } under the server's RSA public key (RSA-OAEP, SHA-256,
MGF1 explicitly SHA-256) and sends it as encryptedMpin. Three integration
hazards are called out in the source, all of which fail silently:
- MGF1 must be passed explicitly. node-forge happens to default it to
md, but Java'sOAEPWithSHA-256AndMGF1Paddingleaves MGF1 on SHA-1 — relying on a default produces ciphertext the server cannot decrypt, with a useless error. tsis in seconds. Milliseconds read as ~55,000 years ahead and are rejected.noncemust be fresh per call or it is refused as a replay.
The key is fetched from /crypto/public-key and cached; PINNED_PUBLIC_KEY is a
fallback for when the fetch fails. invalidate() drops the cache after a
MALFORMED_REQUEST, which is what a stale key looks like client-side (the server
cannot say "your key is wrong" without leaking whether the profile exists).
Device binding
deviceIdentity.ts mints one UUID on
first launch, persisted to the Keychain under
ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY (not WHEN_UNLOCKED_* — the ID
is not a secret and sign-in must read it before the user authenticates). It is
memoised in memory, and concurrent cold-start callers share one in-flight promise
— otherwise each would mint a UUID, last write wins, and the device silently
unbinds. The registered profile is bound to this value; a mismatch returns
DEVICE_NOT_BOUND, which reads like a server fault but is a client bug.
Secure storage
secureStorage.ts keeps three Keychain
services — session, last profile, biometric MPIN — plus a biometric-enabled flag
in AsyncStorage (the device ID is a fourth, owned by deviceIdentity). The
session is cached in memory because the request interceptor needs the token on
every call and an Android Keystore read costs tens of ms.
The MPIN is retained only when the customer opts into biometric sign-in: the API has no refresh token, so a session can only be opened by encrypting the MPIN, which makes biometric sign-in impossible otherwise. It sits behind the device's own biometric prompt, never in AsyncStorage. An unparseable session (written by an older build) is treated as absent rather than crashing bootstrap.
Session lifetime — three independent triggers
sessionManager.ts adopts the server's
own limits at sign-in (idleTimeoutSeconds, expiresAt) so the app never
guesses and never drifts from what the backend enforces. It tracks two clocks
separately:
lastServerContactAt— the only thing the server's idle window is measured from, so the only thing expiry may be judged on.lastInteractionAt— screen touches. Because most of the app reads from fixtures, a user can tap around for five minutes without one authenticated call; treating that as idle would sign them out mid-use. So interaction drives a keep-alive, never the expiry clock itself.
useSessionTimeout.ts polls every 5s and expires
on whichever of three triggers fires first — idle, absolute, or background
(returning to the foreground after lockAfterBackgroundMs, default 30s). Each
has its own copy; background is toned as informational because a deliberate
re-lock is not a failure, and dressing it up as one would train users to ignore
the banner that matters.
ActivityTracker wraps the whole tree and reads
touches via onStartShouldSetResponderCapture, returning false so it sees every
touch on the way down but never becomes the responder — a wrapper that actually
captured touches would break every pressable beneath it.
appConfig.security.requireMpinOnLaunch defaults to true: every launch asks
for the MPIN rather than resuming a stored session, because there is no local
MPIN verifier — the only one is POST /auth/login, so "ask again" necessarily
means "open a fresh session".
9. Presentation layer
Theme. src/theme/ defines a FintechTheme token contract —
colours (including a "Cyber Nexus" accent set), spacing, typography scale, radii,
shadows — consumed via useTheme(). RootNavigator maps these onto React
Navigation's DarkTheme.
Shared primitives. 16 barrel-exported components in
src/components/: Button, Input, Card, Modal,
BottomSheet, DatePicker, Loader, EmptyState, BalanceCard,
TransactionItem, AppHeader, ToastHost, ActivityTracker, plus three
keyboard/layout containers.
ScreenShell is the standard screen wrapper — gradient background, safe
area, AppHeader, optional scroll, per-platform KeyboardAvoidingView behaviour
(padding on iOS, height on Android), withTabBar padding so content never
hides behind the absolutely-positioned tab bar, and a fixedFooter slot.
i18n. useTranslation.ts resolves keys against
translations.ts using the language in the profile
slice, falling back to en then to the key itself. Currently a small dictionary
rather than a full i18n library.
Notifications.
pushNotifications.ts (Notifee)
for delivery; the notifications slice holds the in-app inbox.
10. Build & platform
- Android permissions:
INTERNET, fine/coarse location (branch locator, agent routes),POST_NOTIFICATIONS,READ_PHONE_STATE/READ_PHONE_NUMBERS(SIM-card checks in simCard.ts). - Metro (metro.config.js) block-lists native build output
—
android/app/buildalone exceeds 2 GB after a debug build, and without watchman Metro walks it on startup and never becomes responsive. - TypeScript is
strictwithnoUncheckedIndexedAccessandbundlermodule resolution. - Scripts:
start,android,ios,lint,test,typecheck,clean:android. - Notable native deps: MapLibre (branch locator / agent routes), Reanimated 4
- Worklets, gesture-handler,
@gorhom/bottom-sheet, SVG, linear-gradient, haptic feedback, date-picker.
- Worklets, gesture-handler,
11. Testing
Jest, with 5 unit suites concentrated on the logic where a silent error is costly:
| Suite | Covers |
|---|---|
| featureService.test.ts | flag resolution, hierarchy, layer precedence, fail-closed behaviour |
| cbsErrors.test.ts | all 31 codes present and unique, copy for every code, retryability, CbsApiError identity |
| validation.test.ts | shared input validators |
| emi.test.ts | loan EMI maths |
| depositCalculators.test.ts | FD/RD maturity maths |
There is no component, navigation or integration test layer.
12. Current state & known gaps
Factual observations from the code, not recommendations:
useMockServeristruein src/config/env.ts. Registration, sign-in, accounts/MMID and payees hit the live API; the other modules resolve from ~1,390 lines of fixtures in src/constants/mockData.ts.API_ORIGINis a hard-coded LAN address (http://10.209.40.168:8089) over plain HTTP. The file documents that debug builds permit cleartext and release builds will need HTTPS or a network-security-config. There is no per-environment config mechanism.PINNED_PUBLIC_KEYis committed in source as the fallback when the key fetch fails.schemesApibypasses the shared stack — its owncreateApiwithfetchBaseQuery({ baseUrl: '/' })and fixturequeryFns, including a hard-codedotp !== '123456'check. It does not usebaseApi, the endpoint registry, or the CBS error catalogue.- Remote and tenant flag layers are unwired.
ENVIRONMENT_FEATURE_OVERRIDESis{}andFeatureProvideris mounted without loader props, so all 182 keys resolve from the compiled-in defaults. The layering exists as a seam. - Three data-access patterns coexist (RTK Query, slice+thunk, standalone
createApi), several modules using two of them at once. serializableCheckis disabled store-wide.- The
employeemodule is a single placeholder screen;EmployeeNavigatoris defined but not reachable fromRootNavigator, which routes only'Agent'and'User'.