Skip to content
Go back

Same Car, Every Time: Deterministic Feature Module Generation in KMP

by KMP Bits

KMP Bits Cover

Walk down the pit lane at a Porsche Cup weekend and every garage holds the same car. Same chassis from the same jig, same engine sealed by the same people, same tolerances checked against the same sheet. Nobody on that grid gets to be creative about the monocoque. It isn’t a lack of ambition, it’s the entire point: when thirty cars are identical, a tenth of a second means something. You learn from the difference because the baseline never moves.

A Formula 1 grid gives you brilliant engineering and twenty-two cars where no two problems have the same cause. Both are real forms of racing. They answer different questions.

Feature modules are a spec series. I want every one of mine built to the same jig, so that when a module behaves oddly I know it’s the code I wrote and not the scaffold it arrived in. It took me a while, and one AI-generated codebase, to work out that I’d been running Formula 1.


A while back I wrote about custom Gemini commands in Android Studio, and the one I used most was the one that created KMP feature modules. The argument was simple: write down what your project looks like once, stop explaining it from scratch every time. I still believe that. The command file worked.

Then I went back and read three features I’d generated over about two months.

One had implementation(project(":feature:x:domain")) inside commonMain.dependencies. One had it at the top level of the kotlin block. One had it in both places, which somehow still compiled. The ViewModel in the oldest one extended a base class signature I’d changed since. The package for a hyphenated feature name had come out as user-profile in one file and user_profile in the rest, so that module simply didn’t build until I fixed it by hand.

None of these were dramatic failures. Every single one was a five minute fix. But five minutes times four files times every feature is exactly the interruption I built the command to avoid, and now I was paying it at review time instead of at creation time, which is worse, because at review time I’ve already stopped thinking about this feature.

So I stopped asking and wrote a bash script.


The thing I actually wanted was not intelligence

Here’s the part that took me embarrassingly long to accept: for this specific job, the AI’s greatest strength is the problem.

An AI generator reads your instructions, reads your codebase, and produces something reasonable. “Reasonable” is a range. It has to be, because the model is inferring intent every time. Ask it twice and you get two points inside that range, both defensible, neither identical. That’s exactly what you want when the task is genuinely novel and you can’t specify it up front.

Scaffolding is not that task. Scaffolding is the opposite of that task. I know precisely what the output should be, down to the import order, because I wrote the module it’s copying. There is nothing to infer. Every degree of freedom the model has is a degree of freedom I didn’t ask for.

What I wanted was a fixed baseline. Not better output, identical output, so that any difference between two modules is information rather than noise.

Which raises the fair question of why bash, when a Gradle plugin or an IntelliJ file template would also be deterministic. Both were on the table. The template lost because it stops at files: it can’t append to settings.gradle.kts or splice a route into an existing sealed interface, which is most of what makes a new module actually usable. The Gradle plugin lost on cost. It’s the better answer if you’re handing this to a team of fifteen, but it’s a build-logic module to maintain and a Gradle build to re-run to test a change, whereas the script is one file I can read top to bottom and edit in place. If the thing ever outgrows bash I’ll port it. It hasn’t.


What the script generates

scripts/new-feature.sh takes a kebab-case feature name and produces up to three Gradle modules under feature/<name>/: domain, data, presentation. It mirrors the smallest real feature in the project, so the output isn’t an idea of what my modules look like, it’s a copy of one that already ships.

./scripts/new-feature.sh wishlist

That gives you:

It compiles and runs immediately. The screen is a PlaceholderScreen inside a ScaffoldScreen, the repository interface is empty, and the state holds a single Unit field so the data class is valid. Nothing pretends to be finished.

A note on the code you’re about to read: this is my script, shaped around my project. The paths, the package com.example.template, Metro for DI, Navigation 3, StateViewModel and BaseState from my core/ui module, the template.* convention plugins. Dropped into your codebase as-is it won’t generate anything that compiles, and that’s expected rather than a problem. The value of a generator like this comes from it knowing your conventions, so the interesting part is the structure, not the contents. If you’re on Koin instead of Metro, or MVI instead of this MVVM split, or one module per feature instead of three, the shape of the script stays and every heredoc inside it becomes yours. Treat what follows as a worked example to adapt.

The name handling is the first thing that stops being a per-feature annoyance:

# kebab-case -> snake_case (Kotlin package segment) and PascalCase (class name prefix)
SNAKE="${KEBAB//-/_}"
PASCAL=""
IFS='-' read -ra WORDS <<< "$KEBAB"
for word in "${WORDS[@]}"; do
  first_upper="$(printf '%s' "${word:0:1}" | tr '[:lower:]' '[:upper:]')"
  PASCAL="${PASCAL}${first_upper}${word:1}"
done

user-profile becomes the folder user-profile, the package segment user_profile, and the class prefix UserProfile. Three forms of the same name, derived once, used everywhere. This is the exact transformation an AI gets right most of the time. “Most of the time” is what shipped me a module that didn’t build.


Guardrails come before generation

The script writes into feature/, appends to settings.gradle.kts, and rewrites NavigationRouter.kt in place. Something that edits tracked files without asking should be paranoid about it, so nothing gets written until three checks pass. Two are obvious: the name has to match ^[a-z][a-z0-9]*(-[a-z0-9]+)*$, and feature/$KEBAB must not already exist. The third is the one I care about:

if [[ -n "$(git status --porcelain)" ]]; then
  echo "Warning: you have uncommitted changes. This script edits settings.gradle.kts and"
  echo "NavigationRouter.kt in place, and creates new files under feature/$KEBAB."
  read -r -p "Continue anyway? [y/N] " confirm
  [[ "$confirm" =~ ^[Yy]$ ]] || exit 1
fi

With a clean tree, git diff after the run is a complete, readable record of everything the script touched. With a dirty tree, the generated changes are tangled up with yours and you lose the cheapest verification you have. The script can’t force a clean tree, but it can make you type y and think about it for a second.

set -euo pipefail sits at the top, so anything unexpected stops the run rather than leaving half a feature on disk.


Then I needed one module, not three

I wanted a domain and data pair with no UI. A feature that exposes a repository, consumed by an existing screen somewhere else. The script had exactly one mode: all three modules, every time.

The obvious move, if you’re used to AI generation, is to go back and describe the exception. “Same as before, but skip presentation.” And that works. It works right now, on the first try, which is precisely why it’s a trap. Because next month I want domain alone, and the month after I want data and presentation with the domain contract living in a shared module, and each of those is a fresh description of an exception to a structure that nobody has actually written down. Every exception is a new chance for the output to drift from the last one.

Rigidity was the whole reason I wrote the script. The answer isn’t to soften it, it’s to make the flexibility part of the spec.

# Usage: ./scripts/new-feature.sh <feature-name> [--modules <module> [<module> ...]]
./scripts/new-feature.sh wishlist                    # domain + data + presentation
./scripts/new-feature.sh wishlist --modules domain data

Three booleans default to true, and --modules flips them all off and turns back on only what you named:

GEN_DOMAIN=false
GEN_DATA=false
GEN_PRES=false

for module in "${MODULES[@]}"; do
  case "$module" in
    domain) GEN_DOMAIN=true ;;
    data) GEN_DATA=true ;;
    presentation) GEN_PRES=true ;;
    *)
      echo "Error: unknown module '$module'. Must be one of: domain, data, presentation." >&2
      exit 1
      ;;
  esac
done

The flag accepts at most two names, deliberately. --modules domain data presentation is already the default, and offering two ways to say the same thing is how flags start rotting. If you want all three, don’t pass the flag.

That cap has a hole you only find by running your own tool wrong: --modules domain domain has a length of two, passes the count check, and sets one boolean. Comparing how many booleans ended up true against how many names were typed catches it in three lines. Small thing, and the sort of edge case no amount of prompting reliably covers, because you’d have to think of it in advance to ask for it — and once you’ve thought of it you may as well encode it.

Notice what this is: a flag with a validated, closed set of values. The flexibility is real and it’s bounded. Every combination that’s allowed is a combination I decided was allowed, and the ones I didn’t decide on fail loudly at argument parsing rather than quietly at compile time.


Conditionals are where the real work is

Adding the flag was twenty minutes. Making the generated code correct for each combination was the rest of the afternoon, and this is the part I think matters most.

If you generate data without domain, the data module’s build.gradle.kts must not depend on a domain module that doesn’t exist. Neither branch is clever, and that’s the point:

if $GEN_DOMAIN; then
  cat > "feature/$KEBAB/data/build.gradle.kts" <<EOF
import com.android.build.api.dsl.LibraryExtension

plugins {
    alias(libs.plugins.template.data)
}

extensions.configure<LibraryExtension>("android") {
    namespace = "com.example.template.feature.$SNAKE.data"
}

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(project(":feature:$KEBAB:domain"))
        }
    }
}
EOF

and the else branch is the same file with the whole kotlin { } block gone.

Those alias(libs.plugins.template.data) lines are convention plugins from my build-logic module. Each layer gets one plugin ID, and everything else, targets, compiler options, common dependencies, lives centrally. It’s the same setup I mentioned in the Gemini article. Worth noting here because it’s part of why the generated Gradle files are short enough to template safely: there’s very little per-module configuration left to get wrong.

The second consequence is more interesting. Without a domain module, there is no WishlistRepository, so WishlistRepositoryImpl has nothing to implement and nothing to bind. The script doesn’t generate a degraded version of it. It doesn’t generate it at all, and it tells you why:

echo "Note: domain not generated — feature/$KEBAB/data has no ${PASCAL}RepositoryImpl" \
  "(it would need ${PASCAL}Repository from the domain module)."

That line took a couple of iterations to get right and I’d argue it’s the most valuable output the script produces. A generator that silently omits things trains you to distrust it. A generator that says “I left this out, here’s the reason” is a tool you can stop checking. The absence is intentional and documented at the moment it happens, in the terminal, while you’re still looking.

Same story for the route registration. NavigationRouter.Wishlist.Main only exists to be referenced by WishlistGraph, which lives in the presentation module. No presentation, no route:

if $GEN_PRES; then
  ROUTER_FILE="core/ui/src/commonMain/kotlin/com/example/template/core/ui/navigation/NavigationRouter.kt"
  # ... splice the route in
fi

Three modules, generated or not, gives eight combinations if you count naively, six that the argument parser actually allows. Every one produces code that compiles. That’s the property I was buying, and it’s a property you can verify by running the thing six times, which is not something you can meaningfully do with a prompt.


The one clever bit, and its escape hatch

NavigationRouter.kt is a sealed interface hierarchy, and new routes go in as nested data object declarations before the final closing brace. There’s no marker comment, no anchor, just a } on its own line at the end of the file.

LAST_BRACE_LINE=$(grep -n '^}$' "$ROUTER_FILE" | tail -1 | cut -d: -f1)

Find every line that is exactly a closing brace, take the last one, extract the line number. Then rebuild the file in three pieces:

{
  head -n "$((LAST_BRACE_LINE - 1))" "$ROUTER_FILE"
  cat <<EOF

    @Serializable
    data object $PASCAL : NavigationRouter {
        @Serializable
        data object Main : NavigationRouter
    }
EOF
  tail -n "+$LAST_BRACE_LINE" "$ROUTER_FILE"
} > "$ROUTER_FILE.tmp"
mv "$ROUTER_FILE.tmp" "$ROUTER_FILE"

Everything before the brace, the new block, then the brace and anything after it. Written to a temp file and moved into place, so a failure halfway through leaves the original intact rather than truncated.

I’m aware this is line-based surgery on Kotlin source, and that it depends on a formatting convention holding. Which is why the fallback exists:

if [[ -z "$LAST_BRACE_LINE" ]]; then
  echo "Warning: couldn't find NavigationRouter.kt's closing brace — add the route manually:"
  echo "    @Serializable"
  echo "    data object $PASCAL : NavigationRouter {"
  echo "        @Serializable"
  echo "        data object Main : NavigationRouter"
  echo "    }"

If the assumption breaks, the script says so and prints exactly what you need to paste. It does not guess at an alternative insertion point. A generator that fails visibly and hands you the fix costs you thirty seconds. A generator that improvises when its assumptions break costs you a debugging session, and you won’t know to start one, because it looked like it worked.


What it deliberately refuses to do

Two things the script will not touch, and both refusals are in the header comment so I can’t quietly forget them.

It won’t wire WishlistGraph into anything. A new feature can live in a bottom nav tab, behind a settings row, in a shared overlay, or somewhere I haven’t invented yet, and that is an architecture decision with consequences. A generator making it for me is a generator making an architecture decision I didn’t review.

It won’t write domain logic. WishlistRepository is an empty interface with a comment telling you to define the contract. That’s the actual work. The script’s job was to clear a path to it.

So the run ends with a list rather than a claim of completion:

Still manual:
  - Wire WishlistGraph(onNavigation = ...) into wherever this feature actually lives
  - Fill in WishlistRepository / WishlistRepositoryImpl with real logic.
  - Run './gradlew :feature:wishlist:domain:detekt ...' (or just commit — the pre-commit hook covers it).

Building the detekt task list from the same booleans is a two-line addition, and it means the command you’re told to run is always the right one for what was actually generated.


When I still reach for the AI

I want to be clear that I didn’t replace one tool with a better one. I split one job into two jobs.

The script handles the part I’ve done forty times and will do forty more, where I know the answer and want it applied identically every time. The AI handles everything after that: the domain logic, the state modelling for a screen that doesn’t resemble any other screen, the migration that touches nine files in ways I can’t specify up front. That’s genuinely novel work, and the ability to infer intent from a loose description is exactly what makes it useful there.

There’s also a nice side effect. The AI is now working inside a codebase where every feature module has the same shape, because the shape came from a script rather than from six separate acts of interpretation. Consistent context produces better output. The rigid tool makes the flexible one work better.

A rough test I use now: if I’m about to write a prompt for the third time and it’s substantially the same prompt, that’s not a prompt, that’s a script I haven’t written yet. And if the script needs to handle a case it doesn’t, the fix is a flag with a validated set of values, not a paragraph of natural language explaining the exception.


Wrapping up

The Gemini article’s conclusion was that you should write down what your project looks like once. This one is the next step: for the things you do constantly, write down how as well, in something that executes rather than interprets. Not my how. Yours, with your package name and your base classes and your DI framework, which is an afternoon of work and the reason the thing is worth anything at all.

--modules is the whole argument in miniature. I needed the generator to do something it couldn’t do. I could have described the exception to a model in about fifteen seconds, and I’d have described it again, slightly differently, every time since. Instead I spent an afternoon making the exception a first-class, validated, six-combinations-all-of-which-compile part of the tool. Now nobody describes anything. You pass --modules domain data and you get the same two modules I got, and the same two I’ll get in November.

Boring is the feature. Six months from now the newest module and the oldest one will still be the same shape, and that is worth more to me than a generator clever enough to surprise me.

Same jig, same tolerances, same car off the truck every race weekend. The lap time is where the interesting differences belong. 🏁


Share this post on:

Comments

0 / 250

Loading comments...


Next Post
Change the Map: Feature Flags and Remote Config in Kotlin Multiplatform