| name | structure-feature-code |
| description | Structures feature UI and domain code: thin app navigation, state-only ViewModels, Screen Context for busy screens, shallow UI trees, multi-state previews, validation in the data layer exposed via API, and user-safe errors. Use when adding or refactoring a feature screen, placing validation, wiring app errors, or when the user asks to structure feature code for reuse. |
Structure feature code
Reusable conventions for feature modules (mobile or similar layered apps). Adapt names (NavHost, Koin, Compose) to the project stack; keep the roles the same.
1. Module & navigation
- Split by role: model (types) · api (contracts) · data (impl) · presentation (UI) · di (bindings) — or the project’s equivalent.
- Each feature owns its in-feature navigation (e.g. list ↔ create).
- A screen that hosts inner screens (list ↔ detail ↔ progress) is a thin NavHost only:
AnimatedContent / route enum + child screen calls — same shape as ProjectsNavHost / SettingsScreen / BuildHistoryScreen. Do not put observe/menu/install bodies in that host file; each child screen owns its wiring + logic/.
- The app / integration navigator only wires cross-feature exits. No feature toasts, dialogs, or internal routes in the root host.
- Cross-feature event hooks use a producer-owned registry in
:api (addListener / removeListener). Consumers inject that registry and register in their own construction (e.g. DefaultBuildService + ProjectEventHooks). Do not bind consumer listeners into the producer via Koin getAll() multi-bind.
:model / :api do not shape themselves around UI layout. Progress/result types carry domain facts the caller needs for the next action (e.g. challenge code + URI to open). Do not add fields only so a later screen can redraw chrome that the presentation layer can retain from an earlier emission. UI state may keep display data; the public model must not.
- Comments describe the present. KDoc/comments say what the type or function is for now. Do not narrate removed fields, old mistakes, “why we didn’t put X here,” or contrast against a discarded approach.
2. Errors
- Planned, user-facing failures use a shared app error type with an explicit UI message field (e.g.
AppException(uiMessage)).
- UI shows only that UI message (or a fixed generic string after a user action).
- Unexpected errors: log for debugging; never show raw
Throwable.message / exception text to the user.
- Mutating operations: succeed or throw. On failure, roll back partial side effects (e.g. restore DB row if file delete fails). No silent no-ops for “must work” actions.
- Cancellation is not failure. When a coroutine is cancelled (
CancellationException), do not map it to a user-facing Failed/error state. Re-throw or return; let the cancel path set Cancelled (or equivalent). Catch CancellationException before broad catch (Exception).
- On user-facing / product paths, throw the shared app error type — not
require / check / IllegalArgumentException whose messages are not UI-safe.
3. Validation
- Implement validation once in the data/domain layer.
- Expose it on the feature API so the UI can show field errors.
- Do not duplicate business rules (regex, ranges, copy) in the UI.
- Write/persist paths still run the same validation before mutating storage.
4. Screen package layout
Prefer Screen Context for busy screens
When a screen has many components (list, menus, dialogs, bars), use Screen Context — see docs/agents/screen-context.md.
presentation/<screen>/
<Name>ScreenContext.kt # service, updateState, exits, scope
<Name>ViewModel.kt # state-only + UiState types
<Name>Screen.kt # Ctx.Screen(state)
ui/ # Ctx UI extensions (state param)
logic/ # Ctx logic extensions
presentation/preview/
<Name>Previews.kt # @Preview + fixtures — call real Screen/Content
Summary:
- Context holds resources; UI state is passed as
state.
- Screen-specific UI/logic = context extensions; designsystem stays parameterized.
- Host builds context with
remember(…keys) + ViewModel; screen does not depend on a concrete VM.
- Never nest function declarations (project-wide — see
AGENTS.md → Coding rules).
Thin screens (screen-context shape, no Context class)
Simple screens still follow the same layout roles as Screen Context — just without a *ScreenContext type:
presentation/<screen>/
<Name>ViewModel.kt # state-only + UiState types
<Name>Screen.kt # host wiring + state-driven composition
ui/ # state-driven bodies (take state + only that piece’s actions)
logic/ # top-level functions (service, updateState, exits)
presentation/preview/
<Name>Previews.kt # @Preview + cases / provider — call real Screen/Content
Rules (same as Screen Context, adapted):
- ViewModel holds UI state only — no service calls, validation, or navigation.
- Screen wires resources (service, exits, clipboard/uri) and calls
logic/; compose ui/ from state.
- Do not use a mega
*Content(state, onA, onB, onC, …) that funnels every callback through one parameter list.
ui/ pieces take state (or the relevant subtype) plus only the actions that piece needs.
logic/ is top-level functions (not nested fun inside composables). Screen composables only call those functions — no observe/map/menu/delete/install/navigate bodies inlined in the @Composable. Reference: BuildProgressScreen + progress/logic/, BuildHistoryScreen + history/logic/.
- Design-system stays parameterized.
- Prefer this shape over a single Screen+Content file even when the screen is small (Connect, Settings hub, Build account).
Extract meaningful units
Split long methods into named steps that own a real phase of work (prepare sandbox, upload, poll run). Do not extract tiny wrappers that only rename a single update / catch / one-liner — keep those inline at the call site.
Kotlin control-flow traps
In repeat / forEach / similar inline loops, return@label exits only that iteration (like continue), not the whole loop. To stop after success, use for + break, or return@outer from a wrapping run { … }. Mistaking this for break can fire side effects (e.g. workflow_dispatch) once per attempt.
When the screen grows many components (list + menus + dialogs), add a *ScreenContext and turn ui/ / logic/ into context extensions — see docs/agents/screen-context.md.
When a feature can swap backends (auth, cloud build):
- Identifiers and APIs in
:presentation / feature :api / :model use generic names (openVerificationUri, providerDisplayName, ConnectAccount) — not a vendor (openGitHub…, onConnectGitHubClick).
- User-visible chrome interpolates API-supplied fields (
"Open ${state.providerName}", paste host from verificationUri). Do not bake a vendor into production Compose strings.
- Vendor lives in
:data and dedicated vendor modules (e.g. :feature:github). They emit the concrete display name / URIs.
- Previews may use the current provider as fixture copy so phones look real.
When the screen grows many components (list + menus + dialogs), add a *ScreenContext and turn ui/ / logic/ into context extensions — see docs/agents/screen-context.md.
ViewModel / state holder
- Holds UI state across configuration changes only.
- No service calls, validation, or navigation side effects unless the human explicitly asks otherwise.
- May take route/args in the constructor (host passes them via DI) so the screen does not need a
LaunchedEffect to seed state.
- Screen
*UiState is a flat data class with flags/fields (isLoading, loadError, content fields) — same shape as EditorUiState / BuildHistoryDetailUiState. Do not model screen lifecycle as a sealed class/interface (Loading / Ready / Failed). Content switches with when { state.isLoading → …; state.loadError != null → …; else → … }.
- Async load is visible: start
isLoading = true; on miss set a user-safe loadError (not-found copy is fine); on failure set loadError from userMessageOrNull / generic string; never leave a blank body while collectAsState(initial = null) races.
Data ports & adapters
When feature logic depends on Room / network / filesystem behind a seam:
- Define ports next to the logic that owns them (interfaces + domain types).
- Name concrete implementations
…Adapter explicitly (RoomBuildJobRepositoryAdapter, GitHubBuildEngineAdapter).
- Feature services wire adapters; do not make unrelated stores depend on services they only need for side effects — use event hooks instead.
- Engine-agnostic job lifecycle: credentials and account observation stay inside the engine adapter (e.g.
GitHubBuildEngineAdapter + AuthSession). Do not put AuthSession / access tokens on DefaultBuildService. Eager resume runs without an account; engines optionally emit observeResumeHints() for cloud sign-in re-attach. Bind BuildJobRepository + BuildEngine in DI; do not nest a separate BuildJobLogic type beside the service.
5. Incremental delivery (when on a PR)
Only when the user asks to commit / ship a slice (see /incremental-pr-delivery):
- One focused change.
- Commit with a clear message.
- Push.
- Comment on the PR for that slice.
- Repeat only after another explicit ask (unless they asked for a multi-slice loop).
6. Finish checks
Before calling UI/code work done:
- Compile the touched modules (and install when the change is user-visible).
- Clear deprecation / error diagnostics in files you touched — prefer current public APIs over
@Deprecated replacements the IDE already flags.
- Match existing Kotlin style in the file/module: import types and use short names; avoid inline fully-qualified names (
android.net.Uri.parse(…)) except when disambiguating a clash.
- Walk the changed flow on device when UX changed.
7. Checklist
Portability
Copy this skill into another repo’s .agents/skills/structure-feature-code/ or ~/.cursor/skills/structure-feature-code/. Pair with project-local Cursor rules for stack-specific names (package paths, DI, design system). Ship docs/agents/screen-context.md with it when using Screen Context.