Mobile App Architecture skill for Codex
Structure sized to the app, enforced by the build. Codex sizes the architecture to the team and app before adding layers, then applies one-way screen state, repositories over a local source of truth, typed navigation routes and constructor injection using each platform's default stack. For larger codebases it plans an api/impl feature-module split one feature at a time, measures build impact, enforces dependency rules in CI and records the decision as an ADR.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- mobile-app-architecture Installs to ~/.agents/skills/mobile-app-architecture/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install mobile-app-architecture The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project mobile-app-architecture Installs to .agents/skills/mobile-app-architecture/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/mobile-app-architecture.zipunzip -o mobile-app-architecture.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) mobile-app-architecture PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $mobile-app-architecture, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs and reviews mobile app architecture across iOS, Android, Flutter and React Native - UI, presentation, domain and data layers, unidirectional state, repositories and single sources of truth, navigation as typed routes, dependency injection and composition roots, feature modules with enforced dependency rules, and testing seams. Produces architecture decision records and module graphs. Use when starting a new app, when a codebase has god view models or tangled modules, when splitting into feature modules, or when choosing between MVVM, MVI or TCA-style patterns.
Use it when
- Starting a new app and choosing layers, state and navigation patterns
- View models or controllers have grown into untestable god objects
- Build times or merge conflicts push toward feature modules
- Writing an architecture decision record for a mobile codebase
Use something else when
- Android-specific ViewModel, Hilt and Room implementation details: use android-architecture
- Choosing between Riverpod and Bloc in Flutter: use flutter-state-management
How to check the result
View models and repositories are unit-testable on the host with fakes, the CI dependency rule check passes, and measured incremental build times improve after the module split.
The mistake it prevents
Adding use cases, mappers and DI frameworks to a small app before any pain demands them, tripling the code per feature with no isolation gained.
Done well means: each screen’s state comes from one place and flows one way, business rules are testable without a device, features can be built and tested in isolation, dependencies point inward, and the structure is no heavier than the app’s size and team count justify.
Before changing anything
- Read
AGENTS.mdand any existing ADRs or architecture docs. - Map what exists: modules or packages, the largest files (
find . -name "*.swift" -o -name "*.kt" | xargs wc -l | sort -n | tail -20), how screens get data, how navigation happens, and where DI is configured. - Ask about scale: number of engineers and teams, number of features, build times, platforms, and whether code is shared (KMP, Flutter, React Native).
- Identify the pain driving the change (slow builds, merge conflicts, untestable logic, crashes from state bugs). Architecture work without a named pain is a rewrite risk.
Size the architecture to the app
| App / team | Structure |
|---|---|
| 1-3 engineers, under ~20 screens | Single module/target, folders by feature, MVVM, manual DI |
| 4-15 engineers, growing feature set | Feature modules + core modules, DI container, typed navigation |
| Multiple teams, 100k+ lines, slow builds | api/impl module split, ownership per module, build-time dependency rules |
Do not introduce a domain layer, use-case classes or a DI framework before something needs them. Add a use case when logic is shared by several view models or is complex enough to deserve its own tests.
Layers
UI (views, composables, widgets) renders state, sends user intents
Presentation (ViewModel / store) holds screen state, calls domain/data, no platform UI types
Domain (optional: use cases, models) pure business rules, no framework imports
Data (repositories, data sources) single source of truth: local DB, network, caches
Rules:
- Dependencies point downward only. Data never imports presentation; domain imports nothing platform-specific.
- Repositories expose streams (
Flow,AsyncSequence,Stream) from the local source of truth; network results write into it rather than going straight to the UI. - One immutable state value per screen; the UI renders it and sends events back.
- Platform services (location, camera, notifications) sit behind interfaces in data or a core module, so presentation tests use fakes.
Platform defaults
| Concern | Android | iOS | Flutter | React Native |
|---|---|---|---|---|
| State holder | ViewModel + StateFlow | @Observable model (or TCA reducer) | Riverpod Notifier or Bloc | Zustand or Redux Toolkit store, TanStack Query for server state |
| DI | Hilt (Koin in KMP) | Initializer injection from a composition root; Environment for views | Riverpod providers | Module-level factories or context |
| Local truth | Room / DataStore | SwiftData / Core Data / GRDB | Drift / Isar | SQLite / MMKV / WatermelonDB |
| Navigation | Navigation Compose with type-safe routes | NavigationStack with a path of route values | go_router | React Navigation / Expo Router |
Use the platform default unless the team has a strong reason; non-default stacks raise onboarding cost for every hire.
State and events
data class CartState(val items: List<Line> = emptyList(), val loading: Boolean = false, val error: String? = null)
sealed interface CartEvent { data class Remove(val id: String) : CartEvent; data object Checkout : CartEvent }
class CartViewModel(private val repo: CartRepository) : ViewModel() {
val state: StateFlow<CartState> = repo.observeCart()
.map { CartState(items = it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), CartState(loading = true))
fun onEvent(e: CartEvent) = viewModelScope.launch {
when (e) { is CartEvent.Remove -> repo.remove(e.id); CartEvent.Checkout -> repo.checkout() }
}
}
The same shape works in Swift (@Observable final class CartModel with a state property
and func send(_ event:)) and Dart. One-off effects (navigate, show toast) are modelled as
state that the UI consumes and clears, or as a dedicated effect stream; never as
LiveData that replays on rotation.
Navigation
- Routes are typed values (
Route.product(id:),@Serializable data class Product(val id: String)), not string concatenation spread across screens. - Features expose an entry route; they do not import each other’s screens. The app module (or a navigation module) wires routes to destinations.
- Deep links, notifications and widgets all parse into the same route type; see mobile-deep-links.
- Navigation decisions live in the presentation layer as events; the UI layer performs them.
Feature modules
:app composition root, navigation graph, DI wiring
:feature:cart:api route + public interfaces (tiny)
:feature:cart:impl screens, view models, feature-private data
:core:data repositories shared by features
:core:network, :core:database, :core:designsystem, :core:testing
feature:*:implmay depend oncore:*and other features’:apionly. Never impl-to-impl.- iOS equivalent: Swift packages or local SPM targets per feature with the same rule.
- Enforce rules in the build: Gradle module graph checks (a custom task or a plugin such as a dependency-analysis tool) and SPM target dependencies, plus a CI check, so rules do not decay into wiki pages.
- Measure before and after: clean and incremental build times, and the number of modules rebuilt for a one-line change in a feature.
Migration plan for an existing tangle
- Write the target diagram and an ADR; agree on it with the team
- Extract
core:designsystemandcore:networkfirst (least coupled) - Move one feature into api/impl, fix the leaks it reveals, measure build impact
- Add the dependency rule check in CI once the first feature passes it
- Repeat feature by feature; never freeze feature work for a big-bang rewrite
Testing seams
| Layer | Test type | Runs on |
|---|---|---|
| Domain and repositories | Unit tests with fakes | JVM / host machine, fast |
| View models / stores | Unit tests feeding events, asserting state | JVM / host |
| UI | Screenshot tests and a few UI tests per feature | Emulator/simulator or Paparazzi/Roborazzi-style JVM rendering |
| End to end | A handful of critical journeys | Device farm or CI emulators |
A design where view models need a device or network to test is the clearest smell.
Gotchas
- “Clean architecture” with mappers between identical models in every layer adds code without isolation; map only where shapes truly differ (network DTO to domain model).
- Singletons reached through static accessors defeat DI; inject through constructors.
- Base classes for all view models accumulate unrelated behaviour; prefer composition.
- Shared mutable caches between features become hidden coupling; go through repositories.
Output
Write decisions as an ADR in the repo’s ADR folder (or docs/adr/):
# ADR-012: Feature modules with api/impl split
Status: proposed | accepted
Context: <pain, measured: build time, merge conflicts, test gaps>
Decision: <structure, layers, DI, navigation, dependency rules>
Consequences: <costs, migration order, how rules are enforced in CI>
Alternatives considered: <and why rejected>
Include a module graph (Mermaid graph TD) of the target state.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Mobile App Architecture"
short_description: "Structure sized to the app, enforced by the build"
default_prompt: "$mobile-app-architecture Propose an architecture for our new iOS and Android app with a team of six" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.