Story Opening

Kabir reproduced the second bug report before his coffee had cooled. The store-operations team’s utility was four lines of Java:

// The store-operations team's utility: sorts whatever list it is given, in place.
public final class LegacyReports {
public static void sortInPlace(List<String> skus) {
Collections.sort(skus);
}
}

And his own class handed out its internal list through a read-only type:

// Fragment of story/ReadOnlyLeak.kt
class Assortment {
private val skus = mutableListOf("SHW-2040", "SHW-1001", "SHW-3001")
// Read-only to Kotlin callers. To Java, it is the same java.util.List, set() and all.
fun skus(): List<String> = skus

The Kotlin compiler would not let any Kotlin caller add to, remove from or sort that list. Java’s Collections.sort did it anyway, and the next caller of skus() got the new order.

“So List is just a suggestion,” he said. “Do I pull in Guava’s ImmutableList?”

“You could,” Lena said. “First find out what List actually is. It isn’t immutable, and it isn’t lazy either. That second part is your GC log.”


Java → Kotlin: The Quick Map

JavaKotlinNote
List<T> (with add, set…)List<T> (read-only) and MutableList<T>Both are java.util.List at runtime
A Java method returning List<String>(Mutable)List<String!>! in the IDEA platform type (Part 2): you choose read-only or mutable
List.of(…) (immutable, throws on set)listOf(…)Read-only interface; the object may still be mutable
stream().map(…).collect(toList())map { … }Eager: returns a List straight away
A lazy Stream pipelineasSequence().map { … }…Lazy, element by element; no primitive variants
Collectors.groupingBy / partitioningBygroupBy / partitionpartition returns a Pair
Collectors.toMap(k, v)associateBy { } / associate { }Duplicate keys: last wins, no exception
max(comparator) → OptionalmaxBy { } / maxByOrNull { }No Optional: most throwing accessors have an OrNull twin
IntStream.range(0, n).mapToObj(…)List(n) { … }
int[] / Integer[]IntArray / Array<Int>
"a.b".split("\\.") (regex)"a.b".split(".") (literal)Use Regex(…) for a pattern
Duration, Instant, UUIDkotlin.time.Duration, kotlin.time.Instant, kotlin.uuid.UuidConvert at Java boundaries

Conceptual Deep-Dive

Read-only is a view, not a value

Kotlin splits every collection interface in two: a read-only one (List, Set, Map, Collection, Iterable) and a mutable one that extends it (MutableList, MutableSet…). On the JVM there is only one of each, so both halves map onto the same Java interface:

Kotlin typeIn bytecodeWhat the compiler allows
List<T>java.util.List<T>get, size, iteration, operations
MutableList<T>java.util.List<T>All of the above, plus add, set, remove…
Map<K, V> / MutableMap<K, V>java.util.Map<K, V>Read-only / read-write

Assortment.skus() compiles to public final java.util.List<java.lang.String> skus(), the same signature a Java method would have. That is the design: Kotlin can pass its lists to any Java API and accept any Java list, with no copying and no wrappers. The price is that “read-only” exists only in Kotlin’s type checker. The object underneath is usually a plain java.util.ArrayList, and anything that isn’t Kotlin code, such as Java, reflection or a cast, can mutate it.

It is also why List<out E> can be covariant, so that a List<Product> is usable as a List<Any>: a list you can only read can’t be used to insert the wrong type. Part 8 builds on that.

So the mental model to unlearn is “immutable by type”. That is weaker than Java’s List.of, which throws on modification, and much weaker than a persistent collection.

Eager by default, lazy when asked

A Java developer reads products.map { … }.filter { … } as a Stream pipeline: lazy, fused into one pass, nothing happening until a terminal operation. Kotlin’s collection operations are the opposite. They run immediately and return a new List, and most of the transforming ones (map, filter, groupBy…) are inline functions (Part 5). A three-step chain over two million products makes three lists of up to two million elements, which is what the nightly job’s GC log was showing.

That is a deliberate default. For the small collections most code handles, eager inline operations are usually the cheapest option: a loop in your own bytecode, no lambda objects, no pipeline machinery, and results you can reuse and print. When the data is large, the chain is long, or you stop early (first, take), switch to a Sequence: Kotlin’s lazy equivalent of a Stream, processed element by element through the whole chain.


Technical Explanation

What the collection factories really return

fun main() {
val shelf = mutableListOf("SHW-1001", "SHW-2040")
val view: List<String> = shelf // the same object, seen through a narrower interface
// view.add("SHW-3001") // error: unresolved reference 'add'.
shelf += "SHW-3001"
println(view) // -> [SHW-1001, SHW-2040, SHW-3001]
// What the factories return at runtime: ordinary JDK collections, mostly.
println(listOf("a", "b")::class.java.name) // -> java.util.Arrays$ArrayList
println(listOf("a")::class.java.name) // -> java.util.Collections$SingletonList
println(mutableListOf("a")::class.java.name) // -> java.util.ArrayList
println(listOf("b", "a").map { it }::class.java.name) // -> java.util.ArrayList
println(mapOf("a" to 1, "b" to 2)::class.java.name) // -> java.util.LinkedHashMap
println(setOf("a", "b")::class.java.name) // -> java.util.LinkedHashSet
// A cast defeats the read-only interface, because the object underneath is mutable.
val mapped: List<String> = listOf("b", "a").map { it.uppercase() }
(mapped as MutableList<String>).add("C")
println(mapped) // -> [B, A, C]
// buildList is the exception: its result is read-only at runtime too.
val built: List<String> = buildList { add("x") }
try {
(built as MutableList<String>).add("y")
} catch (e: UnsupportedOperationException) {
println("buildList result is read-only") // -> buildList result is read-only
}
}

listOf("a", "b") is Arrays.asList underneath, which supports set (so Collections.sort works on it) but not add. map, filter and friends return ArrayLists, fully mutable to anyone who casts or calls from Java. buildList is the exception, returning a list that throws on modification at runtime too. Don’t build on the exact classes; they are implementation details. Build on the rule: a read-only type protects you from your own code, not from code that doesn’t respect it. When a list crosses into code you don’t control, return a copy (toList()), an unmodifiable wrapper, or a buildList result. For persistent collections with structural sharing, look at the separate kotlinx.collections.immutable library.

Builders

fun main() {
val skus = listOf("SHW-1001", "SHW-2040")
val prices = mapOf("SHW-1001" to 16_500L, "SHW-2040" to 72_000L) // 'to' builds a Pair
// Builders: mutate inside, read-only outside. No temporary mutable variable leaks out.
val shelfLabels = buildList {
add("AISLE 7")
for (sku in skus) add("$sku ₹${prices.getValue(sku) / 100}")
if (size > 2) add("END")
}
println(shelfLabels) // -> [AISLE 7, SHW-1001 ₹165, SHW-2040 ₹720, END]
val bySku = buildMap {
put("SHW-1001", "Toor Dal 1kg")
putAll(mapOf("SHW-2040" to "Basmati 5kg"))
}
println(bySku) // -> {SHW-1001=Toor Dal 1kg, SHW-2040=Basmati 5kg}
// Size-and-initializer constructors, like IntStream.range(...).mapToObj(...).toList().
println(List(3) { "slot-$it" }) // -> [slot-0, slot-1, slot-2]
// [] returns null for a missing key; getValue (used above) throws instead.
println(prices["SHW-9999"]) // -> null
}

buildList, buildSet and buildMap give you a mutable collection inside a lambda (with a receiver, Part 5) and a read-only one outside. They replace the Java pattern of a local ArrayList, a loop, and Collections.unmodifiableList at the end. mapOf("SHW-1001" to 16_500L) uses the to infix function from Part 6 to build Pairs.

The operations that replace Collectors

data class Product(val sku: String, val name: String, val category: String, val pricePaise: Long, val units: Int)
val catalog = listOf(
Product("SHW-1001", "Toor Dal 1kg", "staples", 16_500, 120),
Product("SHW-1002", "Moong Dal 1kg", "staples", 14_200, 0),
Product("SHW-2040", "Basmati 5kg", "staples", 72_000, 35),
Product("SHW-3001", "Ghee 500ml", "dairy", 34_000, 8),
Product("SHW-3002", "Paneer 200g", "dairy", 9_000, 40),
Product("SHW-5001", "Masala Chips", "snacks", 2_000, 300),
)
fun main() {
// stream().map(...).collect(toList()) -> map
println(catalog.filter { it.units == 0 }.map { it.sku }) // -> [SHW-1002]
// Collectors.groupingBy -> groupBy (a LinkedHashMap: insertion order kept)
val byCategory: Map<String, List<Product>> = catalog.groupBy { it.category }
println(byCategory.mapValues { (_, products) -> products.size }) // -> {staples=3, dairy=2, snacks=1}
// Collectors.toMap(Product::getSku, identity()) -> associateBy (duplicate keys: last wins, no exception)
val bySku: Map<String, Product> = catalog.associateBy { it.sku }
println(bySku.getValue("SHW-3001").name) // -> Ghee 500ml
val relabelled = catalog + Product("SHW-3001", "Ghee 1L", "dairy", 64_000, 2)
println(relabelled.associateBy { it.sku }.getValue("SHW-3001").name) // -> Ghee 1L
// Collectors.partitioningBy -> partition, returning a Pair you destructure
val (inStock, soldOut) = catalog.partition { it.units > 0 }
println("${inStock.size} in stock, ${soldOut.size} sold out") // -> 5 in stock, 1 sold out
// mapToLong(...).sum() -> sumOf; reduce with identity -> fold
println(catalog.sumOf { it.pricePaise * it.units }) // -> 5732000
println(catalog.fold(0) { total, product -> total + product.units }) // -> 503
// max(comparing(...)) -> maxBy / maxByOrNull (no Optional)
println(catalog.maxBy { it.pricePaise }.name) // -> Basmati 5kg
println(catalog.filter { it.category == "frozen" }.maxByOrNull { it.pricePaise }) // -> null
// flatMap, zip, chunked, windowed
val tags = mapOf("SHW-1001" to listOf("protein", "staple"), "SHW-3002" to listOf("protein", "fresh"))
println(tags.values.flatMap { it }.distinct()) // -> [protein, staple, fresh]
println(listOf("A1", "A2", "A3").zip(listOf(120, 35))) // -> [(A1, 120), (A2, 35)]
println(catalog.map { it.sku }.chunked(4)) // -> [[SHW-1001, SHW-1002, SHW-2040, SHW-3001], [SHW-3002, SHW-5001]]
val dailySales = listOf(40, 42, 39, 61, 75)
println(dailySales.windowed(3) { it.average().toInt() }) // -> [40, 47, 58]
// Counting per key without building the groups: groupingBy + eachCount
println(catalog.groupingBy { it.category }.eachCount()) // -> {staples=3, dairy=2, snacks=1}
// isSorted / isSortedBy (Stable since 2.4.0)
println(dailySales.isSorted()) // -> false
println(catalog.isSortedBy { it.sku }) // -> true
}

There is no collect step, because every operation already returns a collection, and no Collector protocol for custom terminal operations: fold, groupingBy { } or a buildMap { } with a loop cover those cases. groupBy returns a LinkedHashMap of lists; when you only need counts or sums per key, groupingBy { } plus eachCount, fold, reduce or aggregate computes them without building the lists. isSorted() and isSortedBy { } became Stable in 2.4.0.

Sequences: Kotlin’s Streams

import kotlin.streams.asSequence
// The same pipeline twice, for the bytecode comparison.
fun eagerTotal(prices: List<Long>): Long = prices.map { it * 105 / 100 }.filter { it > 20_000 }.sum()
fun lazyTotal(prices: List<Long>): Long = prices.asSequence().map { it * 105 / 100 }.filter { it > 20_000 }.sum()
fun main() {
val prices = listOf(16_500L, 14_200L, 72_000L, 34_000L, 9_000L)
// Eager: each step runs over the whole list and builds a new list.
val trace = mutableListOf<String>()
val firstEager = prices
.map { trace += "map $it"; it * 105 / 100 }
.filter { trace += "filter $it"; it > 20_000 }
.first()
println(firstEager) // -> 75600
println(trace.size) // -> 10
// Lazy: each element goes through the whole chain; first() stops the pipeline early.
trace.clear()
val firstLazy = prices.asSequence()
.map { trace += "map $it"; it * 105 / 100 }
.filter { trace += "filter $it"; it > 20_000 }
.first()
println(firstLazy) // -> 75600
println(trace) // -> [map 16500, filter 17325, map 14200, filter 14910, map 72000, filter 75600]
println(eagerTotal(prices) == lazyTotal(prices)) // -> true
// Infinite sequences, like Stream.iterate: only what you take is computed.
val reorderDates = generateSequence(1) { it + 7 }.take(4).toList()
println(reorderDates) // -> [1, 8, 15, 22]
// A sequence builder: yield values from ordinary code.
val labels = sequence {
yield("HEADER")
for (aisle in 1..2) yield("AISLE $aisle")
yield("FOOTER")
}
println(labels.joinToString(" / ")) // -> HEADER / AISLE 1 / AISLE 2 / FOOTER
// Java Streams convert to sequences (and back with asStream()).
println(java.util.stream.Stream.of("b", "a").asSequence().sorted().toList()) // -> [a, b]
}

The two traces tell the story. The eager chain ran map five times and filter five times, building two lists, to return the first match. The sequence ran each element through map and then filter, and stopped at the third element, because first() asked for only one.

The bytecode shows the two designs. eagerTotal is two inlined loops, each filling a new ArrayList:

// javap -c SequencesKt.eagerTotal (simplified)
15: new java/util/ArrayList // list #1, for map
37: invokeinterface Iterable.iterator
...
90: invokestatic java/lang/Long.valueOf // boxing each mapped value
96: invokeinterface Collection.add
102: goto 44
120: new java/util/ArrayList // list #2, for filter
...
214: invokestatic CollectionsKt.sumOfLong:(Ljava/lang/Iterable;)J

lazyTotal is a pipeline of objects, like a Stream:

// javap -c SequencesKt.lazyTotal (simplified)
10: invokestatic CollectionsKt.asSequence:(Ljava/lang/Iterable;)Lkotlin/sequences/Sequence;
13: invokedynamic invoke:()Lkotlin/jvm/functions/Function1; // the map lambda, as an object
18: invokestatic SequencesKt.map:(Lkotlin/sequences/Sequence;Lkotlin/jvm/functions/Function1;)Lkotlin/sequences/Sequence;
21: invokedynamic invoke:()Lkotlin/jvm/functions/Function1; // the filter lambda
26: invokestatic SequencesKt.filter:(Lkotlin/sequences/Sequence;Lkotlin/jvm/functions/Function1;)Lkotlin/sequences/Sequence;
29: invokestatic SequencesKt.sumOfLong:(Lkotlin/sequences/Sequence;)J

Neither is “the fast one”. Eager chains pay in intermediate collections; sequences pay a wrapper sequence and an iterator per step, and a virtual call per element per step. Both box primitives, because List<Long> and Sequence<Long> hold java.lang.Long. Rules of thumb:

Use a List chain when…Use a Sequence when…
The collection is small or medium (thousands)It is large, or comes from a file or a database cursor
The chain is one or two stepsIt is long, with several intermediate results
You need the whole resultYou stop early: first, take, any, find
You want to reuse or inspect intermediate resultsThe source is infinite (generateSequence, sequence { })

Compared with Java Streams: a Sequence can be iterated more than once if its source allows it (a sequence over an iterator or a file can’t), has no parallel mode, and has no primitive variants like LongStream, so numeric pipelines box. Its terminal operations have the same names as on lists (toList, sumOf, groupBy). Streams remain available from Kotlin and are the right tool for parallel or primitive-heavy processing.

Arrays

fun totalUnits(vararg units: Int): Int = units.sum() // vararg Int arrives as an IntArray (int[])
fun main() {
val boxed: Array<Int> = arrayOf(120, 0, 35) // Integer[] in bytecode
val primitive: IntArray = intArrayOf(120, 0, 35) // int[] in bytecode
println(boxed.javaClass.simpleName + " / " + primitive.javaClass.simpleName) // -> Integer[] / int[]
// Arrays are Java arrays: identity equals and an unhelpful toString.
println(primitive.toString().startsWith("[I@")) // -> true
println(primitive.contentToString()) // -> [120, 0, 35]
println(intArrayOf(1, 2) == intArrayOf(1, 2)) // -> false
println(intArrayOf(1, 2) contentEquals intArrayOf(1, 2)) // -> true
// The collection operations work on arrays too, and return Lists.
println(primitive.filter { it > 0 }) // -> [120, 35]
// Spread operator: pass an array where varargs are expected.
println(totalUnits(*primitive, 5)) // -> 160
// Sized constructor with an initializer.
println(IntArray(3) { it * 10 }.contentToString()) // -> [0, 10, 20]
}

The Kotlin twist is the naming: arrayOf(120, 0, 35) reads like the obvious choice and gives you Integer[], boxed. intArrayOf (and LongArray, ByteArray…) give the primitive arrays. Otherwise arrays are Java arrays, identity == included, with contentEquals and contentToString for what you meant. A vararg Int parameter arrives as an IntArray, and the spread operator * passes an existing array into one.

Strings and regex

fun main() {
// split takes a literal delimiter, not a regex as in Java.
println("SHW.1001.MH".split(".")) // -> [SHW, 1001, MH]
println("SHW.1001.MH".split(Regex("\\d+"))) // -> [SHW., .MH]
// And it keeps trailing empty strings, which Java's split drops.
println("dal,rice,,".split(",")) // -> [dal, rice, , ]
// trim() removes Unicode whitespace, such as the no-break spaces web forms like to send.
// Java's trim() only removes characters up to U+0020, and strip() doesn't count U+00A0.
val fromForm = "\u00A0Toor Dal\u00A0"
println(fromForm.trim().length) // -> 8
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") // deliberately calling Java's String.trim()
println((fromForm as java.lang.String).trim().length) // -> 10
// Small helpers that replace a utility class.
val code = "SHW-1001:MH"
println(code.substringBefore(":")) // -> SHW-1001
println("12a".toIntOrNull() ?: -1) // -> -1
// Regex: find, findAll, destructured groups.
val sku = Regex("""([A-Z]{3})-(\d{4})""")
val match = sku.find("label: SHW-1001, alt SHW-2040")
val (prefix, number) = match!!.destructured
println("$prefix / $number") // -> SHW / 1001
println(sku.findAll("SHW-1001 SHW-2040").map { it.value }.toList()) // -> [SHW-1001, SHW-2040]
println("SHW-1001" matches sku) // -> true
}

Kotlin’s String is java.lang.String with several hundred extension functions, so most of StringUtils is already there: substringBefore, padStart, removePrefix, toIntOrNull, lines, isBlank. Two of them share a name with a Java method and behave differently: split and trim (see the Gotchas). Regex wraps java.util.regex.Pattern. find returns a nullable MatchResult, findAll a lazy sequence, and destructured gives the groups as component1(), component2()….

kotlin.time and kotlin.uuid

import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
import kotlin.time.measureTimedValue
import kotlin.time.toJavaInstant
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
fun main() {
// Duration: a value class over a Long, with unit-safe arithmetic.
val shelfLife: Duration = 36.hours + 30.minutes
println(shelfLife) // -> 1d 12h 30m
println(shelfLife.inWholeMinutes) // -> 2190
println(250.milliseconds < 1.minutes) // -> true
// measureTimedValue: the result and how long it took, without System.nanoTime() bookkeeping.
val (total, took) = measureTimedValue { (1..1_000).sum() }
println("total=$total took=$took") // -> total=500500 took=…
// kotlin.time.Instant, convertible to java.time at framework boundaries.
val restock = Instant.parse("2026-10-03T06:00:00Z")
println(restock + 2.hours) // -> 2026-10-03T08:00:00Z
println(restock.toJavaInstant().javaClass.name) // -> java.time.Instant
// kotlin.uuid.Uuid (Stable since 2.4.0), convertible to java.util.UUID.
val id = Uuid.parse("550e8400-e29b-41d4-a716-446655440000")
println(id.toJavaUuid().version()) // -> 4
println(Uuid.random().toString().length) // -> 36
}

Duration is a value class over a Long (Part 4), so 36.hours + 30.minutes costs no allocation, and units can’t be mixed up the way long timeoutMs parameters can. measureTimedValue replaces the System.nanoTime() bookkeeping. kotlin.time.Instant and kotlin.uuid.Uuid are the multiplatform counterparts of java.time.Instant and java.util.UUID, with toJavaInstant() and toJavaUuid() (and the reverse) for the boundaries.

Status in 2.4.20: Duration, measureTime, Clock, Instant, Uuid.parse, Uuid.random() and the Java conversions are all Stable, with no opt-in. The newer Uuid.generateV4() and Uuid.generateV7() still need @OptIn(ExperimentalUuidApi::class).


Step-by-Step Hands-On: The Nightly Stock Report, Rewritten

Code: kotlin-for-java-survivors/language/part07-collections-without-collectors (file report/StockReport.kt).

The nightly job reads a shelf-reading export, one line per shelf per store, and produces a report per store: total units and the SKUs at or below their reorder point. The first version read the export into a List, then ran filter, map, groupBy and mapValues over it, holding several export-sized lists at once. Kabir rewrites it.

Step 1 — The input and output types.

// Fragment of report/StockReport.kt
// Step 1: one reading per shelf, and the report the nightly job produces per store.
data class ShelfReading(val store: String, val sku: String, val units: Int, val reorderAt: Int)
data class StoreReport(val store: String, val totalUnits: Int, val lowStock: List<String>)

Step 2 — Parse lazily. A Sequence<String> in, a Sequence<ShelfReading> out. Nothing runs yet. In production the lines come from File.useLines { }, which hands you the file as a sequence and closes it afterwards. List provides component1() to component5(), so the split line destructures (Part 4); a short line throws IndexOutOfBoundsException:

// Fragment of report/StockReport.kt
// Step 2: parse lazily. In production the input is a file with millions of lines (File.useLines);
// a Sequence keeps one line in flight instead of a List of all of them.
fun parse(lines: Sequence<String>): Sequence<ShelfReading> =
lines
.filter { it.isNotBlank() && !it.startsWith("#") }
.map { line ->
val (store, sku, units, reorderAt) = line.split(",").map { it.trim() }
ShelfReading(store, sku, units.toInt(), reorderAt.toInt())
}

Step 3 — Aggregate as the readings stream past. groupingBy on a sequence plus fold keeps one mutable Totals per store, not every reading. The initialValueSelector form matters (see the Gotchas): it creates an accumulator per key, like a Collector’s supplier.

// Fragment of report/StockReport.kt
// Step 3: aggregate per store as the readings stream past. groupingBy + fold keeps one
// mutable running total per store, not every reading.
private class Totals {
var units = 0
val lowStock = mutableListOf<String>()
}
fun report(readings: Sequence<ShelfReading>): List<StoreReport> =
readings
.groupingBy { it.store }
.fold(
initialValueSelector = { _, _ -> Totals() }, // one accumulator per store
operation = { _, totals, reading ->
totals.units += reading.units
if (reading.units <= reading.reorderAt) totals.lowStock += reading.sku
totals
},
)
.map { (store, totals) -> StoreReport(store, totals.units, totals.lowStock.sorted().readOnlyForJava()) }
.sortedBy { it.store }
.readOnlyForJava()

Step 4 — Hand Java a list it can’t change. readOnlyForJava() is a one-line extension (Part 6) over Collections.unmodifiableList, applied to every list the report exposes. Kotlin callers see a List, as before. Java callers get an UnsupportedOperationException instead of silently changing shared state. (The Assortment from the opening gets the other fix, a copy: fun skusSnapshot(): List<String> = skus.toList().)

// Fragment of report/StockReport.kt
// Step 4: a read-only list that Java can't modify either.
fun <T> List<T>.readOnlyForJava(): List<T> = Collections.unmodifiableList(this)

Step 5 — Run it.

// Fragment of report/StockReport.kt
// Step 5: run it, publish in batches, and let the Java utility try its luck.
fun main() {
val export = """
# store,sku,units,reorderAt
PUN-014,SHW-1001,4,10
PUN-014,SHW-2040,35,5
MUM-002,SHW-1001,60,10
PUN-014,SHW-3001,0,4
MUM-002,SHW-3002,2,6
BLR-007,SHW-5001,300,50
""".trimIndent()
val reports = report(parse(export.lineSequence()))
reports.forEach(::println)
// -> StoreReport(store=BLR-007, totalUnits=300, lowStock=[])
// -> StoreReport(store=MUM-002, totalUnits=62, lowStock=[SHW-3002])
// -> StoreReport(store=PUN-014, totalUnits=39, lowStock=[SHW-1001, SHW-3001])
// Two stores per message: batches like these suit a Kafka producer.
println(reports.chunked(2).map { batch -> batch.map { it.store } }) // -> [[BLR-007, MUM-002], [PUN-014]]
try {
LegacyReports.sortInPlace(reports.last().lowStock)
} catch (e: UnsupportedOperationException) {
println("Java can't sort it in place either") // -> Java can't sort it in place either
}
}

The pipeline now holds one line, one reading and one Totals per store, however large the export. The only collections it builds are the final, small per-store results. chunked(2) prepares the batches that Part 14 will publish to Kafka.


Tips, Tricks & Gotchas

Gotcha — split and trim aren’t Java’s. Java’s "SHW.1001.MH".split(".") returns an empty array, because . is a regex. Kotlin’s split(".") splits on the literal dot, and keeps trailing empty strings that Java drops ("dal,rice,," gives four elements, not two). Kotlin’s trim() removes Unicode whitespace such as the no-break space, which Java’s trim() and strip() leave in place. A ported CSV parser or form handler changes behaviour in all three cases.

Gotcha — remove(1) on a MutableList<Int> removes the value 1. Java’s List<Integer> has remove(int index) and remove(Object), and remove(1) picks the index. Kotlin names them removeAt(index) and remove(element), so ported code that removed by index now removes by value.

Gotcha — fold(initialValue) shares one accumulator across all keys. On a Grouping, the initial value is used as-is for every key. With a mutable accumulator, every store ends up with every SKU. Use fold(initialValueSelector = { _, _ -> Acc() }) { … }.

Gotcha — reversed() is a copy. On JDK 21+, List.reversed() is a live view of the original. Kotlin hides that JDK member on its own list types, so reversed() resolves to the stdlib extension and returns a snapshot: later additions don’t show up. Kotlin’s asReversed() is the live view.

All four, demonstrated:

class Shelf {
val skus = mutableListOf<String>()
}
fun main() {
// remove(1) on a MutableList<Int> removes the element 1. Java's remove(int) removes index 1.
val units = mutableListOf(5, 1, 7)
units.remove(1)
println(units) // -> [5, 7]
units.removeAt(1)
println(units) // -> [5]
// reversed() is a copy. Java 21's List.reversed() is a live view; Kotlin's extension wins.
val aisle = mutableListOf("A1", "A2")
val backwards = aisle.reversed()
aisle += "A3"
println(backwards) // -> [A2, A1]
// Grouping.fold(initialValue) shares ONE initial object between all keys.
val readings = listOf("PUN" to "SHW-1001", "MUM" to "SHW-2040", "PUN" to "SHW-3001")
val shared = readings.groupingBy { it.first }.fold(Shelf()) { shelf, (_, sku) -> shelf.apply { skus += sku } }
println(shared.mapValues { it.value.skus }) // -> {PUN=[SHW-1001, SHW-2040, SHW-3001], MUM=[SHW-1001, SHW-2040, SHW-3001]}
// initialValueSelector creates one accumulator per key, like a Collector's supplier.
val perStore = readings.groupingBy { it.first }.fold(
initialValueSelector = { _, _ -> Shelf() },
operation = { _, shelf, (_, sku) -> shelf.apply { skus += sku } },
)
println(perStore.mapValues { it.value.skus }) // -> {PUN=[SHW-1001, SHW-3001], MUM=[SHW-2040]}
}

Tip — keep associateBy for unique keys. Collectors.toMap throws on a duplicate key; associateBy silently keeps the last value (the relabelled SHW-3001 above). When duplicates are possible, groupBy and check for lists with more than one element.


Key Takeaways

ConceptRemember
Read-only interfacesList/MutableList both compile to java.util.List; read-only is a compile-time view, not immutability
Factory resultsMostly ordinary JDK collections; only buildList & co. are read-only at runtime
OperationsEager, inline, return new collections; no collect step
Java-named trapssplit (literal, keeps empties), trim (Unicode), remove(Int) (by value), reversed() (copy)
Grouping.foldUse initialValueSelector for mutable accumulators
SequencesLazy, element by element; for large data, long chains, early exit, infinite sources
StreamsStill usable; asSequence() / asStream() convert; Streams for parallelism
ArraysIntArray = int[], Array<Int> = Integer[]; contentEquals/contentToString
Time and IDsDuration, Instant, Uuid Stable; Uuid.generateV4/V7 need opt-in

Story Closing

The rewritten nightly job ran in a flat line on the GC dashboard. Assortment now handed out a copy, so the Java utility could sort it without touching Kabir’s list, and the report’s unmodifiable lists made one more in-place sort in the store-operations code fail loudly in their own test. They fixed it with one ArrayList copy on their side.

Kabir’s next task was to make the report reusable. The same pipeline should run over products, stores and suppliers, read from Shelfwise’s shared Java data-access library. He opened its main interface and found this:

public interface LegacyRepository<T extends Entity<ID>, ID extends Comparable<? super ID>> {
List<? extends T> findAll(Predicate<? super T> filter, Comparator<? super T> order);
}

He drew the type hierarchy on the whiteboard to work out which way each extends and super pointed. Lena watched him get halfway, then wrote out and in next to two of the arrows.

In Part 8, Kabir learns how Kotlin moves variance from every use of a type to its declaration, and why that removes most of the wildcards.


This is Part 7 of a 16-part series: “Kotlin for Java Survivors: Life After Semicolons.”