KMP Feature Slice Generator
Procedural workflow for generating complete KMP feature slices. Enforces strict creation order, typed error handling, and compose-arch compliance.
Prerequisites: Read compose-arch skill first — it is the SINGLE SOURCE OF TRUTH for architecture rules.
Companion skills (versions live there — do not duplicate)
- Architecture rules + Component/View/Screen split —
compose-arch. - DI annotations (
@BindingContainer,@Provides,@AssistedInject) —metro-di-mobile(Metro 1.0.0). - Navigation primitives (
Value<T>,ChildStack) —decompose. - HTTP —
ktor-client(3.4.3).
When generating a slice, always read these companion skills first for current versions and idioms; never inline a version here.
Project conventions (project-level glue, NOT framework)
This skill references three helpers that are project conventions, not Decompose/Metro/Kotlin APIs. Define them once in your :core:* modules (or substitute equivalents) before following the steps. If your project already has equivalents under different names, mentally remap.
| Helper | Lives in | Minimal definition / stand-in |
|---|---|---|
componentScope() |
:core:component (typical) |
Extension on ComponentContext returning a CoroutineScope tied to component lifecycle. Stand-in: Decompose Essenty's coroutineScope() from essenty-coroutines-extensions — same semantics. |
runCatchingApp { ... } |
:core:result (typical) |
Project-level runCatching variant that maps Throwable to AppResult.Failure (typed-error mapping inside). Stand-in: plain kotlin.runCatching { ... } returning kotlin.Result<T>. |
AppResult<T> |
:core:result (typical) |
Project sealed result type — usually sealed interface AppResult<out T> { data class Success<T>(val value: T); data class Failure(val error: AppError) }. Stand-in: kotlin.Result<T>. |
If these don't exist yet in the project, create them once in :core:component / :core:result (or whatever module convention the host project uses) before generating feature slices. Do NOT inline them per-feature.
Below, treat any reference to componentScope() / runCatchingApp / AppResult as a call into these conventions — substitute the stand-ins listed above if the project hasn't adopted them.
Phase 1: Input Collection (LOW FREEDOM)
Collect these inputs before generating any code:
| Input | Required | Example |
|---|---|---|
| Feature name | Yes | OrderHistory |
| Data sources | Yes | remote-only, local+remote, local-only |
| Target platforms | Yes | android,ios,desktop, android,ios,desktop,wasm, all |
| Error types | Yes | network,validation, network,auth,conflict |
| Has list/detail? | Yes | list-only, detail-only, list+detail |
| Navigation | Yes | stack (full screen), slot (dialog/modal), none |
| Needs pagination? | No | true/false (default: false) |
| Parent module path | Yes | feature/order-history |
Error Type Catalog
Select from these typed error categories (see references/error-patterns.md for sealed class templates):
| Error Type | When to Use |
|---|---|
network |
API calls, timeouts, connectivity |
validation |
User input, form data |
auth |
Token expired, unauthorized |
conflict |
Concurrent modification, duplicate |
not-found |
Missing resource |
storage |
Database, file system errors |
permission |
OS-level permissions (camera, location) |
Phase 2: File Manifest (ZERO FREEDOM)
Based on inputs, generate the exact file list. Every feature slice follows this structure:
Mandatory Files (always created)
feature/<name>/
├── api/
│ └── src/commonMain/kotlin/
│ ├── <Name>Component.kt # Step 1: Interface
│ ├── <Name>Models.kt # Step 2: Domain models + error types
│ └── <Name>Repository.kt # Step 3: Repository interface (if data sources != none)
│
└── impl/
└── src/commonMain/kotlin/
├── component/
│ └── Default<Name>Component.kt # Step 7: Component implementation
├── domain/
│ ├── usecase/
│ │ └── Get<Name>UseCase.kt # Step 5: Primary use case
│ └── repository/
│ └── <Name>RepositoryImpl.kt # Step 6: Repository implementation
├── data/
│ └── datasource/
│ └── <Name>RemoteDataSource.kt # Step 4: Remote data source
├── view/
│ ├── <Name>ViewState.kt # Step 8: View state
│ ├── <Name>ViewEvent.kt # Step 9: View events
│ └── <Name>View.kt # Step 10: View (pure UI)
├── screen/
│ └── <Name>Screen.kt # Step 11: Screen (thin adapter)
└── di/
└── <Name>Module.kt # Step 12: DI module
Conditional Files
| Condition | Additional Files | Insert at step |
|---|---|---|
data-sources: local+remote |
<Name>LocalDataSource.kt |
Step 4b — same step as RemoteDataSource |
local storage uses DataStore (e.g. Settings-style features) |
commonMain declares expect factory + interface; platform sourceSets supply actuals — typically androidMain/.../<Name>Settings.android.kt and iosMain/.../<Name>Settings.ios.kt (and desktopMain / wasmJsMain if targeted). Do NOT put DataStore construction in commonMain. |
Step 4b — same step as LocalDataSource |
has: list+detail |
Container Component (<Name>Component.kt) owning ChildStack<Config, Child> + two child Components: <Name>ListComponent.kt and <Name>DetailComponent.kt (each with its own viewState/Event/View/Screen). Impl side mirrors with Default<Name>Component, Default<Name>ListComponent, Default<Name>DetailComponent. |
Container interface at Step 1; child interfaces also at Step 1; container impl at Step 7 (drives ChildStack, exposes child factories); child impls at Step 7 (each owns its own state/use cases). View/State/Event/Screen for each child at Steps 8-11. |
pagination: true |
<Name>Pager.kt in impl/domain/ |
Step 5b — between Repository (Step 5) and Component (Step 7) |
navigation: stack |
Navigation config (Config sealed class + ChildStack) in <Name>Component.kt and Default<Name>Component.kt |
Steps 1 + 7 |
navigation: slot |
Slot config in <Name>Component.kt and Default<Name>Component.kt |
Steps 1 + 7 |
Component owning navigation alongside state. A Component can expose BOTH
val viewState: Value<T>ANDval childStack: Value<ChildStack<Config, Child>>. They are independent fields.List+detail uses the container pattern, not one Component with two states. The container Component holds only the
ChildStackand child factories — noviewStateof its own. Each child Component (List, Detail) owns its ownviewState, events, and use cases. This keeps each child testable in isolation and matches Decompose'sChildren { }rendering. A real example:// Container — navigation only, no viewState interface OrderHistoryComponent { val childStack: Value<ChildStack<*, Child>> sealed class Child { data class List(val component: OrderHistoryListComponent) : Child() data class Detail(val component: OrderHistoryDetailComponent) : Child() } } // Each child is a full Component with its own viewState interface OrderHistoryListComponent { val viewState: Value<OrderHistoryListViewState> fun obtainEvent(event: OrderHistoryListEvent) }Avoid the anti-pattern of a single Component carrying
viewState: Value<ListState>+detailViewState: Value<DetailState>+ manual show/hide flags — that scales poorly and breaks the one-Component-one-screen mental model.
Gradle Files
feature/<name>/
├── api/
│ └── build.gradle.kts # Step 13: API module build
└── impl/
└── build.gradle.kts # Step 14: Impl module build
Update settings.gradle.kts — Step 15.
Phase 3: Generation Order (STRICT SEQUENCE)
Generate files in this exact order. Each step depends on previous steps.
Step 1: Component Interface (api/)
interface <Name>Component {
val viewState: Value<<Name>ViewState>
fun obtainEvent(event: <Name>Event)
fun interface Factory {
fun create(
componentContext: ComponentContext,
onNavigate: (<NavigationType>) -> Unit // from navigation input
): <Name>Component
}
}
Step 2: Domain Models + Typed Errors (api/)
- Domain data classes
- Sealed error hierarchy (see
references/error-patterns.md) - Keep models serializable if used in navigation configs
Step 3: Repository Interface (api/)
- Return
AppResult<T>for all operations - Define typed error mapping
Step 4: Data Sources (impl/data/)
- Remote data source with Ktor client
- (Optional) Local data source with Room/DataStore
- DataStore needs platform-specific construction → declare
expect fun create<Name>DataStore(): DataStore<Preferences>incommonMainand provideactualimpls inandroidMain(<Name>Settings.android.kt),iosMain(<Name>Settings.ios.kt), and any other targeted platform. The interface stays incommonMain; only the factory isexpect/actual.
- DataStore needs platform-specific construction → declare
- Map API DTOs to domain models here
Step 5: Use Cases (impl/domain/usecase/)
- Single
execute()function returningResult<T> - All error handling and mapping here
- Reference
error-patterns.mdfor typed error creation
Step 6: Repository Implementation (impl/domain/repository/)
- Coordinate data sources
- Cache strategy (if local+remote)
Step 7: Component Implementation (impl/component/)
@Inject+@AssistedpatternValue<T>for state (NOT StateFlow)componentScope()for coroutines- Event handling via
obtainEvent() - Navigation via Decompose callbacks
Step 8-9: ViewState + ViewEvent (impl/view/)
- Sealed class for ViewState: Loading, Success, Error (+ Empty if list)
- Sealed class for ViewEvent: all user interactions
Step 10: View (impl/view/)
- Pure UI, only viewState + eventHandler params
- NO remember, NO side effects
- See
references/compose-ui-templates.mdfor templates
Step 11: Screen (impl/screen/)
- Thin adapter: subscribe to component state, pass to View
- Target ~20 lines for a single-view screen. With
list+detailand an internalChildren { when }block, ~25–30 lines is acceptable — the rule is "no logic", not a hard line count.
Step 12: DI Module (impl/di/)
@BindingContainerwith@Providesbindings — one module per feature.- For each interface declared in
api/whose impl lives inimpl/, prefer@DefaultBinding(<ApiInterface>::class)on the impl class over a hand-written@Provides— Metro auto-binds and you avoid one boilerplate function per interface. - Use
@Providesin the module only when:- the binding requires plumbing several deps together (e.g. wrapping a
HttpClientinto a feature-scoped service), OR - the binding wires an api-side
Factoryto its impl-side counterpart (e.g. mappingOrderHistoryComponent.Factory→DefaultOrderHistoryComponent.Factory).
- the binding requires plumbing several deps together (e.g. wrapping a
objectvsclass@BindingContainer: both work identically in Metro 1.0. Default toobject(no instance state, matches common KMP convention) unless the host project usesclass. Canonical example:
@BindingContainer
object OrderHistoryModule {
// Use cases + repository auto-bind via @Inject / @DefaultBinding — nothing needed here.
// @Provides only when wiring api Factory ← impl Factory or composing several deps.
@Provides
fun provideComponentFactory(
impl: DefaultOrderHistoryComponent.Factory,
): OrderHistoryComponent.Factory = impl
}
Step 13-14: Gradle Build Files
- API module: kotlin multiplatform only, no compose. Apply the Metro plugin if you want use cases to carry
@Inject. - Impl module: kotlin multiplatform + compose + decompose + Metro plugin.
Cross-target api note
Metro 1.0 supports the full KMP target matrix — including js(IR), wasmJs, wasmWasi, and all native targets (see metro-di-mobile skill's "Supported KMP targets" table). So an api/ module that targets android+ios+desktop+js+wasmJs can still apply the Metro plugin and use @Inject on its use cases. Web frontends consuming the api module will compile fine.
The only reason to keep an api/ module Metro-free is when the consumer doesn't run Metro at all — for example, a non-KMP Kotlin/JS app that pulls in only the api artifact and wires use cases by hand. In that case:
- Use cases in
api/stay as plain Kotlin classes (no annotations). - The
impl/module provides them via@Providesin<Name>Module.ktfor the mobile path. - The web target instantiates use cases manually (
GetOrderHistoryUseCase(JsOrderHistoryRepository())) at the call site.
Default to "Metro everywhere" unless you have a concrete reason to opt out.
Consumer-side graph: two-graph wiring
Once the api module carries @Inject annotations, each consumer (mobile vs web) defines its own @DependencyGraph and reuses the same api use cases. The two graphs differ only in which impl modules they pull in.
Mobile graph — pulls the impl module (with Decompose, real DataSources, full Compose UI):
// composeApp/.../di/AndroidAppGraph.kt
@DependencyGraph(
bindingContainers = [
NetworkModule::class,
AndroidPlatformModule::class,
OrderHistoryModule::class, // impl-side @BindingContainer
]
)
interface AndroidAppGraph {
val orderHistoryComponentFactory: OrderHistoryComponent.Factory
}
Web graph — only the api module + a web-specific repo impl. No Decompose, no impl module:
// webApp/.../di/WebAppGraph.kt
@DependencyGraph(
bindingContainers = [WebPlatformModule::class],
)
interface WebAppGraph {
val getOrderHistoryPageUseCase: GetOrderHistoryPageUseCase
val getOrderDetailUseCase: GetOrderDetailUseCase
val orderHistoryRepository: OrderHistoryRepository
}
// webApp/.../orderhistory/JsOrderHistoryRepository.kt
@Inject
@DefaultBinding(OrderHistoryRepository::class)
class JsOrderHistoryRepository(
private val httpClient: HttpClient,
) : OrderHistoryRepository {
override suspend fun getPage(cursor: String?): AppResult<OrderPage> = /* ... */
override suspend fun getDetail(id: String): AppResult<OrderDetail> = /* ... */
}
The use cases (GetOrderHistoryPageUseCase, GetOrderDetailUseCase) live in api with @Inject and resolve from constructor scanning — no @Provides needed in the web module. The @DefaultBinding on JsOrderHistoryRepository plugs the api interface. The web graph deliberately excludes OrderHistoryModule (impl side) since it pulls Decompose lifecycle types React doesn't need.
For createGraph lifetime on JS (top-level lazy vs React Context provider), see metro-di-mobile's "JS / WASM specifics" section.
Step 15: Settings Update
- Add both modules to
settings.gradle.kts - Add impl dependency to
composeApp/build.gradle.kts
Phase 4: Validation Checklist
After generation, verify ALL of these:
- compose-arch compliance: Screen has no logic, View has no remember
- Typed errors: All error types from input are covered in sealed hierarchy
- One class per file: No file contains multiple classes
- Value state: Component uses
Value<T>, notStateFlow - Result returns: All use cases return
Result<T> - AppResult in repository: Repository interface returns
AppResult<T> - No Compose imports in Component: Component has zero compose dependencies
- Platform targets match input: Build file targets match requested platforms
- DI bindings complete: All interfaces bound in Module
- Navigation wired: Component factory registered in parent navigation
- Build passes:
./gradlew :<module>:assemblesucceeds
Phase 5: Build Verification
# Verify API module compiles
./gradlew :feature:<name>:api:assemble
# Verify impl module compiles
./gradlew :feature:<name>:impl:assemble
# Verify full app compiles with new feature
./gradlew :composeApp:assemble
Reference Files
Load these on demand — do NOT read all upfront:
| Reference | When to Load |
|---|---|
references/error-patterns.md |
Step 2 (domain models + errors) |
references/compose-ui-templates.md |
Steps 8-11 (UI layer) |
references/kotlin-web-templates.md |
When target includes wasm or js |
Integration with Other Skills
| Skill | Relationship |
|---|---|
compose-arch |
SOURCE OF TRUTH for architecture rules. This skill adds generation order on top. |
decompose |
Navigation patterns. Load for Step 7 (component) if complex navigation. |
metro-di-mobile |
DI patterns. Load for Step 12 (module). |
kmp |
Project structure. Load for Steps 13-15 (gradle). |
kotlin-web |
Web-specific patterns. Load when targets include wasm/js. |