Skip to main content

Mobile architecture

Owner: Remy (Principal Mobile Engineer) · Status: locked (Kit + Dex acked) · Audience: Kit (iOS), Dex (Android), Reed (auth/API), Blake (ops)

This is the locked twin-app code architecture for Clubhouse League MVP (Kit + Dex acked). Treat it as the default shape for disbursed work and scaffolding PRs. Product locks stay in Product (Auth soft-gate, MVP scope, feature pages) — this page does not invent them.

Native twins only: iOS Swift/SwiftUI (Kit) and Android Kotlin/Compose (Dex). See Twin apps & disbursement.

Principles

  1. Contracts and shapes, not UI code — twins share API contracts, domain naming, and journey boundaries. No KMP, React Native, or Flutter. No shared UI kit in MVP.
  2. Product journeys drive modules — Auth, Leagues, Draft, Roster, Matchups, Scoring, Waivers map 1:1 to feature modules. Do not invent parallel nav taxonomies.
  3. Soft-gate — browse and onboarding work signed-out; auth before gated writes (create / request-join / redeem, then draft/roster/waiver mutations per MVP). Product detail: Auth soft-gate and Features → Auth.
  4. Server is identity authority — WorkOS JWT verified by Reed’s API (JWKS). Clients never treat email or a client-supplied user id as the identity key.

Keep three layers. Presentation stays thin; domain stays pure; data owns I/O.

LayerResponsibilityOwnsDoes not own
UIScreens, navigation, presentation stateSwiftUI / Compose, nav graphs, view models or @Observable / ViewModel UI stateNetworking, token persistence, JWT parse as source of truth
DomainUse cases + pure modelsNaming parity across Kit/Dex (same nouns: League, Roster, Matchup, …), soft-gate pending-action typesPlatform UI, HTTP, Keychain/prefs
DataRepositories, API client, local session/cacheURLSession / OkHttp (or thin wrapper), session store, repository fakes for testsScreen layout, product copy

Call direction: UI → Domain → Data. Domain does not import UI. Data does not import UI.

Thin and standard. Avoid heavy frameworks unless Kit or Dex pushes back hard with a concrete win.

iOS (Kit)

ConcernDefault
UISwiftUI
NavigationNavigationStack
Concurrencyasync / await
NetworkingURLSession (or a thin typed wrapper)
TokensKeychain
UI stateObservation (@Observable) or equivalent — keep it first-party

Android (Dex)

ConcernDefault
UIJetpack Compose
NavigationNavigation Compose
ConcurrencyCoroutines + Flow
NetworkingOkHttp + Retrofit or Ktor client (pick one; document in the Android repo README)
TokensEncryptedSharedPreferences or DataStore (encrypted)
UI stateViewModel + UI state holders

Auth client (shared design)

Implement the same client behavior on both twins. Values and schemes are already locked in Delivery / Ops — do not re-decide them here.

  • Staging vs prod — staging Client ID client_01M2VWKF87Q557H9D37BFKC52H + clubhouseleague-staging:// schemes; prod Client ID exists but prod WorkOS mutations stay locked until billing. Full table: Auth delivery.
  • Flow — system browser sheet + PKCE; callback and logout/returnTo schemes per build flavor. Package com.pocketlabs.clubhouseleague.
  • Session store — persist tokens securely (Keychain / encrypted prefs). Treat Reed’s API as authority: on 401, clear local session and keep browse/onboarding available (soft-gate).
  • Pending action — preserve create / request-join / redeem (and later gated mutations) across the auth sheet; resume only after required profile (workos_user_id, email, display_name).
  • Links — Product: Features → Auth; Delivery: Auth delivery; Ops: Auth deep-link hosts (OD-9 remaining: Universal Links / assetlinks).

Do not push sign-in background assets or Mira’s design dumps into the app repos from this workstream.

Code architecture

This section is what Kit and Dex implement against for empty-starter scaffolding and ongoing folder/module layout. Locking this page locks the code layout for later scaffolding PRs in clubhouse-league-ios and clubhouse-league-android. This PR still does not wire feature UI or push sign-in assets into those repos.

Both trees enforce UI → Domain → Data (see Recommended layers). Product journeys map 1:1 to feature folders/modules: Auth, Leagues, Draft, Roster, Matchups, Scoring, Waivers.

Dependency rules (both platforms)

  • Feature UI may depend on that feature’s Domain and on AppCore / :core:*.
  • Feature Data implements Domain ports (repositories / interfaces); Domain does not import Data or UI.
  • Features do not import sibling feature UI. Cross-feature coordination goes through AppCore / :core:*, navigation hosts, or shared domain models — not by reaching into another feature’s screens.
  • Auth is a feature module invoked as a gate, not a tab root — opened from onboarding, browse, create, join, redeem (see Feature Auth architecture).
  • Shared models that cross features live in AppCore (iOS) or :core:model (Android) — do not duplicate League / Roster / Matchup types inside each feature.

iOS (clubhouse-league-ios)

Propose the source tree under ClubhouseLeague/ (or Sources/ if Kit prefers a Sources-root layout). Single target is OK for MVP — no SPM package split required yet — but folders must enforce UI → Domain → Data. In Xcode, make each group a real folder on disk (group = folder).

ClubhouseLeague/
App/ # @main, App root, env bootstrap
AppCore/
Networking/
Session/
Errors/
Config/
Features/
Auth/
UI/
Domain/
Data/
Leagues/
UI/
Domain/
Data/
Draft/
UI/
Domain/
Data/
Roster/
UI/
Domain/
Data/
Matchups/
UI/
Domain/
Data/
Scoring/
UI/
Domain/
Data/
Waivers/
UI/
Domain/
Data/
Resources/
  • App/@main, root scene, environment / DI bootstrap, composition root that wires Data implementations into Domain ports.
  • AppCore/ — shared infrastructure only: HTTP client, base URL / env injection, session façade, error types, config. Not a junk drawer for unfinished features. Cross-feature models that cannot live in one feature Domain belong here (or a dedicated AppCore/Model/ if Kit wants that subfolder).
  • Each Features/<Name>/ — owns UI/, Domain/, and Data/. Prefer feature-local repositories over a god ApiService. Auth stays a gate feature, not a primary tab.

Android (clubhouse-league-android)

Prefer Gradle modules for MVP clarity (stronger boundaries than folders alone). Package root: com.pocketlabs.clubhouseleague.*, mirroring modules.

:app
:core:network
:core:session
:core:model
:feature:auth
:feature:leagues
:feature:draft
:feature:roster
:feature:matchups
:feature:scoring
:feature:waivers

Each :feature:* module uses packages:

com.pocketlabs.clubhouseleague.<feature>/
ui/
domain/
data/
ModuleRole
:appApplication class, nav host, flavor/env bootstrap, composition root
:core:networkOkHttp/Retrofit or Ktor client, interceptors, base URL injection
:core:sessionSecure token store, session façade, soft-gate clear-on-401
:core:modelShared cross-feature models (League, Roster, Matchup, …) — not duplicated per feature
:feature:*Journey UI + Domain ports + Data implementations; Auth is gate-only

Feature modules depend on :core:* as needed. Feature UI depends on that feature’s Domain; Data implements Domain. No feature module depends on another feature’s ui package.

Day-1 lock notes (Kit / Dex)

Captured from Mobile Team review. Scaffolding PRs must honor these.

iOS (Kit)

  1. Composition root stays in App/AppCore must not import Features/*.
  2. Pending-action types live in AppCore/Session and must survive ASWebAuthenticationSession / process death (durable storage, not in-memory alone).
  3. AuthKit Client ID + URL schemes live in App/ + Resources/ (xcconfig / Info.plist); register both clubhouseleague and clubhouseleague-staging. Prefer Auth delivery as source of truth for values — do not restate Client IDs here.
  4. Stub every Features/<Name>/{UI,Domain,Data} in the first scaffolding PR.
  5. Keep a single app target for MVP; defer SPM splits (thin AppEnvironment / initializer DI).

Not blocking on Universal Links or a shared UI kit.

Android (Dex)

  1. No :core:network → concrete :core:session dependency cycle — bind a TokenProvider in :app.
  2. Pending-action types live in :core:model / session and must survive Custom Tabs process death.
  3. AuthKit schemes + product flavors live in :app (Client ID / schemes SoT remains Auth delivery).
  4. Stub all modules in the first scaffolding PR.
  5. Prefer build-logic convention plugins so ~10 modules do not fork AGP versions.
  6. HTTP stack locked: Retrofit + OkHttp in :core:network.

Not blocking on Hilt or App Links.

Cross-cutting

TopicRecommendation
API base URL / envInject per build flavor. Concrete staging/prod URLs are OD-6 (Blake) — see Deploy & environments. Clients must not hardcode secrets.
Error modelShared shape in spirit: transport vs 401 vs domain/validation. Map 401 → clear session + soft-gate; do not silently retry gated writes.
LoggingStructured logs without PII, tokens, or AuthKit codes. See Secrets hygiene.
TestingUnit-test domain use cases and repository fakes first. UI smoke / snapshot later — not a merge blocker for this architecture lock.

Open decisions (architecture ODs)

Left open for Kit / Dex / Reed challenge. Not product locks.

IDTopicNotesOwner
AOD-1Exact DI approachiOS: initializer injection vs lightweight container; Android: manual / Hilt / kotlin-inject — pick per platform, keep constructors testableKit / Dex
AOD-2Analytics vendorStub interface in AppCore only until product picks a vendorRemy → Morgan
AOD-3Offline / cache scopeMVP assumes online for gated writes; browse cache depth TBDKit / Dex + Remy
AOD-4Android HTTP stackLocked: Retrofit+OkHttp in :core:networkDex + Remy
AOD-5API error envelopeAlign client error types with Reed’s response shape once publishedReed + Remy
AOD-6Design tokens packagingShared tokens later; no shared UI kit in this proposalRemy / Mira (runbook only)

Product/ops ODs that architecture consumes but does not own: OD-6 (env/API URLs), OD-9 (Universal Links / assetlinks remaining). See Open decisions.

Explicit non-goals

  • No shared UI kit, design-system package, or cross-compile UI in MVP.
  • No feature implementation, tickets closed, or app-repo code in this PR — runbook lock only (scaffolding PRs come later against this layout).
  • No feature UI wiring and no sign-in background assets or brand dumps into clubhouse-league-ios / clubhouse-league-android from this workstream.
  • No passwords, embedded credential WebViews, or inventing AuthKit Client IDs / schemes beyond Auth delivery.
  • No Flutter / RN / KMP evaluation reopening — twin-native is locked in Twin apps & disbursement.

How Kit / Dex should use this

  1. Challenge AODs and stack defaults in the PR thread or Mobile Team.
  2. When Remy disburses work (including empty-starter scaffolding PRs), implement against Code architecture — the iOS folder tree and Android Gradle modules above — plus the layers and stack defaults, unless an AOD is explicitly flipped.
  3. Keep product acceptance on Feature / Journey pages; keep eng how-to under Feature → Implementation notes or app-repo READMEs.
  4. Locking this page locks code layout for scaffolding; it does not authorize feature UI wiring or sign-in assets in the app repos from this PR.