-
Notifications
You must be signed in to change notification settings - Fork 0
Project Requirements
The architectural rules every feature in KMPilot must follow. This is the enforceable layer — the code-reviewer agent (and the reinject-on-compact.sh hook) check these.
New to KMPilot? Start with Getting-Started.
KMPilot/
├── composeApp/
│ └── src/commonMain/kotlin/{pkg}/kmpilot/
│ ├── initKoin.kt # contains startKoin + feature initializers
│ └── BaseAppNavHost.kt # contains the NavHost + calls to NavGraphBuilder extensions
├── core/
│ ├── common/ # Either, UiState, BaseFeature, setState, ErrorModel
│ ├── data/ # ApiClient, ErrorConst, network layer
│ └── designsystem/ # X-components (XTheme, XButton, …)
└── feature/
└── {featurename}/ # lowercase, no hyphens/underscores
├── build.gradle.kts
└── src/commonMain/kotlin/{pkg}/{featurename}/
├── data/
│ ├── model/
│ ├── remote/
│ ├── datasource/
│ └── repository/
├── presentation/
│ ├── {Feature}ViewModel.kt # flat — no viewmodel/ subdir
│ ├── {Feature}UiModel.kt
│ ├── ui/
│ │ ├── {Feature}Screen.kt # 5-slot allowlist only (see UI File Organization)
│ │ ├── {Feature}Utils.kt # optional — non-@Composable helpers
│ │ └── components/ # one file per @Composable (incl. {Feature}Content.kt)
│ └── navigation/
└── di/
└── {Feature}Modules.kt
// ✅
interface ProductRemoteDataSource {
suspend fun getProducts(): Either<List<Product>>
}
class ProductRemoteDataSourceImpl(...) : ProductRemoteDataSource { ... }
// ❌
class ProductRemoteDataSource(...) { ... }// ✅
suspend fun login(req: LoginRequest): Either<LoginResponse>
// ❌
suspend fun login(req: LoginRequest): LoginResponse
suspend fun login(req: LoginRequest): Result<LoginResponse>// ✅
_uiModel.setState { copy(isLoading = true) }
// ❌
_uiModel.value = _uiModel.value.copy(isLoading = true)The four states come from the generic UiState<T> in :core:common — there is no per-feature *UiState.kt (see Rule 11). Each async operation gets one UiState<DTO> slot on the *UiModel, and the ViewModel must handle all four cases:
// In core/common — provided, not regenerated per feature:
sealed interface UiState<out T> {
data object Uninitialized : UiState<Nothing>
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Failed(val error: ErrorModel) : UiState<Nothing>
}
// In the feature — DTO-typed slot on the single UiModel:
val loginState: UiState<LoginResponse> = UiState.Uninitialized// ✅
XScaffold(title = { XText("Login") }) {
XTextField(value = "", onValueChange = {})
XButton(onClick = {}, text = "Login")
}
// ❌
Scaffold(topBar = { TopAppBar(title = { Text("Login") }) }) {
TextField(...); Button(...) { Text(...) }
}MaterialTheme.colorScheme and MaterialTheme.typography are allowed (XTheme wraps MaterialTheme). Only component imports are forbidden.
// ✅
data class ProductListUiModel(val products: ImmutableList<Product> = persistentListOf())
setState { copy(products = products.toImmutableList()) }// ✅
package thisissadeghi.productdetail
// ❌ thisissadeghi.product-detail
// ❌ thisissadeghi.productDetail
// ❌ thisissadeghi.product_detailobject DashboardModules : BaseFeature(DashboardModules::class.simpleName.toString()) {
override fun getKoinModules(): List<Module> =
listOf(
module {
singleOf(::DashboardRemoteDataSourceImpl).bind<DashboardRemoteDataSource>()
singleOf(::DashboardRepositoryImpl).bind<DashboardRepository>()
viewModelOf(::DashboardViewModel)
},
)
override fun initialize() { DashboardModules }
}Registration in initKoin.kt:
private fun initializeFeatures() {
CommonModules.initialize()
DataModules.initialize()
DashboardModules.initialize()
// ... etc.
}ViewModels invoke repositories directly. No UseCase / Interactor layer.
// ✅
@Composable
fun DashboardScreen(
viewModel: DashboardViewModel = koinViewModel(),
onActionClick: (String) -> Unit,
onBackToDashboard: () -> Unit,
) { ... }
// ❌
@Composable
fun DashboardScreen(navController: NavController) { ... }*UiModel is the only presentation state container — there is no *UiState.kt file. It holds plain UI fields plus one UiState<DTO> slot per async operation, where the DTO is the data-layer model (use UiState<Unit> for void ops). The repository returns Either<DTO> directly; the data layer never imports from presentation. UI-derived display values are sibling fields on *UiModel, never mirror DTO types.
// ✅ one container, DTOs inside UiState
data class DashboardUiModel(
val searchQuery: String = "", // plain UI field
val dataState: UiState<DashboardResponse> = UiState.Uninitialized, // UiState<DTO> from data/model/
val submitState: UiState<Unit> = UiState.Uninitialized, // UiState<Unit> for void ops
)
// ❌ separate *UiState.kt file, or presentation-layer mirror of a DTOEvery new feature wires through exactly these 4:
// 1. settings.gradle.kts
include(":feature:dashboard")
// 2. composeApp/build.gradle.kts
dependencies {
implementation(project(":feature:dashboard"))
}
// 3. initKoin.kt
private fun initializeFeatures() {
// ...
DashboardModules.initialize()
}
// 4. BaseAppNavHost.kt
XNavHost(navController = navController, startDestination = DashboardRoute) {
dashboard(
onActionClick = { id -> /* ... */ },
onBackToDashboard = { navController.popBackStack(DashboardRoute, false) },
)
}Routes contain only data; callbacks live in the NavGraphBuilder.{featurename}() extension:
@Serializable
data object DashboardRoute
fun NavGraphBuilder.dashboard(
onActionClick: (String) -> Unit,
onBackToDashboard: () -> Unit,
) {
composable<DashboardRoute> {
DashboardScreen(
viewModel = koinViewModel(),
onActionClick = onActionClick,
onBackToDashboard = onBackToDashboard,
)
}
}BaseAppNavHost.kt just calls dashboard(...) — never composable<Route> { ... } directly.
| A feature depends on | When |
|---|---|
:core:common |
Always (Either, UiState, setState, ErrorModel) |
:core:designsystem |
Always (X-components) |
:core:data |
Only if using ApiClient
|
Features NEVER depend on other features.
// ✅
@Resource("/products")
class ProductResource {
@Resource("{id}") data class Id(val parent: ProductResource, val id: String)
}
val list = client.get(ProductResource())
val single = client.get(ProductResource.Id(ProductResource(), "123"))
// ❌
client.get("/products")
client.get("/products/123")// Screen — ViewModel wrapper (NOT tested)
@Composable
fun FeatureScreen(viewModel: FeatureViewModel, onBackClick: () -> Unit) {
val uiModel by viewModel.uiModel.collectAsStateWithLifecycle()
FeatureScreenRoot(uiModel, onBackClick, viewModel::retry)
}
// ScreenRoot — ViewModel-independent (TESTABLE)
@Composable
fun FeatureScreenRoot(uiModel: FeatureUiModel, onBackClick: () -> Unit, onRetry: () -> Unit) {
// X-components only; route on uiModel.{slot}State for async slots
}UI tests target ScreenRoot. See Test-UI.
{Feature}Screen.kt has a fixed allowlist of top-level @Composable fun declarations — nothing else is permitted at file scope (this is a lint-checked structural rule, not a judgment call):
| # | Name | Visibility | Required? |
|---|---|---|---|
| 1 | {Feature}Screen |
public | Always — ViewModel wrapper |
| 2 | {Feature}ScreenRoot |
public | Always — owns state routing |
| 3 | LoadingContent |
private | Optional — only if the design specifies a dedicated loading screen |
| 4 | FailedContent |
private | Optional — only if the design specifies a dedicated failure screen |
| 5 | EmptyContent |
private | Optional — only if the design specifies a dedicated empty screen |
Everything else lives one file per composable under presentation/ui/components/ — including {Feature}Content.kt (the success-state / form composable), which is never inlined into Screen.kt.
-
Non-
@Composablehelpers (formatters, validators, mappers) go inpresentation/ui/{Feature}Utils.kt, never undercomponents/. -
@Previewcomposables live in the same file as the composable they preview (markedprivate, wrapped inXTheme) and are exempt from the allowlist. Use the importandroidx.compose.ui.tooling.preview.Preview(CMP 1.11.0+, available fromcommonMain) — the olderorg.jetbrains.compose.ui.tooling.preview.Previewis deprecated.
The single canonical file is .claude/skills/_shared/patterns.md. Every skill and agent imports it. This wiki page mirrors that file in a friendlier format — when in doubt, the patterns.md file wins.
Back to Home