Where Kotlin support sits today
The Kotlin support in OpenRewrite has always been an extension of the Java support rather than a replacement for it. The Kotlin LST is built on top of the Java LST: most of the tree nodes a Kotlin source file produces (classes, methods, blocks, statements, method invocations, field accesses, literals, identifiers) are the same J.* nodes a Java source file would produce. The handful of Kotlin constructs that have no Java equivalent (when expressions, the !! operator, properties as a first-class declaration kind, destructuring declarations, string templates, type aliases) get their own K.* node types, and each of those types implements one of J.Expression, J.Statement, or J.TypeTree. K.CompilationUnit implements JavaSourceFile. The practical consequence is that a JavaVisitor whose isAcceptable returns true for JavaSourceFile will already walk a Kotlin source file, seeing the parts of the tree it understands and treating the K.* extensions through their J.* interfaces. KotlinVisitor extends JavaVisitor and adds visitX(K.X, P) overloads for the Kotlin-specific node types. So a recipe author who already knew how to write a Java recipe knew how to write most of a Kotlin recipe; the surface they had to learn to add was small.
That has been the imperative authoring story for years: write a KotlinVisitor, override the visit methods you need, return rewritten nodes. It works, but the vast majority of recipes we wanted to write for Kotlin are pattern-shaped (this expression should look like that expression), and in that pattern-shaped majority, the visitor boilerplate dwarfs the actual rewrite. Java has had Refaster for this for years: write a @BeforeTemplate method and an @AfterTemplate method, run the annotation processor, get a generated recipe. Kotlin has had nothing equivalent. This post is about the DSL that fills that gap: what shape it takes, how it relates to the imperative visitor underneath, and how the recipes we authored on top of it look against real Kotlin code. The recipes are grouped into three domains: migration, performance, and Android.
A Kotlin DSL for declarative recipes
Recipe authors operate in two modes. Most refactorings are pattern-shaped ("this expression should look like that expression"), and ought to read that way. Some are structural: visit a class declaration, look at its annotations, decide. The DSL has a phase for each.
Pattern-shaped rewrites
The pattern-shaped surface is rewrite { } to { }:
val UseAppendLine: Recipe = recipe(
displayName = "Use `appendLine()` instead of `append(\"\\n\")`",
description = "`appendLine` is the idiomatic Kotlin spelling and avoids a literal newline char.",
) {
edit {
rewrite { sb: StringBuilder -> sb.append("\n") } to { sb -> sb.appendLine() }
}
}That clause has the same expressive power as a Refaster @BeforeTemplate / @AfterTemplate pair: a structural before/after with the parameters bound at call sites. What's different is that the recipe is just a Kotlin top-level property. No annotations, no separate template class, no annotation processor staring at your code at compile time. The K2 compiler plugin reads the before lambda, extracts a MethodMatcher spec from its FIR-resolved root call, builds the after template from the after lambda's source, and synthesizes a Recipe subclass whose getVisitor() walks the LST replacing matches.
The plugin makes one choice that's worth surfacing. Every generated visitor is wrapped in Preconditions.check(...) against a UsesMethod (or UsesField for property-access patterns) derived from the matcher spec. Files that don't reference the targeted member are skipped without being walked, the same way Refaster's generated Java recipes skip files that don't reference the targeted type and method. We drop the UsesType half of Refaster's pattern for a Kotlin-specific reason: matcher owners for Kotlin extension functions land on the synthetic JVM facade class (kotlin.text.StringsKt for String.lowercase(), kotlin.io.ConsoleKt for readLine()), and source code never references those facades directly. Adding UsesType would gate every file. UsesMethod and UsesField each already constrain on owner via the methods- and fields-in-use cache, so we lose no real filtering power.
Beyond pattern shape
When pattern shape isn't enough (when you need to look at a class declaration's annotations before deciding, or check whether two call sites in the same file paired up), drop into the imperative kotlin { } scope:
val FindAsyncTaskExecute: Recipe = recipe(
displayName = "Find AsyncTask.execute calls",
description = "AsyncTask was deprecated in API 30. Migrate to coroutines or Executors.",
) {
edit {
kotlin {
visitMethodInvocation { mi ->
if (asyncTaskExecute.matches(mi)) SearchResult.found(mi)
else mi
}
}
}
}The three phases (scan, edit, generate) line up with the three recipe types you'd write by hand in Java. scan accumulates information across files before any edits, edit rewrites trees in place, generate adds new source files. They compose: a recipe can scan its sources, then edit based on what it found, then generate a summary report.
What authors don't see
Authors don't see the K2 plugin. It runs at recipe-compile time, replaces matching recipe(...) calls in each top-level property's initializer with synthesized <Name>$KtRecipe constructors, and writes a fresh Recipe subclass into the same .class file as the property. Recipe consumers see a normal Recipe instance with serializable metadata and a working getVisitor(). No reflection, no ServiceLoader gymnastics.
The migration category
The migration category is the simplest to motivate and the largest in line count. Kotlin1To2.kt is roughly two thousand lines of rewrite rules ferrying code across the years of Kotlin standard library churn: toUpperCase() → uppercase(), appendln(...) → appendLine(...), Char.toInt() → Char.code, sumBy { } → sumOf { }, the Duration API rename from inHours to inWholeHours, the regex options enum reshuffle, the KotlinVersion API rename, the OptIn shape change, and most of the rest.
Most of these are single-line rewrite { } to { } clauses:
val UseUppercase: Recipe = recipe(
displayName = "Use `uppercase()` instead of `toUpperCase()`",
description = "`toUpperCase()` was deprecated in Kotlin 1.5 in favor of `uppercase()`.",
) {
edit {
rewrite { s: String -> s.toUpperCase() } to { s -> s.uppercase() }
}
}What makes migration recipes useful, or not, is whether they cover the patterns that actually appear in real code. The four or five examples in a deprecation notice are rarely a complete enumeration; each family has long-tail cases that the original author of the deprecation didn't bother to list. We seeded each family (string accessors, char arithmetic, duration, IO, regex) with the obvious cases from the changelog, then pointed the seed recipes at a corpus of real Kotlin sources and added the calls that weren't matching but should have been. The declarative shape made that loop cheap to run: when a call shape we hadn't seen showed up, the recipe was usually one more line.
The performance category
The performance category is where the DSL pays off most concretely. Not because anything is impossible without it, but because the per-pattern cost matters here. The Kotlin standard library exposes a number of fused operators (first(p) instead of filter(p).first(), mapNotNull(f) instead of map(f).filterNotNull(), and so on); a complete set of rewrites covering them is one recipe per pair, and with the declarative shape each recipe is one Kotlin line. Read this:
val firstAdmin = users
.filter { it.role == "admin" }
.map { it.copy(name = it.name.uppercase()) }
.first()There's nothing visually wrong with it. The chain reads top to bottom, the operator names mean exactly what they say, and the Kotlin standard library makes the surface look identical to Java Streams. A reader coming from Java has every reason to assume this code is lazy: the .filter records a filter, the .map records a map, the .first() materializes one result. That's what users.stream().filter(...).map(...).findFirst() would do.
It's not what this code does. Every operator in the chain is an eager extension on Iterable<T> that allocates a new list. The .filter allocates a new ArrayList containing every admin in the input. The .map allocates a second new ArrayList containing every admin's transformed copy. The .first() then reads one element from the second list and throws both away. For a thousand-user input where ten match, we allocate two ten-element lists and run the predicate and the map ten times before throwing away the work for nine of them. For a million-user input where the first match is at element three, we still scan the entire list twice and allocate two lists of however many admins happen to be in it. This is what the recipe UseFirstWithPredicate is written to find: the filter { p }.first() half of any chain shaped like the one above.
The Kotlin standard library exposes a lazy alternative, Sequence, that mirrors Java Streams almost exactly:
val firstAdmin = users.asSequence()
.filter { it.role == "admin" }
.map { it.copy(name = it.name.uppercase()) }
.first()That version short-circuits, allocates only an iterator, and runs the predicate and the map exactly once. The DSL ships a recipe that converts the first shape to the second when the chain is long enough to justify the asSequence() overhead.
But the deeper recipe is the one that recognizes that this particular chain, filter { p }.first(), doesn't actually need lazy chaining either. The standard library has a fused operator for it:
val firstAdmin = users.first { it.role == "admin" }No allocation at all, no asSequence() ceremony, and the original intent reads more clearly.
Fused operators
The performance category has a recipe for every fusion pair the standard library offers:
These aren't hypothetical patterns. Running the CollapseFilterTerminals composite (which bundles every entry in the table above) across a handful of well-known Kotlin OSS projects finds hits in roughly every one of them:

And the diffs themselves are exactly the fusions the table promises. From Kotlin/binary-compatibility-validator:

Why Kotlin's standard library is eager
Now to the question that's been hanging in the air: why is Kotlin shaped this way? The answer is interoperability. Kotlin's standard library is built on top of java.util.List, the same interface the JDK ships, the same one every Java method on the classpath accepts and returns. Returning a Stream or some Kotlin-specific lazy proxy from .filter would mean the result couldn't be passed back to Java code without an explicit conversion, would have different equality and serialization semantics than what Java callers expect, and would invert the relationship between the language and its host platform. So the standard library does what the host platform's collections do: eager operations that return new instances.
The price is what we just saw. The pattern looks lazy and isn't.
The Kotlin-shaped-this-way question becomes more interesting when you stop looking at Kotlin alone and put it next to its two JVM neighbors. Java, Scala, and Kotlin are all sitting on the same axis, between two competing concerns. On one end is JVM interoperability, which pulls toward eager-by-default collection operations because java.util.List is the lingua franca of the platform and any other return type forces an explicit conversion at every call boundary. On the other end is allocation-frugal evaluation, which pulls toward lazy-by-default operator chaining because a lazy chain can short-circuit and avoid the intermediate List<R> allocation entirely. The two concerns are not strictly mutually exclusive (every JVM language carries both eager and lazy variants), but each language has to pick a default, and the default is where its identity shows up.
Java picked an asymmetric default. List methods are eager: list.map-shaped operations don't exist on List itself, only the new-list-returning helpers on Collections and Collectors. The lazy half lives on Stream, reachable by an explicit .stream() bridge. Interop wins on the data structure, frugality wins on the chained shape.
Scala's immutable.List.map, filter, and flatMap are also eager: List(1, 2, 3).map(f) allocates a fresh cons-list, the same way listOf(1, 2, 3).map(f) allocates a fresh ArrayList. The lazy alternatives are view and LazyList, and idiomatic Scala reaches for them when chains get long. The language's identity is in how prominent those lazy variants are in the community's defaults, even though the eager forms are the ones literally bound to the standard collection types.
Kotlin picked eager for Iterable and tucked the lazy variant behind asSequence(). None of the three languages is allocating fewer cons cells than the others when you ask for eager operations. What differs is which side of the tradeoff each language wanted to put in the path of least resistance, and Kotlin sits much closer to the interop end of the axis than Scala does: the standard library's chain operators return List<T> rather than a lazy proxy because a returned List<T> doesn't need a conversion to be called from Java, and the cost shows up as the .filter().map().first() chains that the recipes in this category collapse.
Lexical-context performance recipes
A second class of performance recipes, orthogonal to allocation chains, looks at the lexical context in which an allocation happens. The same .filter().map() chain inside a @Composable function is materially worse than the same chain inside a method called once at application startup, because Compose may re-execute the function on every recomposition. The DSL exposes lexical-context predicates that let recipes scope their advice to hot paths:
val FindAllocationInCompose: Recipe = recipe(...) {
edit {
kotlin {
visitMethodInvocation { mi ->
if (allocatingChain.matches(mi) && cursor.isInsideComposable())
SearchResult.found(mi)
else mi
}
}
}
}There are cursor predicates for @Composable, onDraw, onLayout, onMeasure, and tight loops. The same allocation pattern in a once-per-request handler is fine; in a per-frame layout pass it's the difference between 60fps and 6fps.
The Android category
Android is the densest source of deprecation-driven patterns in the catalog. The platform's lifecycle, threading, and UI conventions have churned through three or four distinct eras since Kotlin became the default language for Android development, and most apps in production are running surface from more than one of them. The patterns the recipes flag today are the obvious ones: AsyncTask and its execute calls, deprecated in API 30 in favor of coroutines or Executor; the int-keyed findViewById and the older Activity overload, superseded by the typed findViewById<T>() and ViewBinding; the long-deprecated Resources.getDrawable(int) and Resources.getColor(int), replaced by the Context and ContextCompat variants; LocalBroadcastManager, removed in androidx.localbroadcastmanager:1.1.0 in favor of Flow or LiveData; the startActivityForResult plus onActivityResult override pattern, replaced by registerForActivityResult and the typed activity-result contracts; the synthetic imports from the kotlin-android-extensions plugin, removed years ago in favor of ViewBinding; Fragment.setRetainInstance(true), replaced by ViewModel; and the bare Handler() constructor, which silently picks up Looper.myLooper() from the calling thread rather than the main looper most authors intended. The Compose-specific subset of the Android category reuses the chain-allocation finders from the performance category but scopes them to @Composable functions, where the same allocation runs on every recomposition rather than once per call. It also adds a handful of recipes covering what the Compose stability guide calls out: stable parameters, remember for derived state, LaunchedEffect keys that actually identify the effect.
The list above is roughly chronological: AsyncTask is older than LocalBroadcastManager is older than startActivityForResult is older than the Compose conventions. The reason that's worth noting is that an Android codebase three or four years old will typically have surface mapping to more than one of those eras at once. Running the Android recipes in search-only mode is useful as a quick read on which era a codebase currently sits in: a project that's all findViewById and AsyncTask is somewhere around 2017; one that's mostly the typed registerForActivityResult pattern with a few stray LocalBroadcastManager calls is somewhere around 2021 with a migration tail.
What's in the catalog today should be read as a starting point rather than a complete migration suite. The recipes that exist now are mostly finders (they tell you where each pattern lives without rewriting it) because we're using them to build out the corresponding OpenRewrite traits first. A trait in OpenRewrite is a semantic view over a syntax node: Annotated, MethodAccess, VariableAccess, and similar abstractions in rewrite-java that a recipe can match against without re-deriving the same conditions every time. The Android category needs its own set: a Composable trait that recognizes a @Composable-annotated function and exposes its receivers and call sites, a Modifier trait that exposes the chain of modifier calls and their ordering, lifecycle-method traits for the Activity and Fragment callback shapes, and so on. With those in place, the autofix mutator for each pattern lands on top of a trait rather than as a one-off visitor that re-derives the same matching conditions. The finders shipped today are the discovery half of that work; the mutators will follow as the traits firm up.
val FindBareHandlerConstructor: Recipe = recipe(
displayName = "Find bare `Handler()` constructors that capture the current looper",
description = "The no-arg `Handler()` constructor was deprecated in API 30 because it silently uses `Looper.myLooper()` from the calling thread. The fix is to be explicit: `Handler(Looper.getMainLooper())` for UI work, or `Handler(HandlerThread(...).looper)` for background.",
) {
edit {
kotlin {
visitNewClass { nc ->
if (handlerNoArgCtor.matches(nc)) SearchResult.found(nc)
else nc
}
}
}
}What's next
Where the recipes go next is constrained less by what is possible to express and more by which patterns turn up most often when the full set runs across a corpus of real Kotlin code. The next round of authoring will be heavier on Gradle DSL migrations, Kotlin Multiplatform compatibility, structured-concurrency cleanups for projects that adopted coroutines before structured concurrency landed, and domain-specific recipes for the larger ecosystems: Spring, Ktor, the server-side Coroutines patterns, the rest of Compose. The last of those needs scan-driven analysis more than declarative rewrite shapes; the imperative kotlin { scan { } edit { } } side of the DSL is the lever for that, and it is the same KotlinVisitor underneath that the imperative authoring story has always been built on.
The DSL itself lives in openrewrite/rewrite under rewrite-kotlin, and is open source.

