Skip to main content

Structure a multi-module project

For a reusable or published component library, keep components and stories in separate modules.

The dev.cstories.gradle plugin adds cstories-runtime and the catalog infrastructure to the module where it is applied. If that module is the published component library, consumers may be required to resolve CStories runtime dependencies even when they only use the components.

A single-module setup is appropriate only for an application-owned design system or a module that is not published as a reusable library.

When a single module is acceptable

If your non-published design system module already targets jvm() (or you're fine adding it), you can apply the CStories plugin directly on it. A jvm()-only catalog needs no wasmJs target at all, so it never forces Gradle to resolve commonMain dependencies for a platform your module doesn't otherwise support:

// lib/build.gradle.kts
plugins {
kotlin("multiplatform") version "2.2.0"
id("org.jetbrains.compose") version "1.8.2"
id("org.jetbrains.kotlin.plugin.compose") version "2.2.0"
id("dev.cstories.gradle") version "1.1.5"
}

kotlin {
jvm()
}

The component module must not apply dev.cstories.gradle. It should only apply dev.cstories.gradle.components when it needs component references. That plugin publishes component metadata, not the generated CStoryComponentRefs API.

The stories module is the only module that applies dev.cstories.gradle. This keeps cstories-runtime, catalog tasks, and CStories-specific dependencies outside the published component library.

Why split components and stories

If you also want (or only want) the web catalog, and your design system module already targets other platforms and pulls in dependencies that aren't published for wasmJs (a private icon library, a platform-specific SDK, ...), applying the plugin directly to that module with wasmJs declared forces Gradle to resolve all of its commonMain dependencies for wasmJs too — the build can break with errors like:

Could not resolve com.example:some-native-only-lib:1.0.0.
Required by:
project :lib

The recommended structure mirrors CStories' own core principle (@CStory never lives on the design system component itself) at the module level: keep a separate stories module that depends on :lib as a regular dependency, and is the only place wasmJs and the CStories plugin get applied. :lib itself stays completely untouched — no new target, no new dependency resolution constraints.

:lib // your design system, untouched — jvm, ios, android, whatever it already targets
:lib:stories // new module — depends on :lib, applies the CStories plugin, targets wasmJs (and/or jvm)

Setting it up

settings.gradle.kts:

include(":lib", ":lib:stories")

lib/stories/build.gradle.kts:

import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl

plugins {
kotlin("multiplatform") version "2.2.0"
id("org.jetbrains.compose") version "1.8.2"
id("org.jetbrains.kotlin.plugin.compose") version "2.2.0"
id("dev.cstories.gradle") version "1.1.5"
}

@OptIn(ExperimentalWasmDsl::class)
kotlin {
jvm() // optional — add it for a desktop catalog alongside the web one
wasmJs {
browser()
binaries.executable()
}

sourceSets {
commonMain.dependencies {
implementation(project(":lib"))
}
}
}

Stories live in lib/stories/src/commonMain, importing components from :lib and demonstrating them. :lib never depends on CStories, and :lib:stories never needs to resolve :lib's non-wasmJs-published dependencies for any target other than wasmJs.

The plugin takes care of the rest for whichever target(s) you declared: it wires cstories-annotations, cstories-runtime, and the cstories-processor KSP dependency, and generates the catalog's entry point(s). These dependencies belong to the stories module and are not required by consumers of :lib.

What's next

If your component and its story live in different modules, referencing the component safely from the story requires one extra step — see Reference a component in a story.