Libraries & Ecosystem · کتابخانهها و اکوسیستم متوسطIntermediate ~87 دقیقه مطالعه~76 min read
Kotlin برای برنامهنویس JavaKotlin for the Java Developer
از interop با Java و null-safety تا coroutines و Flow و Spring Boot: چطور یک برنامهنویس Java در چند روز به Kotlin مسلط شود، تصمیمهای سنیور بگیرد و یک codebase موجود را بدون توقف مهاجرت دهد.From Java interop and null safety to coroutines, Flow and Spring Boot: how a Java developer becomes productive in Kotlin within days, makes the senior-level trade-offs, and migrates an existing codebase without stopping the world.
پیشنیاز:Prerequisites: جاوا مدرن ۸ تا ۲۱: Record، Sealed و Pattern MatchingModern Java 8→21: Records, Sealed, Pattern Matching
آگهیهای شغلی backend امروز یک الگوی تکراری دارند: «Java یا Kotlin». این «یا» یعنی تیم انتظار ندارد سالها Kotlin نوشته باشی؛ انتظار دارد اگر فردا وارد repo شدی و دیدی نیمی از سرویسها .kt هستند، همان روز اول بتوانی کد بخوانی، bug درست کنی و در code review حرف حساب بزنی.
خبر خوب: Kotlin زبان جدیدی نیست که از صفر یاد بگیری. Kotlin همان JVM است با یک لایهٔ زبانی متفاوت — همان bytecode، همان GC، همان class loader، همان کتابخانهها، همان profiler و همان heap dump. هرچه در jvm-internals دربارهٔ JIT و حافظه یاد گرفتی بیکموکاست معتبر است.
تلهٔ اصلی این است که خیلیها Kotlin را «Java با syntax کوتاهتر» میبینند و شش ماه بعد کدی مینویسند که Kotlin بهنظر میرسد ولی Java فکر میکند: کلاسهای پر از var، !! در هر خط، entity های JPA بهشکل data class، و coroutine های رهاشده در GlobalScope. آنچه مصاحبهگر میسنجد همین است: آیا مدل ذهنی زبان را گرفتهای یا فقط syntax را.
این فصل زبان را «در تقابل با Java» میآموزد: برای هر مفهوم اول میگوییم در Java چه میکردی و چه دردی داشت، بعد پاسخ Kotlin را میسازیم.
۱. چرا Kotlin وجود دارد و نسبتش با JVM؛ کامپایلر K2 و نسخهها.
۲. راهاندازی: Gradle Kotlin DSL، plugin ها، پرچمهای کامپایلر و یک cheat-sheet نگاشت Java→Kotlin.
۳. Interop — تیتر اصلی: صدا زدن Java از Kotlin (platform type ها) و Kotlin از Java (@JvmStatic، @JvmOverloads، @JvmName و رفقا).
۴. زبان در تقابل با Java: val/var، استنتاج نوع، null safety در عمق، data class، sealed، smart cast، آرگومان پیشفرض و نامدار، extension، scope function ها، collection و Sequence، destructuring، operator و infix، delegation، object، typealias/value class، inline/reified، visibility.
۵. Coroutines درست: suspend، structured concurrency، scope و job، dispatcher ها، async/await، Flow.
۶. مقایسهٔ صادقانه با virtual thread های Java و با Reactor.
۷. Kotlin با Spring Boot: plugin های all-open و no-arg، تزریق سازنده، @ConfigurationProperties، کنترلر coroutine ای، MockK و Kotest.
۸. سبک اصطلاحی و اشتباهات کلاسیک برنامهنویس Java.
۹. استراتژی مهاجرت یک codebase موجود، و چطور در مصاحبه دربارهٔ Kotlin حرف بزنی وقتی زبان اصلیات Java است.
مبانی JVM در jvm-internals، generic ها در generics، collection ها در collections، سبک functional در functional و modern-java، virtual thread ها در virtual-threads و Reactor در reactive-reactor آمدهاند؛ اینجا فقط ارجاعشان میدهیم.
۱. چرا Kotlin وجود دارد و نسبتش با JVM چیست
یک جادهٔ آسفالت عالی داری (JVM): زیرساخت، پمپبنزین، تعمیرگاه، قوانین. ماشین اول (Java) قابلاعتماد است اما گیربکس دستی دارد. ماشین دوم (Kotlin) روی همان جاده و با همان سوخت میرود، اما گیربکس اتوماتیک و ترمز اضطراری دارد.
جاده عوض نشده. اگر جاده چاله دارد (GC pause، heap، thread ها)، هر دو ماشین همان چاله را حس میکنند. Kotlin مشکلات JVM را حل نمیکند؛ مشکلات نوشتن کد روی JVM را حل میکند.
Bytecode: خروجی کامپایلر — زبان میانی که JVM اجرا میکند. kotlinc دقیقاً همان فرمت .class را میسازد که javac میسازد؛ به همین دلیل یک ماژول میتواند همزمان .java و .kt داشته باشد.
K2: نام کامپایلر جدید Kotlin. از Kotlin 2.0 پیشفرض و Stable است برای همهٔ پلتفرمها (JVM، Native، JS، Wasm)، هم سریعتر و هم با استنتاج نوع و smart cast دقیقتر. اگر جایی پرچمهای -Xuse-k2 دیدی، مربوط به دوران قبل از 2.0 است.
نسخهها: خط 2.x جاری است — 2.2 (پایهٔ Spring Boot 4)، سپس 2.3 و 2.4. سیاست سازگاری باینری سختگیرانه است: کدی که با 2.0 کامپایل شده روی runtime نسخههای بعدی کار میکند.
Kotlin/JVM در برابر Multiplatform: Kotlin به JVM، کد ماشین (Native)، JavaScript و WebAssembly کامپایل میشود. برای تیم backend فقط Kotlin/JVM مهم است؛ بقیه را در مصاحبه فقط باید بشناسی.
نمودار: مسیر کامپایل Java و Kotlin به یک bytecode مشترک · Compilation path of Java and Kotlin into one shared bytecode.
flowchart LR
A[".java sources"] --> B["javac"]
C[".kt sources"] --> D["Kotlin K2 compiler"]
B --> E[".class bytecode"]
D --> E
E --> F["JAR / fat JAR"]
F --> G["JVM: classloader, JIT, GC"]
H["kotlin-stdlib.jar"] --> F
kotlin-stdlib فقط یک وابستگی maven عادی است. String.trimIndent()، List.map، Sequence، Result و Pair از آنجا میآیند. Gradle plugin رسمی خودش اضافهاش میکند؛ برای مدیریت دستی kotlin.stdlib.default.dependency=false را در gradle.properties بگذار.
Kotlin چه دردی را درمان کرد؟ فهرست واقعی، نه بازاریابی: (۱) NullPointerException — nullability بخشی از سیستم نوع شد؛ (۲) boilerplate — getter/setter/equals/hashCode/toString که Java تا record ها پاسخی نداشت و Lombok را وصله میکرد (lombok)؛ (۳) سازندهٔ تلسکوپی و Builder — با آرگومان پیشفرض و نامدار؛ (۴) کلاسهای Utils — با extension function؛ (۵) همزمانی خوانا — coroutine ها، سالها قبل از virtual thread ها.
پاسخ کوتاه: هیچ چیز در سطح runtime. Kotlin به همان bytecode ای کامپایل میشود که javac تولید میکند و روی همان JVM اجرا میشود؛ همان GC، همان JIT، همان مدل حافظه (sync-locks-jmm) و همان profiling tool ها.
آنچه اضافه میشود در سطح کامپایلر و کتابخانه است: nullability بهعنوان بخشی از type system، تولید خودکار equals/hashCode/copy برای data class، بررسی exhaustive بودن when روی سلسلهمراتب sealed، و coroutine ها که یک تبدیل CPS در کامپایلر بهعلاوهٔ کتابخانهٔ kotlinx-coroutines اند.
جملهٔ بستن: «Kotlin مشکلات JVM را حل نمیکند، مشکلات نوشتنِ کد روی JVM را حل میکند. اگر مشکل من GC pause است، Kotlin کمکی نمیکند؛ اگر مشکل من NPE در production و ۴۰۰ خط DTO است، بله.»
۲. راهاندازی پروژه
فایل build خودش هم Kotlin است (build.gradle.kts) — همان «Kotlin DSL» که در آگهیها میبینی. مزیتش type-safety و auto-completion است. جزئیات Gradle در build-tools.
// build.gradle.kts
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
kotlin("jvm") version "2.2.20"
kotlin("plugin.spring") version "2.2.20" // all-open برای کلاسهای Spring
kotlin("plugin.jpa") version "2.2.20" // no-arg برای @Entity
id("org.springframework.boot") version "3.5.6"
id("io.spring.dependency-management") version "1.1.7"
}
dependencies {
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
testImplementation("io.mockk:mockk:1.14.11")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}
kotlin {
jvmToolchain(21)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll(
"-Xjsr305=strict", // annotation های JSR-305 سختگیرانه
"-Xannotation-default-target=param-property"
)
}
}
kotlin("jvm") میانبر plugin id واقعی org.jetbrains.kotlin.jvm است؛ kotlin("plugin.spring") معادل org.jetbrains.kotlin.plugin.spring.
اگر تیم روی Maven است، kotlin-maven-plugin را با اجرای compile و test-compile اضافه میکنی و compilerPlugins را با <plugin>spring</plugin> و <plugin>jpa</plugin> پر میکنی (بههمراه وابستگیهای kotlin-maven-allopen و kotlin-maven-noarg).
ترتیب کامپایل. کامپایلر Kotlin کد Java را میخواند (تا نوعها را بفهمد) اما کامپایل نمیکند. ترتیب درست: اول kotlinc روی کل source set، بعد javac روی فایلهای Java. Gradle plugin این را خودکار انجام میدهد؛ در Maven باید execution مربوط به Kotlin قبل از maven-compiler-plugin در pom.xml بیاید، وگرنه خطاهای گیجکنندهٔ «cannot find symbol» میگیری.
-Xannotation-default-target. از Kotlin 2.2 قوانین پیشفرضِ «annotation روی پارامتر سازنده به کجا میچسبد» عوض شده. در Java یک annotation روی فیلد مینشیند و تمام؛ در Kotlin یک val در سازنده همزمان پارامتر، property، فیلد و getter است. بدون این پرچم، annotation های validation میتوانند به target اشتباه بروند و بیصدا کار نکنند. مقدار param-property رفتار جدید و توصیهٔ رسمی Spring Boot است؛ first-only رفتار قدیمی را برمیگرداند.
Cheat-sheet: نگاشت مستقیم Java → Kotlin
| Java | Kotlin | نکته |
|---|---|---|
final String s = "a"; |
val s = "a" |
نوع استنتاج میشود |
String s = null; |
var s: String? = null |
? بخشی از نوع است |
public class Foo {} |
class Foo |
public پیشفرض است |
کلاس غیر final |
open class Foo |
کلاسها پیشفرض final اند |
static method |
تابع سطح فایل یا companion object |
static وجود ندارد |
record Point(int x, int y) |
data class Point(val x: Int, val y: Int) |
data class انعطاف بیشتری دارد |
sealed interface (Java 17) |
sealed interface |
exhaustive check قویتر |
instanceof X x |
is X + smart cast |
cast خودکار |
switch روی sealed |
when |
expression است، مقدار برمیگرداند |
Optional<T> |
T? |
در Kotlin تقریباً هرگز Optional نمینویسی |
List.of(1,2) |
listOf(1, 2) |
فقطخواندنی |
new ArrayList<>() |
mutableListOf() |
new وجود ندارد |
str.equals(other) |
str == other |
== معادل equals و null-safe است |
str == other (مرجع) |
str === other |
برابری مرجع |
String.format("%s", x) |
"$x" |
string template |
Stream.of(..).map(..) |
.asSequence().map { } |
lazy |
try/catch برای checked |
چیزی لازم نیست | همه چیز unchecked است |
Foo.class |
Foo::class.java |
KClass در برابر Class |
۳. Interop: تیتر اصلی داستان
اگر فقط یک بخش از این فصل را باید بلد باشی، همین است. تیمها Kotlin را در یک codebase خالی شروع نمیکنند؛ آن را کنار ۲۰۰ هزار خط Java میگذارند.
۳.۱ صدا زدن Java از Kotlin
مرزبانی هستی که دو نوع مسافر میشناسی: «قطعاً شهروند» و «قطعاً خارجی». حالا مسافری میرسد که پاسپورتش چیزی دربارهٔ ملیت نمیگوید. یا همه را خارجی فرض میکنی (آنگاه هر کد Java باید پر از ? و ?. شود و عملاً غیرقابلاستفاده میشود)، یا اعتماد میکنی و مسئولیت را به مسافر میسپاری. Kotlin راه دوم را انتخاب کرد و اسمش را گذاشت platform type.
Platform type نوعی است که Kotlin به هر مقدار برگشتی از Java میدهد وقتی هیچ annotation ای دربارهٔ nullability نیست. در پیامهای خطا با علامت تعجب دیده میشود: String! یعنی «یا String است یا String? — نمیدانم». نمیتوانی دستی بنویسیاش؛ فقط کامپایلر تولیدش میکند.
// Java
public class LegacyRepo {
public String findName(long id) { return null; }
}
val repo = LegacyRepo()
val a = repo.findName(1) // نوع: String! — کامپایلر اجازه میدهد
val b: String = repo.findName(1) // کامپایل میشود، ولی در runtime NPE میدهد
val c: String? = repo.findName(1) // امن: خودت گفتی ممکن است null باشد
println(a.length) // بدون هشدار، ولی NPE
println(c?.length) // امن
خیلی از تیمها فکر میکنند «Kotlin نوشتیم پس NPE نداریم». نه — هر مرزی با Java، با کتابخانههای قدیمی، با deserialization و با reflection یک درِ باز است.
قانون تیمی درست: هر مقدار برگشتی از Java را در همان خطِ ورود به یک نوع صریح نسبت بده (val x: Foo? یا val x: Foo). آنوقت اگر null بیاید، NPE در مرز رخ میدهد نه بیست فریم پایینتر جایی که هیچ سرنخی نداری.
annotation های nullability: اگر کد Java علامتگذاری شده باشد، Kotlin جدی میگیرد و دیگر platform type نمیسازد. مجموعهٔ شناختهشده گسترده است: org.jspecify.annotations (استاندارد جدید و توصیهشده)، org.jetbrains.annotations، JSR-305 (javax.annotation)، annotation های Android، Eclipse، lombok.NonNull و RxJava 3.
-Xjspecify-annotations=strict # پیشفرض strict است: نقضها خطا میشوند
-Xjsr305=strict # پیشفرض warn است؛ در پروژهٔ جدی strict بگذار
-Xnullability-annotations=@com.acme.annots:strict
از Kotlin 2.1، annotation های JSpecify پیشفرض strict اند. Spring Framework 7 و Spring Boot 4 کل API خود را با JSpecify علامتگذاری کردهاند؛ نتیجه اینکه در یک پروژهٔ Spring Boot 4 عملاً دیگر platform type از سمت Spring، Reactor یا Micrometer نمیبینی — تفاوت کیفی بزرگی نسبت به نسل قبل.
اگر کتابخانهٔ داخلی خودت را داری، یک @NullMarked روی package-info.java بگذار: از آن لحظه همه چیز non-null فرض میشود مگر خلافش گفته شود. ارزانترین ارتقای کیفیت interop که میتوانی بدهی.
نوعهای نگاشتشده: java.lang.String میشود kotlin.String، int میشود Int، java.lang.Integer میشود Int?، java.lang.Object میشود Any!. در bytecode هیچ کلاس اضافهای ساخته نمیشود.
collection ها: Kotlin دو اینترفیس دارد — List<T> (فقطخواندنی) و MutableList<T>. java.util.List<T> به (Mutable)List<T>! نگاشت میشود. اما دقت کن: «فقطخواندنی» با «تغییرناپذیر» یکی نیست. اگر یک ArrayList از Java بگیری و بهعنوان List<T> نگه داری، کد Java همچنان میتواند همان شیء را تغییر دهد. برای تضمین واقعی یا toList() بگیر یا از kotlinx.collections.immutable استفاده کن.
آرایهها: در Java آرایهها covariant اند (Object[] o = new String[1]; که در runtime میترکد). در Kotlin Array<T> invariant است، و برای اولیهها نوعهای اختصاصی داری که بدون boxing به int[], long[], double[] کامپایل میشوند:
val ids: IntArray = intArrayOf(1, 2, 3) // int[] در bytecode
val names: Array<String> = arrayOf("a") // String[]
// val objs: Array<Any> = names // خطا: invariant است
exception های checked: Kotlin هیچ exception ای را checked نمیداند. Files.readAllBytes(...) را بدون try/catch صدا بزن و کامپایلر شکایتی ندارد. اما این یعنی مسئولیت بیشتر، نه کمتر: در Java کامپایلر مجبورت میکرد به IOException فکر کنی. راهکار سنیور: مرزهای خطا را با KDoc و تگ @throws مستند کن و برای منطق دامنه از sealed result یا Result<T> استفاده کن بهجای exception (exceptions).
SAM conversion: هر اینترفیس Java با یک متد انتزاعی را با lambda پر میکنی: executor.execute { println("in pool") }.
generic ها: Foo<? extends Bar> میشود Foo<out Bar!>!، Foo<? super Bar> میشود Foo<in Bar!>!، و نوع خام List میشود List<*>!. جزئیات variance در generics.
۳.۲ صدا زدن Kotlin از Java
اینجا باید آگاهانه طراحی کنی، وگرنه API ات از سمت Java زشت میشود.
تابعهای سطح فایل: تابعی بیرون هر کلاسی در StringUtils.kt در bytecode به یک متد static روی کلاس StringUtilsKt تبدیل میشود.
// file: StringUtils.kt
@file:JvmName("StringUtils") // بدون این، اسم کلاس StringUtilsKt میشود
package com.acme.text
fun slugify(input: String): String = input.lowercase().replace(' ', '-')
String s = com.acme.text.StringUtils.slugify("Hello World");
اگر بخواهی چند فایل به یک facade واحد برسند، @file:JvmMultifileClass را هم اضافه کن.
object و companion object: object Foo از Java با Foo.INSTANCE دیده میشود؛ companion object با Foo.Companion.
class TokenService {
companion object {
const val HEADER = "Authorization" // static final در bytecode
@JvmStatic
fun parse(raw: String): String = raw.removePrefix("Bearer ")
@JvmField
val DEFAULT_TTL: Duration = Duration.ofMinutes(15)
}
}
String t = TokenService.parse("Bearer abc"); // بهخاطر @JvmStatic
String h = TokenService.HEADER; // const خودش static final است
Duration d = TokenService.DEFAULT_TTL; // بهخاطر @JvmField
@JvmOverloads: آرگومان پیشفرض یک ویژگی زبانی است؛ در bytecode فقط یک متد با پارامتر bitmask تولید میشود و Java آن را نمیفهمد. @JvmOverloads میگوید «برای هر ترکیب یک overload واقعی هم بساز».
class HttpClient @JvmOverloads constructor(
private val baseUrl: String,
private val timeout: Duration = Duration.ofSeconds(5),
private val retries: Int = 3,
) {
@JvmOverloads
fun get(path: String, headers: Map<String, String> = emptyMap()): String = TODO()
}
@JvmName: رفع تداخل امضا بعد از erasure و زیباسازی نام — روی تابع، و با @get:/@set: روی property:
fun List<String>.parseAll(): List<Int> = map { it.toInt() }
@JvmName("parseAllInts")
fun List<Int>.parseAll(): List<Int> = this
@get:JvmName("url")
@set:JvmName("setUrl")
var baseUrl: String = ""
@Throws: چون Kotlin checked exception ندارد، امضا در bytecode بخش throws ندارد و کد Java نمیتواند catch کند (کامپایلر Java میگوید «exception هرگز پرتاب نمیشود»).
@Throws(IOException::class)
fun writeReport(path: Path) { Files.writeString(path, "x") }
نامگذاری getter/setter: var firstName: String میشود getFirstName()/setFirstName(). اما property ای که با is شروع شود قانون خاصی دارد: val isOpen: Boolean میشود isOpen() — نه getIsOpen().
internal و mangling: internal یعنی «فقط داخل همین ماژول کامپایل». چون JVM چنین سطحی ندارد، در bytecode public میشود ولی نامش mangle میشود (مثلاً doWork$app_main). یعنی از Java عملاً قابل صدا زدن است. internal یک ابزار طراحی API است، نه مرز امنیتی؛ برای مرز سخت از JPMS یا جداسازی ماژولهای build استفاده کن.
متدهای پیشفرض اینترفیس: از Kotlin 2.2، تابعهای دارای بدنه در اینترفیس پیشفرض به Java default method کامپایل میشوند. با گزینهٔ -jvm-default کنترلش میکنی: enable (پیشفرض: متد پیشفرض + کلاس سازگاری DefaultImpls)، no-compatibility (سبکتر) و disable (فقط DefaultImpls).
@JvmSuppressWildcards / @JvmWildcard: کنترل دستی wildcard های تولیدشده در امضای generic — وقتی از Java یک Box<Base> میخواهی ولی Kotlin Box<? extends Base> ساخته.
نمودار: مرز دو زبان و آنچه در هر جهت باید مراقبش باشی · The two-language boundary and what to watch in each direction.
flowchart TD
subgraph K["Kotlin side"]
K1["null-safe types"]
K2["default args"]
K3["top-level funs"]
K4["companion object"]
end
subgraph J["Java side"]
J1["platform types T!"]
J2["@JvmOverloads needed"]
J3["FileNameKt / @JvmName"]
J4["Foo.Companion / @JvmStatic"]
end
K1 -. "annotate with JSpecify" .-> J1
K2 --> J2
K3 --> J3
K4 --> J4
پنج کار مشخص، و بهتر است دقیقاً همینها را بشماری:
۱. روی سازندهها و متدهای دارای پارامتر پیشفرض، @JvmOverloads — وگرنه Java فقط امضای کامل را میبیند.
۲. برای عضوهای companion object که باید static بهنظر برسند، @JvmStatic (متدها) و @JvmField/const (ثابتها).
۳. روی فایلهای حاوی تابع سطح بالا، @file:JvmName("...") تا اسم ...Kt بیرون نزند.
۴. هرجا exception بخشی از قرارداد است، @Throws تا Java بتواند catch کند.
۵. برای collection های generic که Java باید بنویسد داخلشان، @JvmSuppressWildcards را بررسی کن.
و مهمتر از همه: یک تست Java در همان build بنویس که API را واقعاً از Java صدا بزند. این تنها راه مطمئن است؛ صرفاً «فکر کردن» به ergonomics کافی نیست.
۴. زبان، در تقابل با Java
۴.۱ val در برابر var
در Java برای همین رفتار باید final تایپ کنی و کسی نمیکند. Kotlin معادله را برعکس کرد: val کوتاهتر از var است.
val name = "ali" // final String name = "ali";
var counter = 0 // int counter = 0;
val items = mutableListOf(1, 2)
items.add(3) // مجاز — val یعنی مرجع دوباره نسبت داده نمیشود
// items = mutableListOf() // خطا
قانون سادهٔ code review: اگر در یک PR بیش از دو var در سطح کلاس دیدی، سؤال بپرس. var در سطح متد طبیعی است؛ var در سطح کلاس یعنی state قابل تغییر که در محیط چندنخی باید توضیح داده شود (sync-locks-jmm). در Kotlin نوشتن کد immutable آنقدر ارزان است که هر var باید دلیل داشته باشد.
۴.۲ استنتاج نوع
val n = 42 // Int
val list = listOf("a", "b") // List<String>
fun double(x: Int) = x * 2 // بازگشتی Int، بدنهٔ expression
تله: fun findUser(id: Long) = repo.load(id) نوع بازگشتی را از پیادهسازی میگیرد. اگر فردا repo.load عوض شود، امضای عمومی تو بیسروصدا عوض میشود و مصرفکنندهها در زمان لینک میشکنند. قانون: در هر چیزی که public است نوع بازگشتی را صریح بنویس. در ماژولهای کتابخانهای این را با explicitApi() در بلوک kotlin { } اجباری کن.
۴.۳ Null safety در عمق
جعبهٔ نوع اول (String) طبق قرارداد انبار هرگز خالی نیست. جعبهٔ نوع دوم (String?) روی برچسبش نوشته «ممکن است خالی باشد» و انباردار (کامپایلر) اجازه نمیدهد بدون بررسی بازش کنی. کل null safety همین است: nullability از زمان اجرا به سیستم نوع منتقل شد.
var a: String = "abc"
// a = null // خطای کامپایل
var b: String? = "abc"
b = null // مجاز
// println(b.length) // خطای کامپایل
ابزارها، بهترتیبی که باید ترجیح دهی:
val len1 = b?.length // safe call → Int?
val len2 = b?.length ?: 0 // elvis: پیشفرض
val len3 = b?.length ?: return // elvis + خروج زودهنگام
val len4 = b?.length ?: error("no value") // elvis + پرتاب
val len5 = b!!.length // not-null assertion — بوی بد
val city: String? = order?.customer?.address?.city // زنجیرهٔ امن
user.email?.let { mailer.send(it, subject = "welcome") }
val n = value as? Int ?: 0 // cast امن بهجای ClassCastException
lateinit برای property هایی است که در سازنده مقداردهی نمیشوند (تزریق فیلد، setup تست) اما non-null اند: @Autowired lateinit var repo: OrderRepository. فقط برای var، فقط نوعهای غیر اولیه، بدون custom accessor. با ::repo.isInitialized قابل بررسی است.
خواندن lateinit قبل از مقداردهی UninitializedPropertyAccessException میدهد — همان NPE با اسم بهتر.
و !!: هر !! یک ادعای اثباتنشده است که کامپایلر به یک throw تبدیلش میکند. در ۹۵٪ موارد یکی از اینها بهتر است: ?: با پیشفرض، requireNotNull(x) { "پیام واضح" }، checkNotNull(x)، یا بازطراحی نوع طوری که اصلاً nullable نباشد. در code review هر !! باید کامنت توجیهی داشته باشد یا حذف شود.
نمودار: درخت تصمیم برای برخورد با یک مقدار nullable · Decision tree for handling a nullable value.
flowchart TD
A["value: T?"] --> B{"Can I give a sane default?"}
B -- yes --> C["v ?: default"]
B -- no --> D{"Is null a valid business case?"}
D -- yes --> E["v?.let { ... } / early return"]
D -- no --> F{"Is it a programming bug?"}
F -- yes --> G["requireNotNull / checkNotNull with message"]
F -- no --> H["Redesign the type: make it non-null"]
Kotlin nullability را وارد type system کرده: String و String? دو نوع متفاوتاند و کامپایلر dereference روی نوع nullable را بدون بررسی نمیپذیرد. این یک کلاس کامل از خطاها را در زمان کامپایل میگیرد.
اما NPE هنوز در پنج جا ممکن است: (۱) !! صریح؛ (۲) lateinit خواندهشده و مقداردهینشده؛ (۳) platform type ها از کد Java بدون annotation؛ (۴) reflection و deserialization که سازنده را دور میزنند یا null را در فیلد non-null مینشانند — مثلاً Jackson بدون ماژول Kotlin؛ (۵) نشت this از سازنده به یک متد open.
جملهٔ سنیوری: «Kotlin NPE را حذف نمیکند، آن را به مرزها میراند. کار من صریحکردن آن مرزهاست — JSpecify روی کد Java، ماژول Kotlin برای Jackson، و ممنوعیت !! در code review.»
۴.۴ data class در برابر record
data class Money(val amount: BigDecimal, val currency: String)
val a = Money(BigDecimal("10.00"), "IRR")
val b = a.copy(currency = "USD") // فقط یک فیلد را عوض میکنی
val (amount, cur) = a // destructuring
کامپایلر equals()، hashCode()، toString()، copy() و componentN() را میسازد — بر اساس property های سازندهٔ اصلی.
| ویژگی | Java record |
Kotlin data class |
|---|---|---|
| هدف | نگهدارندهٔ دادهٔ شفاف و تغییرناپذیر | کاهش boilerplate برای نگهدارندهٔ داده |
| تغییرپذیری | همیشه تغییرناپذیر | val یا var — میتواند تغییرپذیر باشد |
| ارثبری | نمیتواند از کلاس ارث ببرد؛ خودش final |
میتواند اینترفیس پیاده کند؛ خودش final |
| کپی جزئی | ندارد | copy(...) داخلی |
| فیلد خارج از سازنده | ندارد | دارد — ولی در equals نمیآید |
| interop | نوع بومی JVM | کلاس معمولی؛ با @JvmRecord میتواند record واقعی شود |
۱. property های بیرون سازنده در equals نیستند.
data class User(val id: Long) {
var email: String = "" // در equals/hashCode/toString نمیآید!
}
User(1).apply { email = "a@x" } == User(1).apply { email = "b@x" } // true
مستند و عمدی است، اما در HashMap و Set غافلگیرکننده. هر چیزی که بخشی از هویت است باید در سازندهٔ اصلی باشد (equals-hashcode).
۲. هرگز entity های JPA را data class نکن. شایعترین اشتباه Kotlin در پروژههای Spring. equals/hashCode تولیدشده همهٔ فیلدها را میخواند و روی association های lazy باعث initialize ناخواسته و LazyInitializationException یا کوئری پنهان میشود؛ hashCode مبتنی بر همهٔ فیلدها با id ای که بعد از persist عوض میشود ناسازگار است؛ و toString() کل گراف را چاپ میکند و در لاگ یک N+1 تمامعیار میسازد. برای entity ها class معمولی با var بنویس و equals/hashCode را دستی بر پایهٔ کلید تجاری پیاده کن (jpa-hibernate).
۴.۵ sealed class و interface
sealed یعنی «مجموعهٔ زیرنوعهای من در زمان کامپایل بسته است».
sealed interface PaymentResult {
data class Approved(val txId: String) : PaymentResult
data class Declined(val reason: String, val retryable: Boolean) : PaymentResult
data object Timeout : PaymentResult // singleton، از Kotlin 1.9
}
fun message(r: PaymentResult): String = when (r) {
is PaymentResult.Approved -> "ok ${r.txId}"
is PaymentResult.Declined -> if (r.retryable) "retry: ${r.reason}" else "failed"
PaymentResult.Timeout -> "timed out"
// هیچ else ای لازم نیست — کامپایلر میداند همهٔ حالتها پوشش داده شده
}
exhaustive بودن یعنی فردا که data object Fraud : PaymentResult را اضافه کنی، هر when ای که مقدار برمیگرداند کامپایل نمیشود تا آن حالت را هم بنویسی. بهترین شبکهٔ ایمنی برای مدلسازی دامنه (ddd).
guard condition (پایدار از Kotlin 2.2) یک شرط اضافه به شاخه میدهد:
fun describe(r: PaymentResult): String = when (r) {
is PaymentResult.Declined if r.retryable -> "transient decline"
is PaymentResult.Declined -> "hard decline"
is PaymentResult.Approved -> "approved"
PaymentResult.Timeout -> "timeout"
}
when یک expression است — تفاوت اصلیاش با switch همین است. و نکتهٔ ظریف: فقط وقتی when مقدار برمیگرداند exhaustive بودن اجباری است. اگر آن را بهشکل statement بنویسی، کامپایلر (بسته به نسخه) فقط هشدار میدهد. پس اگر شبکهٔ ایمنی میخواهی، همیشه از when مقدار برگردان — حتی اگر Unit باشد. ترفند رایج: val ignored: Unit = when (r) { ... }.
enum یک مجموعهٔ ثابت از نمونههای تکی است؛ هر مقدار یک singleton با همان شکل داده. sealed یک مجموعهٔ ثابت از زیرنوعها است؛ هر زیرنوع میتواند شکل و دادهٔ متفاوتی داشته باشد و چندین نمونه بگیرد.
مثال کاربردی: وضعیت سفارش (PENDING, SHIPPED, CANCELLED) با enum بهتر است — همه یک شکلاند و در دیتابیس ذخیره میشوند. نتیجهٔ یک عملیات (Success(data), Failure(error), Timeout) با sealed بهتر است — هر حالت دادههای متفاوتی حمل میکند.
هر دو با when قابل بررسی exhaustive اند. data object پل بین این دوست: زیرنوع sealed با تنها یک نمونه و toString/equals معقول.
۴.۶ smart cast
fun length(x: Any): Int {
if (x is String) return x.length // x اینجا String است، بدون cast
if (x !is Collection<*>) return 0
return x.size // smart cast بعد از نفی
}
محدودیت: اگر property یک var در سطح کلاس باشد (یا open یا با custom getter)، کامپایلر نمیتواند تضمین کند بین بررسی و استفاده تغییر نکرده، پس خطای «smart cast is impossible» میدهد. راهحل استاندارد: مقدار را در یک val محلی بگیر.
class Box(var value: String?) {
fun show() {
val v = value ?: return // کپی محلی
println(v.length) // حالا کار میکند
}
}
در K2 دامنهٔ smart cast محسوس گستردهتر شده — متغیرهای گرفتهشده در lambda و شرطهای || ترکیبی حالا درست کار میکنند.
۴.۷ آرگومان پیشفرض و نامدار — پایان Builder ها
در Java برای تابعی با ۵ پارامتر اختیاری یا ۳۲ overload مینویسی یا یک Builder صد خطی.
fun createOrder(
customerId: Long,
currency: String = "IRR",
discount: BigDecimal = BigDecimal.ZERO,
notifyCustomer: Boolean = true,
idempotencyKey: String? = null,
): Order = TODO()
createOrder(42)
createOrder(42, discount = BigDecimal("10"))
createOrder(customerId = 42, notifyCustomer = false, idempotencyKey = "k-1")
آرگومان نامدار خوانایی را در نقطهٔ فراخوانی درست میکند — مشکلی که در Java با doThing(true, false, true) داشتی.
قانون سنیور: هر Boolean در امضای عمومی باید نامدار صدا زده شود — خیلی از تیمها این را با detekt اجباری میکنند. حتی بهتر: بهجای Boolean یک enum دوحالته بگذار (Notify.YES/Notify.NO). همان الگوی «حذف پارامتر کنترلی» در solid-patterns، فقط اینجا زبان کمکت میکند.
۴.۸ extension function و extension property
مسئلهٔ Java: کلاس StringUtils با متدهای static و فراخوانی StringUtils.isBlank(s) بهجای s.isBlank().
fun String.toSlug(): String =
trim().lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-')
val String.isValidEmail: Boolean
get() = matches(Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"))
"Hello World!".toSlug() // "hello-world"
extension در bytecode یک متد static است که گیرنده را بهعنوان پارامتر اول میگیرد؛ هیچ چیزی به کلاس اصلی اضافه نمیشود.
open class Base
class Derived : Base()
fun Base.name() = "base"
fun Derived.name() = "derived"
val x: Base = Derived()
println(x.name()) // "base" — نه "derived"!
نوع اعلامی متغیر تعیین میکند کدام تابع صدا زده شود، نه نوع واقعی در runtime. و اگر extension با یک متد عضو همامضا باشد، همیشه متد عضو برنده است. این دو قانون منبع اصلی سردرگمی برنامهنویس Java اند.
تلهٔ سازمانی هم دارد: تیمها فایل Extensions.kt میسازند و ۲۰۰ تابع بیربط داخلش میریزند — همان Utils قدیمی با لباس نو. extension بنویس وقتی کلاس مقصد را نمیتوانی تغییر دهی یا عملیات به دامنهٔ تو تعلق دارد نه به آن کلاس؛ اگر کلاس مال خودت است و متد بخشی از رفتار اصلی است، متد عضو بنویس.
۴.۹ scope function ها
پنج تابع inline در stdlib که همه یک کار میکنند — اجرای بلوکی در context یک شیء — و فقط در دو محور فرق دارند: شیء چطور در دسترس است و چه چیزی برمیگردانند.
| تابع | ارجاع به شیء | مقدار بازگشتی | بهترین کاربرد |
|---|---|---|---|
let |
it |
نتیجهٔ lambda | اجرای امن روی nullable، تبدیل مقدار |
run |
this |
نتیجهٔ lambda | محاسبهٔ یک مقدار با چند دسترسی به شیء |
with |
this |
نتیجهٔ lambda | مثل run ولی شیء آرگومان است نه گیرنده |
apply |
this |
خود شیء | پیکربندی شیء (سبک builder) |
also |
it |
خود شیء | کار جانبی: لاگ، اعتبارسنجی، ثبت |
val len: Int = maybeName?.let { it.trim().length } ?: 0
val props = Properties().apply {
setProperty("bootstrap.servers", "kafka:9092")
setProperty("acks", "all")
}
val order = repo.save(newOrder).also { log.info("saved order {}", it.id) }
val url = config.run { "$scheme://$host:$port/$path" }
تله: زنجیرههای تودرتو. وقتی سه let تودرتو داری و it بیرونی توسط it درونی سایهگذاری شده، هیچکس نمیفهمد کدام کدام است. دو قانون: در هر lambda تودرتو به it نام بده (?.let { user -> ... })، و بیش از دو سطح نرو — یک تابع خصوصی با اسم واضح استخراج کن.
۴.۱۰ رشتهها
val greet = "Hello, $name! len=${name.length}"
val json = """
{
"id": $id,
"type": "order"
}
""".trimIndent()
val path = """C:\Users\app""" // بدون escape
$ واقعی میخواهی؟ ${'$'} یا multi-dollar (پایدار از Kotlin 2.2): $$"cost is $price" که در آن $price تحتاللفظی میماند و برای درج باید $$ بنویسی.
۴.۱۱ collection ها و Sequence
val nums = listOf(1, 2, 3, 4, 5)
val evens = nums.filter { it % 2 == 0 }
val grouped = nums.groupBy { it % 3 } // Map<Int, List<Int>>
val assoc = nums.associateBy { it.toString() } // Map<String, Int>
val first = nums.firstOrNull { it > 3 } // Int?
val (small, big) = nums.partition { it < 3 }
val windows = nums.windowed(size = 2, step = 1)
val chunks = nums.chunked(2)
تفاوت کلیدی با Java Stream: عملیات collection در Kotlin eager است. nums.filter{}.map{} دو لیست میانی میسازد. معادل lazy، Sequence است:
val result = hugeList.asSequence()
.filter { it.isActive }
.map { it.toDto() }
.take(10)
.toList() // اینجا واقعاً اجرا میشود
| موضوع | Kotlin collection | Kotlin Sequence |
Java Stream |
|---|---|---|---|
| ارزیابی | eager | lazy | lazy |
| مجموعهٔ میانی | بله، در هر مرحله | خیر | خیر |
| موازیسازی | ندارد | ندارد | parallelStream() |
| بهترین کاربرد | مجموعههای کوچک/متوسط | زنجیرههای بلند، دادههای بزرگ، منابع نامتناهی | interop با Java، موازیسازی |
شهود اشتباه رایج: «lazy همیشه بهتر است». برای لیستی با ۲۰ عضو، سربار iterator های زنجیرهای بیشتر از هزینهٔ ساخت دو لیست کوچک است. قانون عملی: زیر ~۱۰۰۰ عضو و زیر ~۳ عملیات، مستقیم روی collection کار کن؛ برای زنجیرههای بلند، دادههای بزرگ، یا وقتی first()/take(n) داری و میخواهی زود متوقف شوی، asSequence() بگذار. و اگر موازیسازی CPU-bound میخواهی، parallelStream() یا coroutine — Sequence موازی نیست.
۴.۱۲ destructuring
val (x, y) = Point(1, 2)
for ((key, value) in mapOf("a" to 1)) { println("$key=$value") }
val (name, age) = "ali" to 30 // Pair
بر پایهٔ تابعهای component1(), component2() … کار میکند که data class خودکار میسازد؛ برای کلاسهای دیگر با operator fun component1() دستی تعریفشان میکنی.
destructuring فعلی موقعیتی است. اگر ترتیب property ها در data class عوض شود، کد بیسروصدا خطا میدهد. Kotlin 2.3.20 نحو ناممحور را بهصورت آزمایشی معرفی کرده — (val x, val y) = point و val [a, b] = point برای حالت موقعیتی، پشت پرچم -Xname-based-destructuring — با هدف پایدار شدن در 2.5. تا آن زمان: destructuring را فقط روی جفتها، entry های Map و data class های کوچکِ پایدار استفاده کن، نه روی DTO های ۸ فیلدی.
۴.۱۳ operator overloading و infix
data class Money(val cents: Long) {
operator fun plus(other: Money) = Money(cents + other.cents)
operator fun times(k: Int) = Money(cents * k)
operator fun compareTo(other: Money) = cents.compareTo(other.cents)
}
val total = Money(100) + Money(250)
if (Money(100) < Money(200)) { /* ... */ }
infix fun Int.upToBy(limit: Int): IntProgression = this..limit step 2
val r = 0 upToBy 10
operator فقط روی نامهای از پیش تعیینشده کار میکند (plus, minus, times, div, rem, get, set, contains, invoke, compareTo, rangeTo, …) — نمیتوانی عملگر جدید اختراع کنی، که خوب است. قاعدهٔ استفاده: Money + Money و Duration + Duration عالیاند، User + Order جنایت است. اگر معنای عملگر برای خوانندهٔ ناآشنا بدیهی نیست، تابع با اسم بنویس. همین برای infix هم صدق میکند — در DSL های تست (shouldBe) عالی، در منطق تجاری معمولاً نه.
۴.۱۴ delegation
سه چیز متفاوت که همه با by نوشته میشوند.
۱. by lazy — مقداردهی تنبل و thread-safe:
val template: String by lazy { loadTemplateFromDisk() } // فقط یک بار
پیشفرض LazyThreadSafetyMode.SYNCHRONIZED است. اگر مطمئنی تکنخی است، by lazy(LazyThreadSafetyMode.NONE) { ... } سریعتر است.
۲. Delegates.observable / vetoable:
var status: String by Delegates.observable("NEW") { _, old, new ->
log.info("status {} -> {}", old, new)
}
var quantity: Int by Delegates.vetoable(1) { _, _, new -> new > 0 } // false یعنی رد
۳. delegation کلاسی — «ترکیب بهجای وراثت» در سطح زبان:
interface Repository { fun findAll(): List<String> }
class DbRepository : Repository {
override fun findAll() = listOf("a", "b")
}
class LoggingRepository(private val delegate: Repository) : Repository by delegate {
override fun findAll(): List<String> {
log.info("findAll called")
return delegate.findAll()
}
}
کامپایلر همهٔ متدهای Repository را خودکار فوروارد میکند و تو فقط آنچه میخواهی override میکنی — تحقق مستقیم الگوی Decorator (solid-patterns). تله: فراخوانیهای داخلی به delegate میروند، نه به override تو — یعنی self-call ها از decorator رد نمیشوند. همان مشکل معروف پروکسیهای Spring در spring-core، فقط در سطح زبان.
property delegate سفارشی هم میتوانی بنویسی، مثلاً برای خواندن از map پیکربندی:
class ConfigDelegate(private val map: Map<String, String>) {
operator fun getValue(thisRef: Any?, property: KProperty<*>): String =
map[property.name] ?: error("missing config ${property.name}")
}
class AppConfig(map: Map<String, String>) {
val databaseUrl: String by ConfigDelegate(map)
}
۴.۱۵ object، companion object، typealias و value class
object ConnectionPool { // singleton، مقداردهی تنبل و thread-safe
fun acquire(): Connection = TODO()
}
class HttpClient private constructor(val url: String) {
companion object {
fun of(url: String) = HttpClient(url)
}
}
val listener = object : EventListener { // object expression = anonymous class
override fun onEvent(e: Event) = println(e)
}
object در bytecode یک کلاس با فیلد static final INSTANCE است که در <clinit> مقداردهی میشود — همان singleton امنی که در Java با enum یا holder idiom میساختی، رایگان. قاعدهٔ انتخاب: اگر state ندارد و به کلاس خاصی گره نخورده → تابع سطح بالا؛ اگر factory است یا به private های کلاس نیاز دارد → companion object؛ اگر واقعاً یک نمونهٔ واحد با state است → object (و آنوقت باید به thread-safety و تستپذیریاش فکر کنی).
typealias UserId = Long
typealias Handler = (HttpRequest) -> HttpResponse
typealias فقط نام مستعار است و هیچ ایمنی نوعی اضافه نمیکند. برای ایمنی نوع، value class (که قبلاً inline class نام داشت):
@JvmInline
value class UserId(val value: Long)
fun load(id: UserId) { }
// load(42L) // خطای کامپایل — دقیقاً چیزی که میخواهی
load(UserId(42))
value class در بیشتر جاها در runtime حذف میشود و فقط long میماند — ایمنی نوع بدون هزینهٔ allocation. استثناها: بهعنوان nullable، در collection، یا در موقعیت generic، boxing رخ میدهد.
اما value class را در مرزهای reflection-heavy نگذار. مستندات Spring Boot صریحاً میگوید پشتیبانی از value class در @ConfigurationProperties محدود است و باید data class استفاده کنی؛ در JPA بهعنوان نوع فیلد و در بعضی مسیرهای serialization هم رفتار غیرمنتظره میبینی. جای درست value class مدل دامنه و امضاهای داخلی است.
۴.۱۶ inline، reified و visibility
inline fun <T> measure(name: String, block: () -> T): T {
val start = System.nanoTime()
try { return block() }
finally { log.info("{} took {}ms", name, (System.nanoTime() - start) / 1_000_000) }
}
inline fun <reified T> ObjectMapper.readValue(json: String): T =
readValue(json, T::class.java)
val order: Order = mapper.readValue(body) // بدون پاس دادن Order::class.java
inline یعنی کامپایلر بدنهٔ تابع و lambda را در نقطهٔ فراخوانی کپی میکند — پس شیء lambda ساخته نمیشود و return از داخل lambda میتواند از تابع بیرونی خارج شود (non-local return). reified نوع generic را در زمان اجرا حفظ میکند و فقط داخل تابع inline ممکن است — همان چیزی که Spring در extension های Kotlin برای RestClient و TestRestTemplate استفاده میکند تا از ParameterizedTypeReference خلاص شوی.
noinline جلوی inline شدن یک پارامتر خاص را میگیرد؛ crossinline اجازهٔ non-local return را میگیرد وقتی lambda قرار است در context دیگری اجرا شود.
| modifier | معنا در Kotlin | معادل Java |
|---|---|---|
public (پیشفرض) |
همهجا | public |
internal |
فقط همان ماژول کامپایل | public با نام mangle شده |
protected |
کلاس و زیرکلاسها (نه package) | protected (در Java شامل package هم میشود) |
private |
همان فایل (سطح بالا) یا همان کلاس | private |
دو تفاوت مهم با Java: هیچ سطح «package-private» وجود ندارد — جایش را internal گرفته که واحدش ماژول است نه package؛ و protected در Kotlin شامل package نمیشود.
inline بدنهٔ تابع و lambda هایش را در نقطهٔ فراخوانی کپی میکند: حذف allocation شیء lambda در مسیرهای داغ، و امکان return غیرمحلی. reified — فقط داخل inline — اجازه میدهد به نوع generic در زمان اجرا دسترسی داشته باشی، چون نوع واقعی در نقطهٔ فراخوانی معلوم است. این پاسخ Kotlin به type erasure است و دلیل اینکه میتوانی mapper.readValue<Order>(json) بنویسی.
هزینهها: bytecode بزرگتر؛ تابع inline عمومی به عضوهای private دسترسی ندارد؛ و تغییر بدنهاش سازگاری باینری را میشکند چون در کد مصرفکننده کپی شده — برای کتابخانههای عمومی یک تصمیم جدی است. و inline روی تابعی که پارامتر lambda ندارد فقط bytecode را باد میکند؛ کامپایلر هم هشدار میدهد.
۵. Coroutines، بهشکل درست
این دلیل اصلی مهاجرت خیلی از تیمهاست، و بیشترین جایی که برنامهنویس Java اشتباه میکند.
آشپزخانهای با ده پیشخدمت (thread) داری. مدل قدیمی: پیشخدمت سفارش را میگیرد، کنار اجاق میایستد تا غذا آماده شود، بعد میبرد. با ده مشتری همزمان، ده پیشخدمت بیکار کنار اجاق ایستادهاند و مشتری یازدهم منتظر میماند.
مدل coroutine: پیشخدمت سفارش را به آشپز میدهد، برگهٔ سفارش را روی میز میگذارد و میرود سراغ مشتری بعدی. وقتی غذا آماده شد، هر پیشخدمتی که آزاد باشد برگه را برمیدارد و ادامه میدهد. آن «برگهٔ سفارش» دقیقاً همان continuation است: اطلاعات لازم برای ادامه از همان نقطه. کل coroutine ها همین یک ایدهاند.
۵.۱ suspend چیست
suspend fun fetchUser(id: Long): User {
val profile = httpClient.get("/users/$id") // نقطهٔ تعلیق
val orders = httpClient.get("/orders?u=$id") // نقطهٔ تعلیق
return User(profile, orders)
}
این کد مثل کد بلوکهکننده خوانده میشود ولی نخ را بلوکه نمیکند. کامپایلر آن را با CPS (Continuation-Passing Style) بازنویسی میکند: به هر تابع suspend یک پارامتر مخفی Continuation اضافه میشود و بدنه به یک ماشین حالت تبدیل میشود که در هر نقطهٔ تعلیق میتواند برگردد و بعداً ادامه دهد. در bytecode امضا چیزی شبیه این میشود:
Object fetchUser(long id, Continuation<? super User> $completion)
۱. suspend یعنی «ممکن است بایستم و بعداً ادامه دهم» — نه «ناهمگام اجرا میشوم». تابع suspend ای که I/O نکند، دقیقاً مثل تابع عادی و روی همان نخ اجرا میشود.
۲. تابع suspend را فقط از داخل یک suspend دیگر یا از داخل یک coroutine builder میتوانی صدا بزنی. تضمینی کامپایلری که «رنگ تابع» را در امضا صریح میکند.
۳. همزمانی از suspend نمیآید؛ از launch و async میآید. کد بالا دو درخواست را پشتسرهم میفرستد، نه همزمان.
۵.۲ structured concurrency
مدیری هستی که سه کار را به سه نفر میسپاری. structured concurrency سه قانون است: تا هر سه تمام نشوند کار تو تمام نشده؛ اگر پروژه لغو شد هر سه بلافاصله دست نگه میدارند؛ و اگر یکی شکست خورد تو خبردار میشوی — نه اینکه خطا در سکوت گم شود. در Java قدیم با ExecutorService هر سه را دستی میساختی؛ StructuredTaskScope همین ایده را به Java آورد (virtual-threads).
suspend fun loadDashboard(userId: Long): Dashboard = coroutineScope {
val profile = async { userService.profile(userId) }
val orders = async { orderService.recent(userId) }
val credit = async { creditService.limit(userId) }
Dashboard(profile.await(), orders.await(), credit.await())
}
coroutineScope محدودهای میسازد که تا همهٔ فرزندان تمام نشوند برنمیگردد، اگر لغو شود همه را لغو میکند، و اگر هر فرزندی خطا بدهد بقیه را لغو کرده و خطا را به بالا پرتاب میکند. supervisorScope همان است با یک تفاوت: شکست یک فرزند بقیه را لغو نمیکند — مناسب کارهای مستقل.
نمودار: درخت structured concurrency و انتشار لغو و خطا · The structured-concurrency tree and how cancellation and failure propagate.
flowchart TD
P["parent Job (coroutineScope)"] --> C1["child: async profile"]
P --> C2["child: async orders"]
P --> C3["child: async credit"]
C2 -- "throws" --> P
P -- "cancels" --> C1
P -- "cancels" --> C3
P -- "rethrows to caller" --> X["caller"]
۵.۳ scope، job و builder ها
CoroutineScope یک شیء است که یک CoroutineContext نگه میدارد و طول عمر coroutine ها را تعیین میکند. Job دستهٔ یک coroutine است (cancel()، join()، خواندن وضعیت). CoroutineContext مجموعهای شبیه Map است از Job، CoroutineDispatcher، CoroutineName و CoroutineExceptionHandler.
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + CoroutineName("worker"))
val job: Job = scope.launch { doWork() } // fire-and-forget
val deferred: Deferred<Int> = scope.async { 42 } // نتیجهدار
job.cancel()
scope.cancel() // همهٔ فرزندان را لغو میکند
نمودار: چرخهٔ حیات یک Job · Lifecycle of a coroutine Job.
stateDiagram-v2
[*] --> New: created lazily
New --> Active: start()
Active --> Completing: body finished
Completing --> Completed: children done
Active --> Cancelling: cancel() or child failure
Completing --> Cancelling: failure
Cancelling --> Cancelled: cleanup done
Completed --> [*]
Cancelled --> [*]
GlobalScope.launch { } یک coroutine میسازد که به هیچ والدی وصل نیست: هرگز خودکار لغو نمیشود، خطایش را کسی نمیبیند، و هنگام shutdown ناتمام رها میشود — یعنی نشت منابع و کار نیمهتمام هنگام deploy. جایگزینها: coroutineScope { } وقتی داخل یک تابع suspend هستی، یا یک CoroutineScope صریح متعلق به مؤلفهات که در @PreDestroy صدا زده میشود cancel(). خود Kotlin هم آن را @DelicateCoroutinesApi علامت زده.
runBlocking نخ فعلی را بلوکه میکند تا coroutine تمام شود. جای درستش دقیقاً سه جاست: تابع main، تستها (البته آنجا runTest بهتر است)، و مرز بین کد بلوکهکنندهٔ قدیمی و دنیای coroutine. داخل یک کنترلر یا سرویس، همان نخی را که میخواستی آزاد کنی بلوکه کردهای؛ و اگر داخل یک coroutine روی Dispatchers.Default بنویسیاش میتوانی کل pool را قفل کنی و به deadlock برسی.
۵.۴ dispatcher ها — کد روی کدام نخ اجرا میشود
| Dispatcher | استخر نخ | بهترین کاربرد |
|---|---|---|
Dispatchers.Default |
بهاندازهٔ هستههای CPU (حداقل ۲) | کار CPU-bound: پارس، محاسبه، مرتبسازی |
Dispatchers.IO |
۶۴ نخ یا تعداد هستهها (هرکدام بزرگتر) | I/O بلوکهکننده: JDBC، فایل، کتابخانههای blocking |
Dispatchers.Unconfined |
نخ فراخواننده تا اولین تعلیق | موارد خاص و تست؛ در production معمولاً نه |
Dispatchers.Main |
نخ UI (Android/JavaFX) | در backend بیربط |
Dispatchers.IO نخها را با Dispatchers.Default به اشتراک میگذارد؛ سقفش با system property به نام kotlinx.coroutines.io.parallelism قابل تنظیم است. چون نخها مشترکاند، withContext(Dispatchers.IO) وقتی از قبل روی Default هستی معمولاً اصلاً نخ عوض نمیکند — بهینهسازی عمدی کتابخانه.
suspend fun report(): Report = coroutineScope {
val rows = withContext(Dispatchers.IO) { jdbcTemplate.query(...) } // بلوکهکننده
withContext(Dispatchers.Default) { heavyAggregate(rows) } // CPU
}
Dispatchers.IO.limitedParallelism(n) یک «نما» میسازد که حداکثر n نخ همزمان دارد و — نکتهٔ ظریف — محدود به سقف ۶۴ خودِ IO نیست. یعنی برای هر منبع پاییندستی یک نما میسازی:
val dbDispatcher = Dispatchers.IO.limitedParallelism(20) // اندازهٔ connection pool
val legacySoap = Dispatchers.IO.limitedParallelism(5) // سرویس شکننده
دقیقاً همان الگوی bulkhead در resilience، بدون کتابخانهٔ اضافه. عدد 20 را از روی اندازهٔ HikariCP بگذار نه از روی حدس — اگر ۶۴ coroutine دنبال ۱۰ connection باشند، فقط صف را جابهجا کردهای.
۵.۵ لغو، و مدیریت خطا
لغو در coroutine ها تعاونی است: هیچکس نخ را نمیکشد. همهٔ تابعهای suspend کتابخانه (delay, withContext, await, …) نقاط لغو اند و CancellationException پرتاب میکنند. اما یک حلقهٔ CPU-bound خالص هرگز لغو نمیشود مگر خودت isActive یا ensureActive() یا yield() بگذاری.
val job = scope.launch {
while (isActive) { compute() }
}
job.cancel()
CancellationException یک Exception است. اگر بنویسی try { doWork() } catch (e: Exception) { log.error(...) }، coroutine لغو شده را «مدیریتشده» جا زدهای و والد فکر میکند فرزند سالم است. الگوی درست:
try {
doWork()
} catch (e: CancellationException) {
throw e // همیشه دوباره پرتاب کن
} catch (e: Exception) {
log.error("failed", e)
}
برای cleanup ای که باید حتی بعد از لغو انجام شود، در finally از withContext(NonCancellable) { ... } استفاده کن — چون بعد از لغو هر تابع suspend دیگری بلافاصله دوباره پرتاب میکند.
نکتهٔ مکمل: خطا در launch بلافاصله به والد منتشر میشود، ولی خطا در async تا await() نگه داشته میشود؛ اگر Deferred را دور بریزی، خطا در سکوت گم میشود. و CoroutineExceptionHandler فقط برای coroutine های ریشهای که با launch ساخته شدهاند فعال است — روی async کار نمیکند. این رایجترین دلیل «خطا در لاگ نیست ولی چیزی کار نمیکند» است.
۵.۶ Flow — جریانهای سرد
Flow<T> معادل coroutine ای یک stream ناهمگام است. سرد یعنی هیچ کاری نمیکند تا کسی collect کند — مثل Flux در Reactor و برخلاف Channel یا SharedFlow.
fun orders(customerId: Long): Flow<Order> = flow {
var page = 0
while (true) {
val batch = api.fetchPage(customerId, page++) // suspend
if (batch.isEmpty()) break
batch.forEach { emit(it) }
}
}
suspend fun report(customerId: Long) {
orders(customerId)
.filter { it.total > BigDecimal.TEN }
.map { it.toDto() }
.flowOn(Dispatchers.IO) // فقط بالادست خود را جابهجا میکند
.buffer(capacity = 64) // تولیدکننده و مصرفکننده را جدا میکند
.catch { e -> log.error("stream failed", e) }
.onEach { metrics.increment() }
.collect { sink.write(it) } // اینجا واقعاً شروع میشود
}
| عملگر | کار |
|---|---|
map / filter / transform |
تبدیلهای پایه (همه میتوانند suspend باشند) |
flowOn(d) |
context بخش بالادست را عوض میکند |
buffer(n) |
تولید و مصرف را موازی میکند؛ صف با ظرفیت n |
conflate() |
مقادیر میانی را دور میریزد؛ فقط آخرین را نگه میدارد |
collectLatest |
با رسیدن مقدار جدید، پردازش قبلی را لغو میکند |
debounce(ms) |
مقدار را فقط بعد از سکوت منتشر میکند |
flatMapConcat / Merge / Latest |
تخت کردن با ترتیب / موازی / لغو قبلی |
catch { } |
خطای بالادست را میگیرد (نه پاییندست) |
retryWhen |
تلاش مجدد با شرط |
stateIn / shareIn |
تبدیل flow سرد به گرم (StateFlow/SharedFlow) |
onStart / onCompletion |
قلابهای چرخهٔ عمر |
در Reactor باید دربارهٔ request(n) و استراتژیهای onBackpressureBuffer/Drop/Latest فکر کنی. در Flow مکانیزم پیشفرض تعلیق است: emit() یک تابع suspend است، پس اگر مصرفکننده کند باشد تولیدکننده طبیعتاً معلق میشود — سادهترین و امنترین شکل backpressure.
buffer، conflate و collectLatest فقط وقتی لازماند که نمیخواهی تولیدکننده کند شود — همان انتخاب آگاهانهای که در Reactor بهاجبار میکردی.
جریانهای گرم: StateFlow (همیشه یک مقدار جاری دارد، تکراریها را حذف میکند) و SharedFlow (پخش به چند مصرفکننده با replay قابل تنظیم).
private val _health = MutableStateFlow(Health.UNKNOWN)
val health: StateFlow<Health> = _health.asStateFlow()
_health.value = Health.UP
kotlinx-coroutines-reactor پلهای Flux.asFlow()، Flow.asFlux()، Mono.awaitSingle() و Publisher.awaitFirstOrNull() را میدهد، پس میتوانی در WebFlux لایهٔ خودت را coroutine ای بنویسی و در مرز به Reactor برگردی.
اما تلهٔ واقعی جای دیگری است: متغیرهای ThreadLocal — MDC برای لاگ، SecurityContextHolder، span های tracing — بهطور خودکار از مرز coroutine رد نمیشوند. راهحل رسمی در Spring Boot 4: وابستگی io.micrometer:context-propagation را اضافه کن و spring.reactor.context-propagation=auto را ست کن. بدون این، لاگهایت traceId ندارند و ساعتها دنبال دلیلش میگردی (observability).
۵.۷ تست coroutine ها
@Test
fun `retries three times then fails`() = runTest {
val client = FlakyClient(failures = 3)
assertEquals("ok", withRetry(times = 3) { client.call() })
}
runTest از یک scheduler مجازی استفاده میکند: delay(10_000) بلافاصله رد میشود. یعنی timeout و backoff را تست میکنی بدون اینکه تست ۱۰ ثانیه طول بکشد. برای کنترل دقیقتر advanceTimeBy() و runCurrent() را داری.
structured concurrency یعنی طول عمر هر کار همزمان به یک محدودهٔ لغوی گره خورده است. در Kotlin با coroutineScope { }: تابع تا وقتی همهٔ فرزندان تمام نشوند برنمیگردد، لغو از بالا به پایین منتشر میشود، و خطای فرزند به والد میرسد.
سه مشکل کلاسیک ExecutorService را حل میکند: نشت کار (task هایی که بعد از برگشتن متد هنوز اجرا میشوند)؛ لغو ناقص (درخواست HTTP کنسل میشود ولی سه فراخوانی پاییندستی ادامه میدهند و منابع میسوزانند)؛ و خطای گمشده (استثنایی که در Future میماند و کسی هرگز get() نمیکند).
اگر مصاحبهگر Java-محور است پل بزن: «همان ایدهای که در Java با StructuredTaskScope وارد شد، Kotlin سالهاست بهعنوان پیشفرض کتابخانه دارد.»
launch یک coroutine «شلیک کن و برو» میسازد و Job برمیگرداند؛ نتیجهای ندارد و خطایش بلافاصله به والد منتشر میشود. async یک coroutine نتیجهدار میسازد و Deferred<T> برمیگرداند که با await() مقدار را میدهد؛ خطایش تا await() نگه داشته میشود.
نکتهٔ سنیوری: async را فقط برای موازیسازی واقعی استفاده کن. الگوی async { ... }.await() بلافاصله پشت سر هم، صرفاً یک لایهٔ سربار روی فراخوانی مستقیم است. و اگر Deferred را بدون await دور بریزی، خطا در سکوت گم میشود — کابوس دیباگ.
کامپایلر هر تابع suspend را با CPS بازنویسی میکند: یک پارامتر پنهان Continuation اضافه میشود و بدنه به ماشین حالتی تبدیل میشود که در هر نقطهٔ تعلیق شمارهٔ حالت و متغیرهای محلی را نگه میدارد. هیچ نخ اضافهای در کار نیست.
بنابراین یک coroutine معلق فقط یک شیء روی heap است، نه نخی با استک ۱ مگابایتی — به همین دلیل داشتن صدها هزار coroutine عادی است. CoroutineDispatcher تصمیم میگیرد ادامهٔ کار روی کدام نخ اجرا شود.
نکتهای که خیلیها اشتباه میگویند: coroutine «نخ سبک» نیست؛ مکانیزم زبانی برای تعلیق و ازسرگیری است. مقایسه با virtual thread ها دقیقاً از همینجا شروع میشود.
List وقتی همهٔ داده در حافظه جا میشود و اندازهاش کراندار و قابلپیشبینی است. Flow وقتی یکی از این سه شرط برقرار است: داده بهمرور تولید میشود (صفحهبندی از API، خواندن از Kafka، رویدادهای SSE)؛ اندازه بزرگ یا نامتناهی است و نمیخواهی همه را در heap بریزی؛ یا به backpressure و لغو در وسط کار نیاز داری.
نکتهٔ کلیدی: Flow سرد است — تا کسی collect نکند هیچ کاری نمیشود، و هر collect جریان را از نو اجرا میکند. اگر منبعی مشترک بین چند مصرفکننده میخواهی، به SharedFlow/StateFlow یا shareIn/stateIn نیاز داری — همان تمایز cold/hot که در reactive-reactor دیدی.
۶. مقایسهٔ صادقانه: Coroutines، Virtual Threads، Reactor
اینجا جایی است که یک سنیور از یک برنامهنویس متوسط تشخیص داده میشود. جواب «coroutine بهتر است» غلط است.
| محور | Kotlin Coroutines | Java Virtual Threads | Project Reactor |
|---|---|---|---|
| مدل ذهنی | کد ترتیبی با نقاط تعلیق صریح | کد ترتیبی بلوکهکننده، بدون تغییر | زنجیرهٔ عملگرها روی publisher |
| نیاز به بازنویسی کد موجود | بله — تابعها باید suspend شوند |
تقریباً هیچ | بله — بازنویسی کامل |
| «رنگ تابع» | دارد (suspend در امضا) |
ندارد | دارد (Mono/Flux) |
| لغو | داخلی و structured | با interrupt، دستیتر |
با dispose() |
| backpressure | داخلی (تعلیق emit) |
ندارد (مدل thread) | صریح با request(n) |
| stack trace | خوانا | کاملاً عادی | بدنام؛ نیاز به checkpoint() |
| کد بلوکهکننده | باید به Dispatchers.IO منتقل شود |
مستقیماً پشتیبانی میشود | سم مطلق؛ باید subscribeOn شود |
| عملگرهای stream | Flow |
ندارد | غنیترین |
| زبان | فقط Kotlin | Java و Kotlin | هر دو |
تیم روی Java و مشکل «تعداد زیاد درخواست I/O-bound» → virtual thread ها (JDK 21+). ارزانترین راه، بدون تغییر مدل ذهنی، بدون رنگ تابع (virtual-threads).
تیم روی Kotlin → coroutine ها. مزیت اصلی نسبت به virtual thread ها، Flow و structured concurrency و لغو داخلی است، نه صرفاً «سبکبودن».
نیاز واقعی به backpressure انتها-به-انتها روی یک stream نامتناهی (پردازش رویداد، SSE، gRPC streaming) → Flow یا Reactor. اینجا مدل thread چه مجازی چه نه جواب نمیدهد، چون مسئله «کنترل نرخ» است نه «تعداد نخ».
روی WebFlux با Kotlin → لایهٔ خودت را coroutine ای بنویس و در مرز به Reactor تبدیل کن؛ بهترین خوانایی با کمترین بازنویسی.
و یک صداقت که خوب مینشیند: بخش بزرگی از ارزش Reactor بعد از آمدن virtual thread ها از بین رفت. اگر امروز پروژهٔ Java جدیدی صرفاً برای «مقیاسپذیری» با Reactor شروع کنی، احتمالاً انتخاب اشتباهی است.
سه دلیل واقعی: (۱) Flow — virtual thread ها هیچ مدل stream ناهمگام با backpressure ندارند؛ اگر کار تو رویداد-محور است، نخ ابزار اشتباهی است. (۲) structured concurrency و لغو — در coroutine ها لغو شهروند درجهیک است و از درخت والد-فرزند منتشر میشود، در حالی که interrupt قراردادی و شکننده است (StructuredTaskScope در حال پر کردن این شکاف است). (۳) قابلیت حمل — coroutine ها روی Android و Kotlin Multiplatform هم کار میکنند.
و دلیلی که معتبر نیست: «coroutine ها سریعترند». برای بار I/O-bound معمولی، virtual thread ها کاملاً رقابتیاند و کد را سادهتر نگه میدارند چون رنگ تابع ندارند.
۷. Kotlin با Spring Boot
Spring Boot 4 و Spring Framework 7 پایهٔ Kotlin را روی 2.2 گذاشتهاند و کل API خود را با JSpecify علامتگذاری کردهاند؛ تجربهٔ Kotlin در Spring امروز بهمراتب تمیزتر از دو سال پیش است.
۷.۱ دو plugin کامپایلری که بدون آنها هیچ چیز کار نمیکند
مسئلهٔ اول: در Kotlin کلاسها و متدها پیشفرض final اند. Spring برای @Transactional، @Cacheable، @Async و AOP باید زیرکلاس بسازد (CGLIB proxy)، و زیرکلاسسازی از یک کلاس final غیرممکن است. راهحل: plugin کامپایلری all-open که کلاسهای دارای annotation مشخصی را خودکار open میکند. kotlin-spring پیشتنظیم آمادهای از همان است که @Component، @Configuration، @Service، @Repository، @Controller، @RestController، @Transactional، @Async و @Cacheable را پوشش میدهد.
مسئلهٔ دوم: JPA به سازندهٔ بدون آرگومان نیاز دارد تا entity را با reflection بسازد و Kotlin چنین سازندهای تولید نمیکند. راهحل: plugin no-arg که یک سازندهٔ synthetic بدون آرگومان میسازد. kotlin-jpa پیشتنظیمی از آن است برای @Entity، @Embeddable و @MappedSuperclass. آن سازنده synthetic است — از Kotlin یا Java قابل صدا زدن نیست و فقط با reflection استفاده میشود، دقیقاً همان چیزی که Hibernate میخواهد.
plugins {
kotlin("plugin.spring") version "2.2.20"
kotlin("plugin.jpa") version "2.2.20"
}
allOpen { annotation("com.acme.OpenForProxy") } // annotation سفارشی
noArg {
annotation("com.acme.NoArgEntity")
invokeInitializers = true // بلوکهای init را هم اجرا کن
}
بدون kotlin-spring: یا خطای «Cannot subclass final class» میگیری، یا — خطرناکتر — @Transactional بیصدا کار نمیکند و تراکنش باز نمیشود. تستهای واحدت پاس میشوند و در production داده نیمهذخیره میشود.
بدون kotlin-jpa: InstantiationException یا «No default constructor for entity» هنگام بالا آمدن یا اولین کوئری.
قانون: در هر پروژهٔ Kotlin + Spring، این دو خط اولین چیزی است که در build.gradle.kts چک میکنی.
۷.۲ ساختار یک سرویس
@SpringBootApplication
class ShopApplication
fun main(args: Array<String>) {
runApplication<ShopApplication>(*args)
}
@Service
class OrderService(
private val repository: OrderRepository, // تزریق سازنده، بدون @Autowired
private val payments: PaymentGateway,
) {
@Transactional
fun place(cmd: PlaceOrder): Order =
repository.save(Order(customerId = cmd.customerId, total = cmd.total))
}
runApplication<T>(*args) یک extension با reified است — معادل SpringApplication.run(ShopApplication.class, args) بدون کلاسنویسی. تزریق سازنده در Kotlin کاملاً طبیعی است: هیچ @Autowired، هیچ فیلد var، همه چیز val و non-null. همان الگویی که در spring-core بهترینروش است، فقط اینجا زبان مجبورت میکند درست بنویسی.
۷.۳ @ConfigurationProperties با data class
@ConfigurationProperties("shop.payments")
data class PaymentProperties(
val baseUrl: URI,
val timeout: Duration = Duration.ofSeconds(5),
val retries: Int = 3,
val credentials: Credentials,
) {
data class Credentials(val clientId: String, val clientSecret: String)
}
shop:
payments:
base-url: https://payments.internal/api
timeout: 10s
credentials:
client-id: shop-svc
client-secret: ${PAYMENTS_SECRET}
با data class و val، binding از طریق سازنده انجام میشود (@ConstructorBinding ضمنی است)، پیکربندی تغییرناپذیر میشود و مقادیر پیشفرض Kotlin کار میکنند. اگر یک مقدار اجباری در YAML نباشد، برنامه هنگام بالا آمدن شکست میخورد — نه سه ساعت بعد در production با NPE. کلاس اصلی را با @ConfigurationPropertiesScan علامت بزن.
۷.۴ کنترلرهای coroutine ای
@RestController
@RequestMapping("/api/orders")
class OrderController(private val service: OrderService) {
@GetMapping("/{id}")
suspend fun get(@PathVariable id: Long): OrderDto =
service.find(id) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping(produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun stream(): Flow<OrderDto> = service.streamAll()
}
Spring خودش suspend را به Mono و Flow را به Flux تبدیل میکند. متدهای suspend در MVC معمولی هم پشتیبانی میشوند و روی مدل درخواست-بهازای-نخ اجرا میشوند.
suspend جادو نمیکند. اگر داخل یک تابع suspend مستقیماً jdbcTemplate.query(...) بنویسی، نخ event-loop را بلوکه کردهای و کل سرویس زانو میزند. باید صریح withContext(Dispatchers.IO) { ... } بنویسی یا از R2DBC استفاده کنی. برای تشخیص در زمان توسعه، BlockHound را در profile تست فعال کن؛ هر فراخوانی بلوکهکننده روی نخ non-blocking را با خطا اعلام میکند.
۷.۵ تست: MockK و Kotest
چرا Mockito در Kotlin اذیت میکند: کلاسها final اند (نیاز به mock-maker-inline)، any() مقدار null برمیگرداند که با پارامتر non-null نمیسازد، و mock کردن extension function و object تقریباً غیرممکن است. MockK برای Kotlin نوشته شده:
class OrderServiceTest {
private val repo = mockk<OrderRepository>()
private val service = OrderService(repo)
@Test
fun `saves order`() {
every { repo.save(any()) } answers { firstArg<Order>().copy(id = 1) }
val result = service.place(PlaceOrder(customerId = 7, total = BigDecimal.TEN))
assertEquals(1L, result.id)
verify(exactly = 1) { repo.save(any()) }
}
@Test
fun `suspend calls work too`() = runTest {
coEvery { repo.findAsync(1) } returns Order(id = 1)
assertNotNull(service.findAsync(1))
}
}
coEvery/coVerify برای تابعهای suspend، mockkStatic برای متدهای static و mockkObject برای object ها. در Spring، کتابخانهٔ SpringMockK همان @MockkBean و @SpykBean را میدهد که معادل @MockitoBean و @MockitoSpyBean اند.
Kotest یک فریمورک تست جایگزین با سبکهای مختلف spec و ماتچرهای روان است:
class SlugSpec : StringSpec({
"slugify lowercases and dashes" {
"Hello World".toSlug() shouldBe "hello-world"
}
})
JUnit 5 را نگه دار (تستهای Java موجودت روی همان اجرا میشوند) و فقط Mockito را با MockK جایگزین کن — یا حتی هر دو را نگه دار تا مهاجرت تدریجی باشد. Kotest را وقتی بیاور که تیم واقعاً Kotlin-first شده و از property-based testing سود میبرد. اضافه کردن دو فریمورک تست به تیمی که هنوز با Kotlin راحت نیست فقط مقاومت میسازد. Testcontainers و باقی زیرساخت تست بدون تغییر کار میکنند (testing).
۷.۶ serialization و یک نکتهٔ SQL در مرز
اگر jackson-module-kotlin روی classpath باشد، Spring Boot خودکار ثبتش میکند. بدون آن، Jackson سازندهٔ Kotlin و مقادیر پیشفرض را نمیفهمد و — بدتر — میتواند با reflection مستقیماً null را در یک property غیر nullable بنشاند و یک NPE «غیرممکن» بسازد. سه محافظ: ماژول Kotlin را حتماً داشته باش؛ روی DTO های ورودی @Valid و annotation های validation بگذار (validation-mapstruct)؛ و deserialization یک payload ناقص را صریحاً تست کن. جزئیات در jackson-json. Spring Boot 4 یک starter به نام spring-boot-kotlinx-serialization-json هم دارد که اگر همزمان با Jackson باشد فقط نوعهای دارای @Serializable را برمیدارد.
یکی از سودهای کوچک ولی روزمرهٔ Kotlin این است که SQL خام دیگر با + به هم چسبانده نمیشود:
SELECT id, total, created_at
FROM orders
WHERE customer_id = :cid
ORDER BY created_at DESC
OFFSET :skip LIMIT :take;SELECT id, total, created_at
FROM orders
WHERE customer_id = :cid
ORDER BY created_at DESC
OFFSET :skip ROWS FETCH FIRST :take ROWS ONLY;یک نکتهٔ ریز ولی پرتکرار: trimIndent() را داخل annotation نمیتوانی صدا بزنی، چون annotation ها فقط ثابت زمان کامپایل میپذیرند — پس رشتهٔ چندخطیِ داخل @Query خام میماند. فاصلههای اضافه در SQL بیضررند ولی در متنهای حساس به whitespace نه. و تفاوت دیالکتها همچنان سر جای خودش است؛ تفصیل در sql-mastery و oracle-postgres-dialects.
پنج مورد را نام ببر: (۱) plugin های کامپایلری — kotlin-spring (all-open) الزامی است چون کلاسها final اند و AOP/@Transactional به proxy نیاز دارد، و kotlin-jpa (no-arg) برای entity ها. (۲) مدلسازی — data class برای DTO و @ConfigurationProperties، ولی کلاس معمولی برای entity های JPA. (۳) تزریق — فقط سازنده، همه val، بدون @Autowired. (۴) null-safety در مرزها — jackson-module-kotlin، JSpecify روی API های داخلی Java، و -Xjsr305=strict. (۵) تست — MockK بهجای Mockito.
اگر WebFlux هم هست اضافه کن: کنترلرهای suspend، تبدیل Flow↔Flux، و انتشار context برای MDC/tracing با spring.reactor.context-propagation=auto.
۸. سبک اصطلاحی و اشتباهات کلاسیک برنامهنویس Java
فهرست زیر همان چیزی است که در code review های واقعی بارها میبینی:
۱. !! بهجای فکر کردن — هر !! یعنی «کامپایلر را ساکت کردم». جایگزین: ?:، requireNotNull با پیام، یا بازطراحی نوع.
۲. همهچیز var — کلاسهای Kotlin که دقیقاً مثل POJO های Java نوشته شدهاند. اگر کلاست فقط داده است، data class با val.
۳. کلاس Utils با object — object StringUtils { ... } بهجای extension یا تابع سطح فایل.
۴. data class برای entity های JPA — خطرناکترین مورد این فهرست.
۵. Optional<T> بهجای T? — یک لایهٔ boxing اضافه و ناخوانا. Optional را فقط در مرز با Java نگه دار و بلافاصله به T? تبدیل کن.
۶. if/else تودرتو بهجای when — when هم expression است هم exhaustive check میدهد.
۷. حلقهٔ دستی بهجای عملگرهای collection.
۸. GlobalScope.launch — در production تقریباً همیشه bug.
۹. runBlocking در سرویس — کل سود coroutine را از بین میبرد.
۱۰. زنجیرهٔ ?.let تودرتو — بیش از دو سطح یعنی وقت استخراج یک تابع نامدار است.
۱۱. companion object بهعنوان انبار static — با ۱۵ ثابت و ۸ متد، همان کلاس خدای قدیمی است. private const val سطح فایل و extension معمولاً تمیزترند.
۱۲. نادیده گرفتن internal — اگر ماژولبندی داری، مرز API را خیلی روشنتر از package میکند.
۱۳. catch (e: Exception) بدون بازپرتاب CancellationException — در کد coroutine ای یک bug واقعی است.
۱۴. apply برای همه چیز — apply برای پیکربندی است؛ اگر مقدار میخواهی let یا run.
سه ابزار که ارزش راهاندازی دارند: ktlint یا ktfmt برای فرمت خودکار (بحث سبک را از code review حذف میکند)، detekt برای قوانین کیفیت (میتوانی !! را ممنوع کنی، پیچیدگی سیکلوماتیک را سقف بگذاری و GlobalScope را رد کنی)، و explicitApi() در ماژولهای کتابخانهای.
قانون سنیور: هر چیزی که در code review سه بار تکرار شد باید تبدیل به یک قانون خودکار در CI شود (cicd-pipelines).
۹. استراتژی مهاجرت یک codebase موجود
هیچکس اجازه نمیدهد شش ماه توسعهٔ محصول را متوقف کنی تا ۲۰۰ هزار خط را بازنویسی کنی. مهاجرت درست شبیه تعویض ریل زیر قطار در حرکت است: یک قطعه در هر بار، همیشه با امکان برگشت.
نمودار: مسیر مهاجرت تدریجی از Java به Kotlin · Incremental migration path from Java to Kotlin.
flowchart LR
A["1. Add Kotlin plugin<br/>zero .kt files"] --> B["2. New tests in Kotlin"]
B --> C["3. New code in Kotlin<br/>leaf classes first"]
C --> D["4. Convert DTOs, value objects,<br/>utils to Kotlin"]
D --> E["5. Convert services<br/>keep entities last"]
E --> F["6. Optional: coroutines<br/>only where they pay"]
گام ۱ — plugin را اضافه کن، بدون هیچ فایل Kotlin. فقط kotlin("jvm") را به build اضافه کن و مطمئن شو CI سبز است. زمان build کمی بیشتر میشود؛ همان را اندازه بگیر و به تیم گزارش بده. ریسک صفر، و مقاومت سازمانی را میشکند.
گام ۲ — تستهای جدید را Kotlin بنویس. تستها مصرفکنندهٔ API اند نه بخشی از آن؛ اگر خراب شوند production آسیب نمیبیند. اینجا تیم data class، آرگومان نامدار و رشتهٔ چندخطی را در امنترین محیط ممکن یاد میگیرد — و نام تست بهشکل backtick انگیزهٔ خوبی است: fun `rejects order when credit limit exceeded`().
گام ۳ — کد جدید را Kotlin بنویس، از برگها. کلاسهایی که وابستگی کمی دارند و کسی از آنها ارث نمیبرد.
گام ۴ — DTO ها، value object ها و کلاسهای utility را تبدیل کن. بیشترین سود با کمترین ریسک. ابزار «Convert Java File to Kotlin» در IDE نقطهٔ شروع خوبی است، اما خروجیاش را هرگز بدون بازبینی merge نکن؛ معمولاً پر از !!، var و نوعهای بیجهت nullable است.
گام ۵ — سرویسها و لایهٔ web؛ entity ها را برای آخر بگذار. entity ها بیشترین تعامل را با reflection و proxy دارند و کمترین سود را از Kotlin میگیرند.
گام ۶ — coroutine ها را فقط جایی بیاور که هزینهاش را برمیگرداند. مهاجرت به coroutine یک تصمیم معماری جداست، نه بخشی از مهاجرت زبان.
| مرحله | ریسک | سود | برگشتپذیری |
|---|---|---|---|
| افزودن plugin | ناچیز | صفر (آمادهسازی) | کامل |
| تستهای Kotlin | خیلی کم | یادگیری تیم | کامل |
| کلاسهای برگ جدید | کم | کد کمتر، null-safe | آسان |
| DTO / value object | کم | حذف حجم زیادی boilerplate | آسان |
| سرویسها | متوسط | خوانایی، null-safety | متوسط |
| entity های JPA | بالا | کم | سخت |
| coroutine ها | بالا | بستگی به بار دارد | سخت |
۱. زمان build. کامپایل Kotlin کندتر از javac است، بهویژه در build کامل. K2 خیلی بهبود داده و incremental خوب است، ولی انتظار افزایش زمان build کامل را داشته باش. kapt (پردازشگر annotation) کندترین بخش است؛ هرجا ممکن است به KSP مهاجرت کن که چند برابر سریعتر است.
۲. ابزار اطراف. پوشش کد (JaCoCo)، تحلیل استاتیک (SonarQube)، mutation testing و قوانین لینت — همه باید بررسی شوند که Kotlin را میفهمند. معمولاً میفهمند، ولی پیکربندی لازم دارند.
۳. مهارت تیم. نوشتن Kotlin بد از نوشتن Java خوب بدتر است. اگر تیم آموزش نبیند، نتیجه یک codebase با دو سبک ناهمگون است. قبل از گام ۳ یک راهنمای سبک تیمی بنویس و detekt را روشن کن.
نکتهٔ اول و مهمترین: بازنویسی یکباره (big-bang) هرگز. interop دوطرفهٔ Kotlin دقیقاً برای همین ساخته شده.
ترتیب من: plugin را اضافه کن و build را سبز نگه دار؛ تستهای جدید را Kotlin بنویس تا تیم یاد بگیرد؛ کد جدید و کلاسهای برگ؛ DTO ها و کلاسهای utility (بیشترین سود، کمترین ریسک)؛ سرویسها؛ و entity ها آخر از همه چون بیشترین تعامل را با reflection دارند.
معیارهایی که رصد میکنم: زمان build (مخصوصاً اگر kapt داریم)، تعداد !! در codebase بهعنوان شاخص کیفیت، و نرخ NPE در production. و یک تصمیم صریح: coroutine ها بخشی از این مهاجرت نیستند — آن یک تصمیم معماری جداگانه است که فقط با یک مشکل کارایی مشخص توجیه میشود.
چند حالت واقعی: تیمی که هیچکس Kotlin بلد نیست و در میانهٔ یک ددلاین بحرانی است — هزینهٔ یادگیری در بدترین زمان. پروژهای که بهشدت به annotation processing قدیمی و kapt وابسته است و زمان build از قبل هم مشکلساز است. کتابخانهای که مصرفکنندهٔ اصلیاش Java است و تیم حاضر نیست هزینهٔ نگهداری @JvmOverloads/@JvmStatic و تستهای interop را بدهد. و جایی که کل ابزار داخلی سازمان — تولید کد، linter ها، تحلیل استاتیک — فقط Java را میفهمد.
پاسخ ضعیف: «Kotlin همیشه بهتر است». پاسخ قوی: «Kotlin در بیشتر پروژههای backend جدید انتخاب خوبی است، ولی زبان یک تصمیم سازمانی است نه سلیقهای — و هزینهاش را تیم میپردازد، نه من.»
۱۰. چطور در مصاحبه دربارهٔ Kotlin حرف بزنی وقتی زبان اصلیات Java است
بزرگترین اشتباه، وانمود کردن به تجربهای است که نداری؛ مصاحبهگر با دو سؤال متوجه میشود. رویکرد برنده سه بخش دارد.
صادق باش و چارچوب را عوض کن. «تجربهٔ production من عمدتاً Java است. Kotlin را در حد نوشتن سرویس و تست بلدم و مدل ذهنیاش را میشناسم — null-safety بهعنوان بخشی از type system، coroutine ها بهعنوان تعلیق نه thread، و اینکه در Spring بدون plugin های all-open و no-arg هیچ چیز درست کار نمیکند. چون هر دو روی همان JVM اند، دانش من دربارهٔ GC، تراکنش و طراحی سیستم بدون تغییر منتقل میشود.» این جمله سه چیز نشان میدهد: صداقت، درک عمقدار، و پایین بودن ریسک استخدام.
سؤال را به قلمرو قویات بکش. اگر پرسیدند «coroutine چطور کار میکند»، بعد از توضیح CPS آن را به virtual thread ها و مدل thread وصل کن.
یک مثال واقعی آماده داشته باش، حتی یک پروژهٔ کوچک شخصی: «اولین چیزی که یاد گرفتم این بود که @Transactional بدون plugin kotlin-spring بیصدا کار نمیکند.» چنین جزئیاتی نشان میدهد واقعاً کد نوشتهای، نه فقط مقاله خواندهای.
طراحی عمدی بر اساس اصل «Design for inheritance, or prohibit it». وراثت از کلاسی که برای وراثت طراحی نشده، شکنندهترین شکل کوپلینگ است: نویسندهٔ کلاس پایه نمیتواند پیادهسازی داخلی را عوض کند بدون شکستن زیرکلاسها. Kotlin پیشفرض را روی امن گذاشته و برای بازکردن باید open بنویسی — یعنی یک تصمیم آگاهانه.
پیامد عملی در Spring: proxy های CGLIB به زیرکلاس نیاز دارند، پس plugin all-open لازم است. جایگزینها: پروکسی مبتنی بر اینترفیس، یا اصلاً تکیهنکردن به AOP.
یک تفاوت کوچک که زیاد میسوزاند: در Kotlin a == b به a?.equals(b) ?: (b === null) ترجمه میشود — برابری ساختاری و null-safe — و برابری مرجع با === نوشته میشود. دقیقاً برعکس Java. خبر خوب: مقایسهٔ String با == در Kotlin درست است. و هرگز === را برای مقایسهٔ مقدار استفاده نکن؛ روی Int های boxed به cache مقادیر کوچک JVM وابسته است، همان تلهٔ Integer cache در Java.
دو تله. اول: copy() یک کپی کمعمق است. اگر فیلدی یک MutableList باشد، نسخهٔ کپی همان لیست را به اشتراک میگذارد و تغییر در یکی در دیگری دیده میشود — یعنی «تغییرناپذیری» تو یک توهم است. راهحل: فیلدهای collection را فقطخواندنی نگه دار و در سازنده toList() بگیر.
دوم: copy() بلوک init را با مقادیر جدید اجرا میکند، ولی اگر اعتبارسنجی را بیرون از سازنده گذاشته باشی (مثلاً در یک factory method)، copy() آن را دور میزند و میتوانی یک شیء نامعتبر بسازی. قاعده: اعتبارسنجی value object را همیشه در بلوک init بگذار، نه در factory.
کوتاه: اعتماد نمیکنم؛ مرز را صریح میکنم. سه لایه: (۱) اگر کد Java مال ماست، با JSpecify علامتگذاری میکنیم — یک @NullMarked روی package و @Nullable روی استثناها؛ کامپایلر Kotlin جدی میگیرد و دیگر platform type نمیسازد. (۲) اگر کتابخانهٔ بیرونی است، در همان خطِ ورود نوع صریح مینویسم (val x: Foo?) تا NPE — اگر قرار است رخ دهد — در مرز رخ دهد نه بیست فریم پایینتر. (۳) -Xjsr305=strict را روشن میکنم تا annotation های موجود واقعاً اعمال شوند.
جملهٔ بستن: «platform type ها تنها سوراخ باقیماندهٔ null-safety اند، و تنها راه بستنشان نظم در مرز است، نه امید.»
Kotlin همان JVM است. همان bytecode، همان GC، همان اکوسیستم. هرچه دربارهٔ JVM بلدی معتبر میماند؛ فقط لایهٔ زبان عوض میشود.
Interop سرِ داستان است. از سمت Java مراقب platform type ها باش و مرزها را با JSpecify صریح کن؛ از سمت Kotlin با @JvmStatic، @JvmOverloads، @JvmField، @JvmName و @Throws API را برای Java خوشدست کن — و یک تست Java بنویس که ثابتش کند.
null-safety یک تغییر مدل ذهنی است، نه syntax. ?, ?., ?:, as? ابزارند؛ !! و lateinit بدهیاند. NPE حذف نمیشود، به مرزها رانده میشود.
زبان boilerplate را حذف میکند: val پیشفرض، data class، sealed + when exhaustive، آرگومان پیشفرض و نامدار (پایان Builder ها)، extension ها، scope function ها، delegation با by، و inline/reified برای فرار از type erasure.
Coroutines سه چیزند: suspend (تعلیق، نه thread)، structured concurrency (طول عمر گرهخورده به scope)، و Flow (stream سرد با backpressure رایگان). GlobalScope و runBlocking در کد سرویس تقریباً همیشه bug اند.
در انتخاب مدل همزمانی صادق باش: virtual thread ها برای Java و بار I/O-bound ساده کافیاند؛ coroutine ها وقتی میدرخشند که Flow، لغو ساختاریافته یا Kotlin Multiplatform داشته باشی.
در Spring: plugin های kotlin-spring و kotlin-jpa غیرقابلمذاکرهاند؛ data class برای DTO و پیکربندی، نه برای entity؛ تزریق سازنده با val؛ MockK بهجای Mockito.
مهاجرت را تدریجی و برگشتپذیر کن: plugin → تستها → کلاسهای برگ → DTO ها → سرویسها → entity ها. coroutine ها یک تصمیم معماری جداگانهاند.
در مصاحبه صادق باش. تجربهٔ Java خودت را دارایی معرفی کن و نشان بده مدل ذهنی Kotlin را گرفتهای — این خیلی قانعکنندهتر از حفظکردن نام scope function هاست.
Backend job posts today keep repeating one pattern: "Java or Kotlin". That "or" means the team does not expect you to have written Kotlin for years. It means that if you join tomorrow, open the repo and find half the services are .kt, you can read the code, fix a bug and say something useful in code review on day one.
The good news: Kotlin is not a new language you must learn from zero. Kotlin is the same JVM with a different language layer — the same bytecode, the same garbage collector, the same class loader, the same libraries, the same profiler, the same heap dumps. Everything you learned in jvm-internals about JIT and memory holds without a single change.
The trap is that many people see Kotlin as "Java with shorter syntax", and six months later they are writing code that looks like Kotlin but thinks like Java: classes full of var, !! on every other line, JPA entities declared as data class, and coroutines abandoned in GlobalScope. That is exactly what an interviewer is probing: did you internalise the mental model, or only the syntax?
This chapter teaches the language by contrast with Java: for every concept we first say what you did in Java and what hurt, then build Kotlin's answer.
- Why Kotlin exists and its relationship to the JVM; the K2 compiler and versions.
- Project setup: Gradle Kotlin DSL, plugins, compiler flags, and a Java→Kotlin cheat-sheet.
- Interop — the headline: calling Java from Kotlin (platform types) and Kotlin from Java (
@JvmStatic,@JvmOverloads,@JvmNameand friends). - The language by contrast with Java:
val/var, type inference, null safety in depth,data class,sealed, smart casts, default and named arguments, extensions, scope functions, collections andSequence, destructuring, operators andinfix, delegation,object,typealias/value class,inline/reified, visibility. - Coroutines properly:
suspend, structured concurrency, scopes and jobs, dispatchers,async/await,Flow. - An honest comparison with Java virtual threads and with Reactor.
- Kotlin with Spring Boot: the
all-openandno-argcompiler plugins, constructor injection,@ConfigurationProperties, coroutine controllers, MockK and Kotest. - Idiomatic style and the classic mistakes Java developers make.
- A migration strategy for an existing codebase, and how to talk about Kotlin in an interview when Java is your main language.
JVM fundamentals live in jvm-internals, generics in generics, Java collections in collections, functional style in functional and modern-java, virtual threads in virtual-threads, and Reactor in reactive-reactor; here we only reference them.
1. Why Kotlin exists and how it relates to the JVM
You have an excellent paved road (the JVM): infrastructure, fuel stations, garages, traffic rules. The first car (Java) is reliable but has a manual gearbox. The second car (Kotlin) runs on the same road with the same fuel, but has an automatic gearbox and emergency braking.
The road did not change. If the road has potholes (GC pauses, heap pressure, threads), both cars feel them. Kotlin does not fix JVM problems; it fixes the problems of writing code on the JVM.
Bytecode: the compiler's output — an intermediate language the JVM executes. kotlinc emits exactly the same .class format javac emits, which is why one module can hold .java and .kt side by side.
K2: the name of Kotlin's new compiler. Since Kotlin 2.0 it is the default and Stable for every target (JVM, Native, JS, Wasm); it is faster and has sharper type inference and smart casts. If you see old code fiddling with -Xuse-k2 flags, that is pre-2.0 archaeology.
Versions: the 2.x line is current — 2.2 (the Spring Boot 4 baseline), then 2.3 and 2.4. Binary-compatibility policy is strict: code compiled with 2.0 runs against later runtimes.
Kotlin/JVM vs Multiplatform: Kotlin compiles to the JVM, to machine code (Native), to JavaScript and to WebAssembly. For a backend team only Kotlin/JVM matters; the rest you only need to recognise in an interview.
Diagram: مسیر کامپایل Java و Kotlin به یک bytecode مشترک · Compilation path of Java and Kotlin into one shared bytecode.
flowchart LR
A[".java sources"] --> B["javac"]
C[".kt sources"] --> D["Kotlin K2 compiler"]
B --> E[".class bytecode"]
D --> E
E --> F["JAR / fat JAR"]
F --> G["JVM: classloader, JIT, GC"]
H["kotlin-stdlib.jar"] --> F
kotlin-stdlib is just an ordinary Maven dependency. String.trimIndent(), List.map, Sequence, Result and Pair come from there. The official Gradle plugin adds it automatically; to manage it yourself set kotlin.stdlib.default.dependency=false in gradle.properties.
What pain did Kotlin actually cure? The real list, not the marketing one: (1) NullPointerException — nullability became part of the type system; (2) boilerplate — getters/setters/equals/hashCode/toString, which Java had no answer for until records and tolerated Lombok as a patch (lombok); (3) telescoping constructors and builders — solved by default and named arguments; (4) Utils classes — solved by extension functions; (5) readable concurrency — coroutines, years before virtual threads shipped.
Short answer: nothing at runtime. Kotlin compiles to the same bytecode javac produces and runs on the same JVM — same GC, same JIT, same memory model (sync-locks-jmm), same profiling tools.
What it adds lives in the compiler and the library: nullability as part of the type system, generated equals/hashCode/copy for data class, exhaustiveness checking of when over sealed hierarchies, and coroutines, which are a CPS transform in the compiler plus the kotlinx-coroutines library.
A good closing line: "Kotlin does not solve JVM problems, it solves the problem of writing code on the JVM. If my problem is GC pauses, Kotlin will not help; if my problem is NPEs in production and 400 lines of DTOs, it absolutely will."
2. Project setup
The build file itself is Kotlin (build.gradle.kts) — this is the "Kotlin DSL" you see in job ads. You get type safety and auto-completion. Gradle details are in build-tools.
// build.gradle.kts
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
kotlin("jvm") version "2.2.20"
kotlin("plugin.spring") version "2.2.20" // all-open for Spring classes
kotlin("plugin.jpa") version "2.2.20" // no-arg for @Entity
id("org.springframework.boot") version "3.5.6"
id("io.spring.dependency-management") version "1.1.7"
}
dependencies {
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
testImplementation("io.mockk:mockk:1.14.11")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}
kotlin {
jvmToolchain(21)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll(
"-Xjsr305=strict", // treat JSR-305 annotations strictly
"-Xannotation-default-target=param-property"
)
}
}
kotlin("jvm") is shorthand for the real plugin id org.jetbrains.kotlin.jvm; kotlin("plugin.spring") is org.jetbrains.kotlin.plugin.spring.
On Maven you add kotlin-maven-plugin with compile and test-compile executions and fill compilerPlugins with <plugin>spring</plugin> and <plugin>jpa</plugin> (plus the kotlin-maven-allopen and kotlin-maven-noarg dependencies).
Compilation order. The Kotlin compiler reads Java code (to resolve types) but does not compile it. The correct order is: kotlinc over the whole source set first, then javac over the Java files. The Gradle plugin does this for you; on Maven the Kotlin execution must come before maven-compiler-plugin in pom.xml, otherwise you get baffling "cannot find symbol" errors.
-Xannotation-default-target. Since Kotlin 2.2 the default rules for "where does an annotation on a constructor parameter land" changed. In Java an annotation sits on a field and that is it; in Kotlin a val in the primary constructor is simultaneously a parameter, a property, a field and a getter. Without this flag, validation annotations can land on the wrong target and silently do nothing. param-property is the new behaviour and Spring Boot's documented recommendation; first-only restores the old one.
Cheat-sheet: direct Java → Kotlin mapping
| Java | Kotlin | Note |
|---|---|---|
final String s = "a"; |
val s = "a" |
type is inferred |
String s = null; |
var s: String? = null |
? is part of the type |
public class Foo {} |
class Foo |
public is the default |
non-final class |
open class Foo |
classes are final by default |
static method |
top-level function or companion object |
there is no static |
record Point(int x, int y) |
data class Point(val x: Int, val y: Int) |
data class is more flexible |
sealed interface (Java 17) |
sealed interface |
stronger exhaustiveness check |
instanceof X x |
is X + smart cast |
automatic cast |
switch over a sealed type |
when |
it is an expression, it returns a value |
Optional<T> |
T? |
you almost never write Optional in Kotlin |
List.of(1,2) |
listOf(1, 2) |
read-only |
new ArrayList<>() |
mutableListOf() |
there is no new |
str.equals(other) |
str == other |
== is equals and null-safe |
str == other (identity) |
str === other |
referential equality |
String.format("%s", x) |
"$x" |
string template |
Stream.of(..).map(..) |
.asSequence().map { } |
lazy |
try/catch for checked |
nothing needed | everything is unchecked |
Foo.class |
Foo::class.java |
KClass vs Class |
3. Interop: the headline of the whole story
If you only master one section of this chapter, make it this one. Teams do not start Kotlin in an empty codebase; they drop it next to 200,000 lines of Java.
3.1 Calling Java from Kotlin
You are a border officer who recognises two kinds of traveller: "definitely a citizen" and "definitely a foreigner". Now someone arrives whose passport says nothing about nationality. Either you treat everyone as foreign (then every call into Java code drowns in ? and ?. and becomes unusable), or you trust the traveller and hand them the responsibility. Kotlin chose the second and called it a platform type.
A platform type is what Kotlin assigns to any value coming back from Java when no nullability annotation is present. It shows up in error messages with an exclamation mark: String! means "either String or String? — I do not know". You cannot write it by hand; only the compiler produces it.
// Java
public class LegacyRepo {
public String findName(long id) { return null; }
}
val repo = LegacyRepo()
val a = repo.findName(1) // type: String! — the compiler allows it
val b: String = repo.findName(1) // compiles, but throws NPE at runtime
val c: String? = repo.findName(1) // safe: you declared it may be null
println(a.length) // no warning, but NPE
println(c?.length) // safe
Many teams think "we write Kotlin, therefore no NPEs". No — every boundary with Java, with legacy libraries, with deserialization and with reflection is an open door.
The right team rule: assign every value coming back from Java to an explicit type on the line where it enters (val x: Foo? or val x: Foo). Then, if null arrives, the NPE happens at the boundary, not twenty frames deeper where you have no clue what went wrong.
Nullability annotations: if the Java code is annotated, Kotlin takes it seriously and stops producing platform types. The recognised set is wide: org.jspecify.annotations (the new, recommended standard), org.jetbrains.annotations, JSR-305 (javax.annotation), Android annotations, Eclipse, lombok.NonNull and RxJava 3.
-Xjspecify-annotations=strict # strict is the default: violations are errors
-Xjsr305=strict # default is warn; use strict on a serious project
-Xnullability-annotations=@com.acme.annots:strict
Since Kotlin 2.1, JSpecify annotations are strict by default. Spring Framework 7 and Spring Boot 4 annotated their entire API surface with JSpecify, so in a Spring Boot 4 project you effectively no longer see platform types coming from Spring, Reactor or Micrometer — a qualitative jump over the previous generation.
If you own an internal Java library, put @NullMarked on package-info.java: from that moment everything is assumed non-null unless stated otherwise. It is the cheapest interop-quality upgrade available to you.
Mapped types: java.lang.String becomes kotlin.String, int becomes Int, java.lang.Integer becomes Int?, java.lang.Object becomes Any!. No extra class is created in bytecode.
Collections: Kotlin has two interfaces — List<T> (read-only) and MutableList<T>. java.util.List<T> maps to (Mutable)List<T>!. Be careful though: "read-only" is not "immutable". If you take an ArrayList from Java and hold it as a List<T>, Java code can still mutate that same object. For a real guarantee either copy (toList()) or use persistent structures from kotlinx.collections.immutable.
Arrays: in Java arrays are covariant (Object[] o = new String[1]; blows up at runtime). In Kotlin Array<T> is invariant, and for primitives you get dedicated types that compile to int[], long[], double[] with no boxing:
val ids: IntArray = intArrayOf(1, 2, 3) // int[] in bytecode
val names: Array<String> = arrayOf("a") // String[]
// val objs: Array<Any> = names // error: invariant
Checked exceptions: Kotlin treats no exception as checked. Call Files.readAllBytes(...) without try/catch and the compiler is happy. But that means more responsibility, not less: in Java the compiler forced you to think about IOException. The senior move: document error boundaries with KDoc @throws, and for domain logic use a sealed result or Result<T> instead of exceptions (exceptions).
SAM conversion: any Java interface with a single abstract method takes a lambda: executor.execute { println("in pool") }.
Generics: Foo<? extends Bar> becomes Foo<out Bar!>!, Foo<? super Bar> becomes Foo<in Bar!>!, and the raw type List becomes List<*>!. Variance details are in generics.
3.2 Calling Kotlin from Java
Here you have to design deliberately, or your API becomes ugly from the Java side.
Top-level functions: a function outside any class in StringUtils.kt compiles to a static method on a class named StringUtilsKt.
// file: StringUtils.kt
@file:JvmName("StringUtils") // without this the class is called StringUtilsKt
package com.acme.text
fun slugify(input: String): String = input.lowercase().replace(' ', '-')
String s = com.acme.text.StringUtils.slugify("Hello World");
If you want several files to collapse into one facade, add @file:JvmMultifileClass.
object and companion object: object Foo is visible from Java as Foo.INSTANCE; a companion object as Foo.Companion.
class TokenService {
companion object {
const val HEADER = "Authorization" // static final in bytecode
@JvmStatic
fun parse(raw: String): String = raw.removePrefix("Bearer ")
@JvmField
val DEFAULT_TTL: Duration = Duration.ofMinutes(15)
}
}
String t = TokenService.parse("Bearer abc"); // thanks to @JvmStatic
String h = TokenService.HEADER; // const is already static final
Duration d = TokenService.DEFAULT_TTL; // thanks to @JvmField
@JvmOverloads: default arguments are a language feature; in bytecode only one method with a bitmask parameter is produced, and Java cannot use it. @JvmOverloads says "also generate a real overload for each combination".
class HttpClient @JvmOverloads constructor(
private val baseUrl: String,
private val timeout: Duration = Duration.ofSeconds(5),
private val retries: Int = 3,
) {
@JvmOverloads
fun get(path: String, headers: Map<String, String> = emptyMap()): String = TODO()
}
@JvmName: resolves signature clashes after erasure, and prettifies names — on functions, and via @get:/@set: on properties:
fun List<String>.parseAll(): List<Int> = map { it.toInt() }
@JvmName("parseAllInts")
fun List<Int>.parseAll(): List<Int> = this
@get:JvmName("url")
@set:JvmName("setUrl")
var baseUrl: String = ""
@Throws: since Kotlin has no checked exceptions, the bytecode signature carries no throws clause and Java code cannot catch it (javac says "exception is never thrown").
@Throws(IOException::class)
fun writeReport(path: Path) { Files.writeString(path, "x") }
Getter/setter naming: var firstName: String becomes getFirstName()/setFirstName(). But a property starting with is has its own rule: val isOpen: Boolean becomes isOpen() — not getIsOpen().
internal and mangling: internal means "visible only inside this compilation module". Since the JVM has no such level, it becomes public in bytecode with a mangled name (e.g. doWork$app_main). So Java can call it. internal is an API-design tool, not a security boundary; for a hard boundary use JPMS or separate build modules.
Interface default methods: since Kotlin 2.2, interface functions with a body compile to Java default methods by default. Control it with -jvm-default: enable (default — default method plus a DefaultImpls compatibility class), no-compatibility (leaner) and disable (only DefaultImpls).
@JvmSuppressWildcards / @JvmWildcard: manual control over generated wildcards in generic signatures — for when Java wants a Box<Base> but Kotlin emitted Box<? extends Base>.
Diagram: مرز دو زبان و آنچه در هر جهت باید مراقبش باشی · The two-language boundary and what to watch in each direction.
flowchart TD
subgraph K["Kotlin side"]
K1["null-safe types"]
K2["default args"]
K3["top-level funs"]
K4["companion object"]
end
subgraph J["Java side"]
J1["platform types T!"]
J2["@JvmOverloads needed"]
J3["FileNameKt / @JvmName"]
J4["Foo.Companion / @JvmStatic"]
end
K1 -. "annotate with JSpecify" .-> J1
K2 --> J2
K3 --> J3
K4 --> J4
Five concrete actions, and it is best to enumerate exactly these:
- Put
@JvmOverloadson constructors and methods with default parameters — otherwise Java only sees the full signature. - For
companion objectmembers that should look static, add@JvmStatic(methods) and@JvmField/const(constants). - Put
@file:JvmName("...")on files with top-level functions so no...Ktname leaks out. - Wherever an exception is part of the contract, add
@Throwsso Java cancatchit. - For generic collections Java has to write into, check whether you need
@JvmSuppressWildcards.
And most importantly: write a Java test in the same build that actually calls the API from Java. That is the only reliable way to keep the Java-side ergonomics honest; "thinking about it" is not enough.
4. The language, by contrast with Java
4.1 val vs var
In Java you had to type final to get this behaviour, and nobody did. Kotlin inverted the equation: val is shorter than var.
val name = "ali" // final String name = "ali";
var counter = 0 // int counter = 0;
val items = mutableListOf(1, 2)
items.add(3) // allowed — val means the reference is not reassigned
// items = mutableListOf() // error
A simple code-review rule: if you see more than two class-level vars in a PR, ask why. A method-local var is fine; a class-level var is mutable state that has to be explained in a multi-threaded environment (sync-locks-jmm). In Kotlin writing immutable code is so cheap that every var needs a reason.
4.2 Type inference
val n = 42 // Int
val list = listOf("a", "b") // List<String>
fun double(x: Int) = x * 2 // returns Int, expression body
The trap: fun findUser(id: Long) = repo.load(id) derives its return type from the implementation. If repo.load changes tomorrow, your public signature changes silently and consumers break at link time. The rule: write the return type explicitly on anything public. In library modules enforce it with explicitApi() inside the kotlin { } block.
4.3 Null safety in depth
Box type one (String) is guaranteed by warehouse policy to never be empty. Box type two (String?) is labelled "may be empty", and the warehouse keeper (the compiler) will not let you open it without checking. That is all null safety is: nullability moved from runtime into the type system.
var a: String = "abc"
// a = null // compile error
var b: String? = "abc"
b = null // allowed
// println(b.length) // compile error
The tools, in the order you should prefer them:
val len1 = b?.length // safe call → Int?
val len2 = b?.length ?: 0 // elvis: a default
val len3 = b?.length ?: return // elvis + early return
val len4 = b?.length ?: error("no value") // elvis + throw
val len5 = b!!.length // not-null assertion — a code smell
val city: String? = order?.customer?.address?.city // safe chain
user.email?.let { mailer.send(it, subject = "welcome") }
val n = value as? Int ?: 0 // safe cast instead of ClassCastException
lateinit is for properties you cannot initialise in the constructor (field injection, test setup) but which are non-null: @Autowired lateinit var repo: OrderRepository. It works only on var, only on non-primitive types, and not with custom accessors. You can check it with ::repo.isInitialized.
Reading a lateinit before it is assigned throws UninitializedPropertyAccessException — an NPE with a nicer name.
As for !!: every !! is an unproven claim that the compiler turns into a throw. In 95% of cases one of these is better: ?: with a default, requireNotNull(x) { "clear message" }, checkNotNull(x), or redesigning the type so it is not nullable at all. In code review every !! should carry a justifying comment or be deleted.
Diagram: درخت تصمیم برای برخورد با یک مقدار nullable · Decision tree for handling a nullable value.
flowchart TD
A["value: T?"] --> B{"Can I give a sane default?"}
B -- yes --> C["v ?: default"]
B -- no --> D{"Is null a valid business case?"}
D -- yes --> E["v?.let { ... } / early return"]
D -- no --> F{"Is it a programming bug?"}
F -- yes --> G["requireNotNull / checkNotNull with message"]
F -- no --> H["Redesign the type: make it non-null"]
Kotlin lifted nullability into the type system: String and String? are different types, and the compiler refuses to dereference a nullable type without a check. That removes an entire class of errors at compile time.
But NPEs are still possible in five places: (1) an explicit !!; (2) a lateinit read before assignment; (3) platform types from unannotated Java; (4) reflection and deserialization that bypass constructors or write null into a non-null field — Jackson without the Kotlin module, for example; (5) leaking this from a constructor into an open method.
The senior framing: "Kotlin does not eliminate NPEs, it pushes them to the boundaries. My job is to make those boundaries explicit — JSpecify on the Java code, the Kotlin module for Jackson, and a ban on !! in code review."
4.4 data class vs record
data class Money(val amount: BigDecimal, val currency: String)
val a = Money(BigDecimal("10.00"), "IRR")
val b = a.copy(currency = "USD") // change exactly one field
val (amount, cur) = a // destructuring
The compiler generates equals(), hashCode(), toString(), copy() and componentN() — based on the properties in the primary constructor.
| Aspect | Java record |
Kotlin data class |
|---|---|---|
| Purpose | transparent, immutable data carrier | remove boilerplate from a data carrier |
| Mutability | always immutable | val or var — can be mutable |
| Inheritance | cannot extend a class; itself final |
can implement interfaces; itself final |
| Partial copy | none | built-in copy(...) |
| Fields outside the constructor | none | allowed — but excluded from equals |
| Interop | native JVM type | ordinary class; @JvmRecord makes it a real record |
1. Properties declared outside the primary constructor are not part of equals.
data class User(val id: Long) {
var email: String = "" // not in equals/hashCode/toString!
}
User(1).apply { email = "a@x" } == User(1).apply { email = "b@x" } // true
It is documented and deliberate, but shocking inside a HashMap or Set. Anything that is part of identity belongs in the primary constructor (equals-hashcode).
2. Never make JPA entities data classes. This is the single most common Kotlin mistake in Spring projects. The generated equals/hashCode reads every field, which on lazy associations triggers unintended initialisation and LazyInitializationException or hidden queries; a hashCode over all fields is inconsistent with an id that changes after persist; and toString() prints the whole graph, producing a full-blown N+1 in your logs. For entities write a plain class with var and implement equals/hashCode by hand on a business key (jpa-hibernate).
4.5 sealed classes and interfaces
sealed means "the set of my subtypes is closed at compile time".
sealed interface PaymentResult {
data class Approved(val txId: String) : PaymentResult
data class Declined(val reason: String, val retryable: Boolean) : PaymentResult
data object Timeout : PaymentResult // singleton, since Kotlin 1.9
}
fun message(r: PaymentResult): String = when (r) {
is PaymentResult.Approved -> "ok ${r.txId}"
is PaymentResult.Declined -> if (r.retryable) "retry: ${r.reason}" else "failed"
PaymentResult.Timeout -> "timed out"
// no else needed — the compiler knows every case is covered
}
Exhaustiveness means that when you add data object Fraud : PaymentResult tomorrow, every value-returning when stops compiling until you handle it. That is the best safety net you can get for domain modelling (ddd).
Guard conditions (Stable since Kotlin 2.2) attach an extra condition to a branch:
fun describe(r: PaymentResult): String = when (r) {
is PaymentResult.Declined if r.retryable -> "transient decline"
is PaymentResult.Declined -> "hard decline"
is PaymentResult.Approved -> "approved"
PaymentResult.Timeout -> "timeout"
}
when is an expression — that is its main difference from switch. And the subtle part: exhaustiveness is only enforced when when returns a value. Written as a statement, the compiler (depending on version) merely warns. So if you want the safety net, always return from when — even if the value is Unit. The common trick: val ignored: Unit = when (r) { ... }.
An enum is a fixed set of singleton instances; every value has the same shape. A sealed type is a fixed set of subtypes; each subtype can carry different data and can have many instances.
Practical example: order status (PENDING, SHIPPED, CANCELLED) is better as an enum — same shape, stored in the database. The outcome of an operation (Success(data), Failure(error), Timeout) is better as sealed — each case carries different data.
Both are exhaustively checkable with when. data object is the bridge: a sealed subtype with exactly one instance and sensible toString/equals.
4.6 Smart casts
fun length(x: Any): Int {
if (x is String) return x.length // x is a String here, no cast
if (x !is Collection<*>) return 0
return x.size // smart cast after negation
}
Limitation: if the property is a class-level var (or open, or has a custom getter), the compiler cannot guarantee it did not change between the check and the use, so it reports "smart cast is impossible". The standard fix is to capture it in a local val.
class Box(var value: String?) {
fun show() {
val v = value ?: return // local copy
println(v.length) // now it works
}
}
K2 widened smart-cast coverage noticeably — variables captured in lambdas and combined || conditions now work.
4.7 Default and named arguments — the end of builders
In Java, a function with five optional parameters means either 32 overloads or a hundred-line builder.
fun createOrder(
customerId: Long,
currency: String = "IRR",
discount: BigDecimal = BigDecimal.ZERO,
notifyCustomer: Boolean = true,
idempotencyKey: String? = null,
): Order = TODO()
createOrder(42)
createOrder(42, discount = BigDecimal("10"))
createOrder(customerId = 42, notifyCustomer = false, idempotencyKey = "k-1")
Named arguments fix readability at the call site — the problem you had in Java with doThing(true, false, true).
A senior rule: every Boolean in a public signature should be passed by name — many teams enforce this with detekt. Better still: replace the Boolean with a two-valued enum (Notify.YES/Notify.NO). That is the "remove the control parameter" pattern from solid-patterns, except here the language helps you.
4.8 Extension functions and properties
The Java problem: a StringUtils class of static methods, and calls like StringUtils.isBlank(s) instead of s.isBlank().
fun String.toSlug(): String =
trim().lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-')
val String.isValidEmail: Boolean
get() = matches(Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"))
"Hello World!".toSlug() // "hello-world"
In bytecode an extension is a static method taking the receiver as its first parameter; nothing is added to the original class.
open class Base
class Derived : Base()
fun Base.name() = "base"
fun Derived.name() = "derived"
val x: Base = Derived()
println(x.name()) // "base" — not "derived"!
The declared type of the variable decides which function is called, not the runtime type. And if an extension has the same signature as a member function, the member always wins. These two rules are the main source of confusion for Java developers.
There is an organisational trap too: teams create an Extensions.kt file and dump 200 unrelated functions into it — the old Utils class in new clothes. Write an extension when you cannot change the target class, or when the operation belongs to your domain rather than to that class; if the class is yours and the method is core behaviour, write a member function.
4.9 Scope functions
Five inline stdlib functions that all do the same thing — run a block in the context of an object — and differ on only two axes: how the object is referenced and what they return.
| Function | Object reference | Returns | Reads best for |
|---|---|---|---|
let |
it |
lambda result | safe execution on a nullable, transforming a value |
run |
this |
lambda result | computing one value from several members |
with |
this |
lambda result | like run, but the object is an argument, not a receiver |
apply |
this |
the object | configuring an object (builder style) |
also |
it |
the object | side effects: logging, validation, registration |
val len: Int = maybeName?.let { it.trim().length } ?: 0
val props = Properties().apply {
setProperty("bootstrap.servers", "kafka:9092")
setProperty("acks", "all")
}
val order = repo.save(newOrder).also { log.info("saved order {}", it.id) }
val url = config.run { "$scheme://$host:$port/$path" }
The trap is nesting. When you have three nested lets and the outer it is shadowed by an inner it, nobody can tell which is which. Two rules: name it in any nested lambda (?.let { user -> ... }), and never go more than two levels deep — extract a private function with a clear name instead.
4.10 Strings
val greet = "Hello, $name! len=${name.length}"
val json = """
{
"id": $id,
"type": "order"
}
""".trimIndent()
val path = """C:\Users\app""" // no escaping
Need a literal $? Use ${'$'}, or multi-dollar interpolation (Stable since Kotlin 2.2): $$"cost is $price", where $price stays literal and you need $$ to interpolate.
4.11 Collections and Sequence
val nums = listOf(1, 2, 3, 4, 5)
val evens = nums.filter { it % 2 == 0 }
val grouped = nums.groupBy { it % 3 } // Map<Int, List<Int>>
val assoc = nums.associateBy { it.toString() } // Map<String, Int>
val first = nums.firstOrNull { it > 3 } // Int?
val (small, big) = nums.partition { it < 3 }
val windows = nums.windowed(size = 2, step = 1)
val chunks = nums.chunked(2)
The key difference from Java Streams: Kotlin collection operations are eager. nums.filter{}.map{} allocates two intermediate lists. The lazy equivalent is Sequence:
val result = hugeList.asSequence()
.filter { it.isActive }
.map { it.toDto() }
.take(10)
.toList() // this is where it actually runs
| Aspect | Kotlin collection | Kotlin Sequence |
Java Stream |
|---|---|---|---|
| Evaluation | eager | lazy | lazy |
| Intermediate collections | yes, at every step | no | no |
| Parallelism | none | none | parallelStream() |
| Best for | small/medium collections | long chains, big data, infinite sources | Java interop, parallelism |
The common wrong intuition is "lazy is always better". For a 20-element list, the overhead of chained iterators exceeds the cost of allocating two small lists. Practical rule: below ~1,000 elements and ~3 operations, work on the collection directly; for long chains, large data, or when you have first()/take(n) and want to stop early, add asSequence(). And if you genuinely want CPU-bound parallelism, use parallelStream() or coroutines — Sequence is not parallel.
4.12 Destructuring
val (x, y) = Point(1, 2)
for ((key, value) in mapOf("a" to 1)) { println("$key=$value") }
val (name, age) = "ali" to 30 // Pair
It is built on component1(), component2() … which data class generates; for other classes you declare them yourself with operator fun component1().
Today's destructuring is positional. If the property order in a data class changes, your code breaks silently. Kotlin 2.3.20 introduced name-based syntax experimentally — (val x, val y) = point, and val [a, b] = point for the positional form, behind the -Xname-based-destructuring flag — targeting Stable in 2.5. Until then: use destructuring only on pairs, Map entries and small stable data classes, not on eight-field DTOs.
4.13 Operator overloading and infix
data class Money(val cents: Long) {
operator fun plus(other: Money) = Money(cents + other.cents)
operator fun times(k: Int) = Money(cents * k)
operator fun compareTo(other: Money) = cents.compareTo(other.cents)
}
val total = Money(100) + Money(250)
if (Money(100) < Money(200)) { /* ... */ }
infix fun Int.upToBy(limit: Int): IntProgression = this..limit step 2
val r = 0 upToBy 10
operator works only on a predefined set of names (plus, minus, times, div, rem, get, set, contains, invoke, compareTo, rangeTo, …) — you cannot invent new operators, which is a good thing. The usage rule: Money + Money and Duration + Duration are excellent, User + Order is a crime. If the meaning is not obvious to an unfamiliar reader, write a named function. The same applies to infix — great in test DSLs (shouldBe), usually wrong in business logic.
4.14 Delegation
Three different things, all written with by.
1. by lazy — lazy, thread-safe initialisation:
val template: String by lazy { loadTemplateFromDisk() } // computed once
The default is LazyThreadSafetyMode.SYNCHRONIZED. If you know it is single-threaded, by lazy(LazyThreadSafetyMode.NONE) { ... } is faster.
2. Delegates.observable / vetoable:
var status: String by Delegates.observable("NEW") { _, old, new ->
log.info("status {} -> {}", old, new)
}
var quantity: Int by Delegates.vetoable(1) { _, _, new -> new > 0 } // false rejects
3. Class delegation — "composition over inheritance" as a language feature:
interface Repository { fun findAll(): List<String> }
class DbRepository : Repository {
override fun findAll() = listOf("a", "b")
}
class LoggingRepository(private val delegate: Repository) : Repository by delegate {
override fun findAll(): List<String> {
log.info("findAll called")
return delegate.findAll()
}
}
The compiler forwards every Repository method to delegate automatically and you override only what you care about — the Decorator pattern realised directly (solid-patterns). The trap: internal calls go to the delegate, not to your override — self-calls do not pass through the decorator. Exactly the famous Spring proxy problem from spring-core, only at the language level.
You can also write a custom property delegate, for example to read from a configuration map:
class ConfigDelegate(private val map: Map<String, String>) {
operator fun getValue(thisRef: Any?, property: KProperty<*>): String =
map[property.name] ?: error("missing config ${property.name}")
}
class AppConfig(map: Map<String, String>) {
val databaseUrl: String by ConfigDelegate(map)
}
4.15 object, companion object, typealias and value class
object ConnectionPool { // singleton, lazily and safely initialised
fun acquire(): Connection = TODO()
}
class HttpClient private constructor(val url: String) {
companion object {
fun of(url: String) = HttpClient(url)
}
}
val listener = object : EventListener { // object expression = anonymous class
override fun onEvent(e: Event) = println(e)
}
In bytecode an object is a class with a static final INSTANCE field initialised in <clinit> — the same safe singleton you used to build in Java with an enum or the holder idiom, for free. The selection rule: if it has no state and is not tied to a class → top-level function; if it is a factory or needs the class's private members → companion object; if it truly is a single stateful instance → object (and then you must think about thread safety and testability).
typealias UserId = Long
typealias Handler = (HttpRequest) -> HttpResponse
A typealias is only an alias and adds no type safety at all. For type safety use a value class (formerly inline class):
@JvmInline
value class UserId(val value: Long)
fun load(id: UserId) { }
// load(42L) // compile error — exactly what you want
load(UserId(42))
A value class is erased at runtime in most positions, leaving only a long — type safety with no allocation cost. Exceptions: as a nullable, inside a collection, or in a generic position, boxing occurs.
But do not put value class on reflection-heavy boundaries. Spring Boot's documentation explicitly says support for value classes in @ConfigurationProperties is limited and you should use a data class; in JPA as a field type and on some serialization paths you will also see surprising behaviour. The right home for value class is the domain model and internal signatures.
4.16 inline, reified and visibility
inline fun <T> measure(name: String, block: () -> T): T {
val start = System.nanoTime()
try { return block() }
finally { log.info("{} took {}ms", name, (System.nanoTime() - start) / 1_000_000) }
}
inline fun <reified T> ObjectMapper.readValue(json: String): T =
readValue(json, T::class.java)
val order: Order = mapper.readValue(body) // no need to pass Order::class.java
inline means the compiler copies the function body and its lambdas into the call site — so no lambda object is allocated, and a return inside the lambda can exit the enclosing function (non-local return). reified preserves the generic type at runtime and is only possible inside an inline function — exactly what Spring uses in its Kotlin extensions for RestClient and TestRestTemplate so you can drop ParameterizedTypeReference.
noinline prevents inlining of a specific parameter; crossinline forbids non-local returns when the lambda will run in another context.
| Modifier | Meaning in Kotlin | Java equivalent |
|---|---|---|
public (default) |
everywhere | public |
internal |
only within the compilation module | public with a mangled name |
protected |
the class and its subclasses (not the package) | protected (which in Java also includes the package) |
private |
the file (top level) or the class | private |
Two important differences from Java: there is no "package-private" level — internal replaced it, and its unit is the module rather than the package; and Kotlin's protected does not include the package.
inline copies the function body and its lambdas into the call site: no lambda-object allocation on hot paths, and non-local return becomes possible. reified — legal only inside inline — lets you reach the generic type at runtime, because the real type is known at each call site. It is Kotlin's answer to type erasure and the reason you can write mapper.readValue<Order>(json).
The costs: larger bytecode; a public inline function cannot touch private members; and changing its body breaks binary compatibility because the body was copied into consumer code — a serious consideration for public libraries. Also, inline on a function that takes no lambda parameter only bloats bytecode; the compiler warns you about it.
5. Coroutines, done properly
This is the main reason many teams switch, and the place Java developers get it wrong most often.
You run a kitchen with ten waiters (threads). The old model: the waiter takes an order, stands next to the stove until the food is ready, then delivers it. With ten simultaneous customers, ten idle waiters are standing at the stove and the eleventh customer waits.
The coroutine model: the waiter hands the order to the cook, leaves the ticket on the counter and moves on to the next customer. When the food is ready, whichever waiter is free picks up the ticket and continues. That "ticket" is precisely a continuation: everything needed to resume from that point. All of coroutines is that one idea.
5.1 What suspend is
suspend fun fetchUser(id: Long): User {
val profile = httpClient.get("/users/$id") // suspension point
val orders = httpClient.get("/orders?u=$id") // suspension point
return User(profile, orders)
}
This code reads like blocking code but does not block a thread. The compiler rewrites it using CPS (Continuation-Passing Style): a hidden Continuation parameter is added to every suspend function and the body becomes a state machine that can return at each suspension point and resume later. In bytecode the signature looks roughly like:
Object fetchUser(long id, Continuation<? super User> $completion)
suspendmeans "I might pause and continue later" — not "I run asynchronously". Asuspendfunction that does no I/O runs exactly like a normal function on the same thread.- You may only call a
suspendfunction from anothersuspendfunction or from a coroutine builder. A compiler-enforced guarantee that makes "function colour" explicit in the signature. - Concurrency does not come from
suspend; it comes fromlaunchandasync. The code above issues the two requests sequentially, not concurrently.
5.2 Structured concurrency
You delegate three tasks to three people. Structured concurrency is three rules: your work is not done until all three finish; if the project is cancelled all three stop immediately; and if one of them fails you find out — the error does not vanish silently. With a plain ExecutorService you had to build all three by hand; StructuredTaskScope brought the same idea to Java (virtual-threads).
suspend fun loadDashboard(userId: Long): Dashboard = coroutineScope {
val profile = async { userService.profile(userId) }
val orders = async { orderService.recent(userId) }
val credit = async { creditService.limit(userId) }
Dashboard(profile.await(), orders.await(), credit.await())
}
coroutineScope creates a scope that does not return until all children complete, cancels every child if it is cancelled, and — if any child throws — cancels the siblings and rethrows to the caller. supervisorScope is the same with one difference: one child's failure does not cancel the others, which suits genuinely independent work.
Diagram: درخت structured concurrency و انتشار لغو و خطا · The structured-concurrency tree and how cancellation and failure propagate.
flowchart TD
P["parent Job (coroutineScope)"] --> C1["child: async profile"]
P --> C2["child: async orders"]
P --> C3["child: async credit"]
C2 -- "throws" --> P
P -- "cancels" --> C1
P -- "cancels" --> C3
P -- "rethrows to caller" --> X["caller"]
5.3 Scopes, jobs and builders
A CoroutineScope is an object holding a CoroutineContext that defines coroutine lifetimes. A Job is the handle on a coroutine (cancel(), join(), state). A CoroutineContext is a Map-like set of Job, CoroutineDispatcher, CoroutineName and CoroutineExceptionHandler.
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + CoroutineName("worker"))
val job: Job = scope.launch { doWork() } // fire-and-forget
val deferred: Deferred<Int> = scope.async { 42 } // produces a result
job.cancel()
scope.cancel() // cancels every child
Diagram: چرخهٔ حیات یک Job · Lifecycle of a coroutine Job.
stateDiagram-v2
[*] --> New: created lazily
New --> Active: start()
Active --> Completing: body finished
Completing --> Completed: children done
Active --> Cancelling: cancel() or child failure
Completing --> Cancelling: failure
Cancelling --> Cancelled: cleanup done
Completed --> [*]
Cancelled --> [*]
GlobalScope.launch { } creates a coroutine attached to no parent: it is never cancelled automatically, nobody sees its errors, and it is abandoned half-done at shutdown — meaning leaked resources and unfinished work on every deploy. The alternatives: coroutineScope { } when you are inside a suspend function, or an explicit CoroutineScope owned by your component and cancel()ed in @PreDestroy. Kotlin itself marks it @DelicateCoroutinesApi for a reason.
runBlocking blocks the current thread until the coroutine completes. Its legitimate homes are exactly three: main, tests (where runTest is better anyway), and the boundary between legacy blocking code and the coroutine world. Inside a controller or service you have blocked the very thread you were trying to free; and if you do it inside a coroutine on Dispatchers.Default you can pin the whole pool and deadlock.
5.4 Dispatchers — which thread your code runs on
| Dispatcher | Thread pool | Best for |
|---|---|---|
Dispatchers.Default |
as many as CPU cores (minimum 2) | CPU-bound work: parsing, computation, sorting |
Dispatchers.IO |
64 threads or the core count, whichever is larger | blocking I/O: JDBC, files, blocking libraries |
Dispatchers.Unconfined |
the calling thread until the first suspension | special cases and tests; rarely production |
Dispatchers.Main |
the UI thread (Android/JavaFX) | irrelevant on the backend |
Dispatchers.IO shares threads with Dispatchers.Default; its limit is tunable through the system property kotlinx.coroutines.io.parallelism. Because the threads are shared, withContext(Dispatchers.IO) when you are already on Default usually does not switch threads at all — a deliberate optimisation in the library.
suspend fun report(): Report = coroutineScope {
val rows = withContext(Dispatchers.IO) { jdbcTemplate.query(...) } // blocking
withContext(Dispatchers.Default) { heavyAggregate(rows) } // CPU
}
Dispatchers.IO.limitedParallelism(n) creates a "view" with at most n concurrent threads and — the subtle part — it is not bounded by IO's own 64-thread limit. So you can create one view per downstream resource:
val dbDispatcher = Dispatchers.IO.limitedParallelism(20) // size of the connection pool
val legacySoap = Dispatchers.IO.limitedParallelism(5) // fragile service
This is exactly the bulkhead pattern from resilience, with no extra library. Derive the 20 from your HikariCP pool size rather than guessing — if 64 coroutines chase 10 connections, you have only moved the queue.
5.5 Cancellation and error handling
Cancellation in coroutines is cooperative: nobody kills a thread. Every suspending library function (delay, withContext, await, …) is a cancellation point and throws CancellationException. But a pure CPU-bound loop is never cancelled unless you add isActive, ensureActive() or yield() yourself.
val job = scope.launch {
while (isActive) { compute() }
}
job.cancel()
CancellationException is an Exception. If you write try { doWork() } catch (e: Exception) { log.error(...) }, you have reported a cancelled coroutine as "handled" and the parent believes the child is healthy. The correct pattern:
try {
doWork()
} catch (e: CancellationException) {
throw e // always rethrow
} catch (e: Exception) {
log.error("failed", e)
}
For cleanup that must run even after cancellation, wrap it in withContext(NonCancellable) { ... } inside finally — because after cancellation any other suspending call rethrows immediately.
A related point: an error in launch propagates to the parent immediately, while an error in async is held until await(); if you discard the Deferred, the error is lost silently. And CoroutineExceptionHandler only applies to root coroutines started with launch — it does nothing for async. That is the most common cause of "there is nothing in the log but nothing works" in coroutine code.
5.6 Flow — cold streams
Flow<T> is the coroutine equivalent of an asynchronous stream. Cold means nothing happens until someone collects — just like Flux in Reactor, and unlike a Channel or SharedFlow.
fun orders(customerId: Long): Flow<Order> = flow {
var page = 0
while (true) {
val batch = api.fetchPage(customerId, page++) // suspend
if (batch.isEmpty()) break
batch.forEach { emit(it) }
}
}
suspend fun report(customerId: Long) {
orders(customerId)
.filter { it.total > BigDecimal.TEN }
.map { it.toDto() }
.flowOn(Dispatchers.IO) // affects only the upstream part
.buffer(capacity = 64) // decouples producer from consumer
.catch { e -> log.error("stream failed", e) }
.onEach { metrics.increment() }
.collect { sink.write(it) } // this is where it actually starts
}
| Operator | What it does |
|---|---|
map / filter / transform |
basic transforms (all may be suspend) |
flowOn(d) |
changes the context of the upstream part |
buffer(n) |
runs producer and consumer in parallel; queue of capacity n |
conflate() |
drops intermediate values, keeps only the latest |
collectLatest |
cancels the in-flight processing when a new value arrives |
debounce(ms) |
emits only after a quiet period |
flatMapConcat / Merge / Latest |
flatten in order / concurrently / cancelling the previous |
catch { } |
catches upstream errors (not downstream) |
retryWhen |
conditional retry |
stateIn / shareIn |
turn a cold flow hot (StateFlow/SharedFlow) |
onStart / onCompletion |
lifecycle hooks |
In Reactor you must think about request(n) and the onBackpressureBuffer/Drop/Latest strategies. In Flow the default mechanism is suspension: emit() is a suspend function, so a slow consumer naturally suspends the producer — the simplest and safest form of backpressure.
buffer, conflate and collectLatest are needed only when you do not want the producer slowed down — the same deliberate choice Reactor forced on you.
Hot streams: StateFlow (always holds a current value, drops duplicates) and SharedFlow (broadcast to multiple collectors with configurable replay).
private val _health = MutableStateFlow(Health.UNKNOWN)
val health: StateFlow<Health> = _health.asStateFlow()
_health.value = Health.UP
kotlinx-coroutines-reactor gives you the bridges Flux.asFlow(), Flow.asFlux(), Mono.awaitSingle() and Publisher.awaitFirstOrNull(), so in a WebFlux service you can write your own layer with coroutines and convert back to Reactor at the boundary.
The real trap is elsewhere: ThreadLocal values — MDC for logging, SecurityContextHolder, tracing spans — do not cross coroutine boundaries automatically. The official fix in Spring Boot 4: add the io.micrometer:context-propagation dependency and set spring.reactor.context-propagation=auto. Without it your logs have no traceId and you will spend hours wondering why (observability).
5.7 Testing coroutines
@Test
fun `retries three times then fails`() = runTest {
val client = FlakyClient(failures = 3)
assertEquals("ok", withRetry(times = 3) { client.call() })
}
runTest uses a virtual scheduler: delay(10_000) is skipped instantly. That lets you test timeouts and backoff without a ten-second test. For finer control you have advanceTimeBy() and runCurrent().
Structured concurrency means the lifetime of every concurrent task is bound to a lexical scope. In Kotlin that is coroutineScope { }: the function does not return until all children finish, cancellation propagates downward, and a child's failure reaches the parent.
It solves three classic ExecutorService problems: leaked work (tasks still running after the method returned); incomplete cancellation (the HTTP request is cancelled but three downstream calls keep burning resources); and lost errors (an exception sitting in a Future on which nobody ever calls get()).
If the interviewer is Java-centric, build the bridge: "the same idea Java introduced with StructuredTaskScope has been the library default in Kotlin for years."
launch creates a fire-and-forget coroutine and returns a Job; it produces no result and its error propagates to the parent immediately. async creates a result-producing coroutine and returns a Deferred<T> whose value you get with await(); its error is held until await().
The senior nuance: use async only for real parallelism. The pattern async { ... }.await() immediately in sequence is just an overhead layer over a direct call. And if you discard a Deferred without awaiting it, the error vanishes silently — a debugging nightmare.
The compiler rewrites every suspend function using CPS: a hidden Continuation parameter is added and the body becomes a state machine that stores the state index and local variables at each suspension point. No extra threads are involved.
So a suspended coroutine is just an object on the heap, not a thread with a one-megabyte stack — which is why hundreds of thousands of coroutines are routine. The CoroutineDispatcher decides which thread the continuation resumes on.
The point many people get wrong: a coroutine is not a "lightweight thread"; it is a language mechanism for suspending and resuming. That is exactly where the comparison with virtual threads begins.
Use a List when all the data fits in memory and the size is bounded and predictable. Use a Flow when one of three conditions holds: the data is produced over time (paging an API, reading from Kafka, SSE events); the size is large or unbounded and you do not want it all on the heap; or you need backpressure or mid-stream cancellation.
The key point that signals seniority: a Flow is cold — nothing happens until someone collects, and every collection re-runs the stream. If you need one shared source for several consumers you need SharedFlow/StateFlow or shareIn/stateIn — the same cold/hot distinction you saw in reactive-reactor.
6. An honest comparison: coroutines, virtual threads, Reactor
This is where a senior is separated from a mid-level engineer. "Coroutines are better" is the wrong answer.
| Axis | Kotlin coroutines | Java virtual threads | Project Reactor |
|---|---|---|---|
| Mental model | sequential code with explicit suspension points | sequential blocking code, unchanged | operator chains over a publisher |
| Rewrite of existing code | yes — functions must become suspend |
almost none | yes — a full rewrite |
| "Function colour" | yes (suspend in the signature) |
none | yes (Mono/Flux) |
| Cancellation | built-in and structured | via interrupt, more manual |
via dispose() |
| Backpressure | built-in (suspending emit) |
none (thread model) | explicit via request(n) |
| Stack traces | readable | completely normal | notorious; needs checkpoint() |
| Blocking code | must be moved to Dispatchers.IO |
directly supported | outright poison; needs subscribeOn |
| Stream operators | Flow |
none | the richest |
| Language | Kotlin only | Java and Kotlin | both |
Team on Java, problem is "many I/O-bound requests" → virtual threads (JDK 21+). Cheapest path, no change in mental model, no function colouring (virtual-threads).
Team on Kotlin → coroutines. Their real edge over virtual threads is Flow, structured concurrency and built-in cancellation, not merely "being lightweight".
Genuine need for end-to-end backpressure over an unbounded stream (event processing, SSE, gRPC streaming) → Flow or Reactor. Here the thread model, virtual or not, does not answer the question, because the problem is rate control, not thread count.
On WebFlux with Kotlin → write your own layer with coroutines and convert to Reactor at the boundary; best readability for the least rewriting.
And a piece of honesty that lands well: much of Reactor's value evaporated once virtual threads arrived. Starting a new Java project on Reactor purely "for scalability" today is probably the wrong call.
Three real reasons: (1) Flow — virtual threads offer no asynchronous stream model with backpressure; if your work is event-driven, threads are the wrong tool. (2) Structured concurrency and cancellation — in coroutines cancellation is a first-class citizen propagating through the parent-child tree, whereas interrupt is conventional and fragile (StructuredTaskScope is closing that gap). (3) Portability — coroutines also work on Android and Kotlin Multiplatform.
And a reason that is not valid: "coroutines are faster". For ordinary I/O-bound load, virtual threads are entirely competitive and keep the code simpler because they do not colour functions.
7. Kotlin with Spring Boot
Spring Boot 4 and Spring Framework 7 set the Kotlin baseline at 2.2 and annotated their whole API with JSpecify; the Kotlin experience in Spring today is far cleaner than it was two years ago.
7.1 The two compiler plugins without which nothing works
Problem one: in Kotlin, classes and methods are final by default. For @Transactional, @Cacheable, @Async and AOP, Spring must create a subclass (a CGLIB proxy), and you cannot subclass a final class. The fix: the all-open compiler plugin, which automatically opens classes carrying given annotations. kotlin-spring is a ready-made preset of it covering @Component, @Configuration, @Service, @Repository, @Controller, @RestController, @Transactional, @Async and @Cacheable.
Problem two: JPA needs a no-argument constructor to instantiate entities reflectively, and Kotlin does not generate one. The fix: the no-arg plugin, which generates a synthetic zero-argument constructor. kotlin-jpa is its preset for @Entity, @Embeddable and @MappedSuperclass. That constructor is synthetic — you cannot call it from Kotlin or Java, only through reflection, which is exactly what Hibernate wants.
plugins {
kotlin("plugin.spring") version "2.2.20"
kotlin("plugin.jpa") version "2.2.20"
}
allOpen { annotation("com.acme.OpenForProxy") } // custom annotation
noArg {
annotation("com.acme.NoArgEntity")
invokeInitializers = true // also run init blocks
}
Without kotlin-spring you either get "Cannot subclass final class", or — far more dangerous — @Transactional silently does nothing and no transaction is opened. Your unit tests pass and production ends up with half-written data.
Without kotlin-jpa you get InstantiationException or "No default constructor for entity" at startup or on the first query.
The rule: in any Kotlin + Spring project these two lines are the first thing you check in build.gradle.kts.
7.2 The shape of a service
@SpringBootApplication
class ShopApplication
fun main(args: Array<String>) {
runApplication<ShopApplication>(*args)
}
@Service
class OrderService(
private val repository: OrderRepository, // constructor injection, no @Autowired
private val payments: PaymentGateway,
) {
@Transactional
fun place(cmd: PlaceOrder): Order =
repository.save(Order(customerId = cmd.customerId, total = cmd.total))
}
runApplication<T>(*args) is a reified extension — the equivalent of SpringApplication.run(ShopApplication.class, args) without the class literal. Constructor injection is entirely natural in Kotlin: no @Autowired, no mutable fields, everything val and non-null. It is the same best practice from spring-core, except here the language forces you into it.
7.3 @ConfigurationProperties with a data class
@ConfigurationProperties("shop.payments")
data class PaymentProperties(
val baseUrl: URI,
val timeout: Duration = Duration.ofSeconds(5),
val retries: Int = 3,
val credentials: Credentials,
) {
data class Credentials(val clientId: String, val clientSecret: String)
}
shop:
payments:
base-url: https://payments.internal/api
timeout: 10s
credentials:
client-id: shop-svc
client-secret: ${PAYMENTS_SECRET}
With a data class and vals, binding goes through the constructor (@ConstructorBinding is implicit), configuration becomes immutable, and Kotlin default values work. If a required value is missing from the YAML the application fails at startup — not three hours later in production with an NPE. Mark the main class with @ConfigurationPropertiesScan.
7.4 Coroutine controllers
@RestController
@RequestMapping("/api/orders")
class OrderController(private val service: OrderService) {
@GetMapping("/{id}")
suspend fun get(@PathVariable id: Long): OrderDto =
service.find(id) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping(produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun stream(): Flow<OrderDto> = service.streamAll()
}
Spring adapts suspend to Mono and Flow to Flux for you. suspend methods are also supported in plain MVC, where they run on the thread-per-request model.
suspend is not magic. If you call jdbcTemplate.query(...) directly inside a suspend function you have blocked an event-loop thread and the whole service buckles. You must explicitly write withContext(Dispatchers.IO) { ... } or move to R2DBC. To catch this during development, enable BlockHound in your test profile; it fails loudly on any blocking call made on a non-blocking thread.
7.5 Testing: MockK and Kotest
Why Mockito hurts in Kotlin: classes are final (you need mock-maker-inline), any() returns null which does not fit a non-null parameter, and mocking extension functions or objects is nearly impossible. MockK was written for Kotlin:
class OrderServiceTest {
private val repo = mockk<OrderRepository>()
private val service = OrderService(repo)
@Test
fun `saves order`() {
every { repo.save(any()) } answers { firstArg<Order>().copy(id = 1) }
val result = service.place(PlaceOrder(customerId = 7, total = BigDecimal.TEN))
assertEquals(1L, result.id)
verify(exactly = 1) { repo.save(any()) }
}
@Test
fun `suspend calls work too`() = runTest {
coEvery { repo.findAsync(1) } returns Order(id = 1)
assertNotNull(service.findAsync(1))
}
}
coEvery/coVerify handle suspend functions, mockkStatic handles static methods and mockkObject handles objects. In Spring, the SpringMockK library provides @MockkBean and @SpykBean, the equivalents of @MockitoBean and @MockitoSpyBean.
Kotest is an alternative test framework with several spec styles and fluent matchers:
class SlugSpec : StringSpec({
"slugify lowercases and dashes" {
"Hello World".toSlug() shouldBe "hello-world"
}
})
Keep JUnit 5 (your existing Java tests run on it) and replace only Mockito with MockK — or keep both for a while so the migration stays gradual. Bring in Kotest when the team is genuinely Kotlin-first and would benefit from property-based testing. Adding two test frameworks to a team that is not yet comfortable with Kotlin only creates resistance. Testcontainers and the rest of your test infrastructure work unchanged (testing).
7.6 Serialization, and one SQL note at the boundary
If jackson-module-kotlin is on the classpath, Spring Boot registers it automatically. Without it, Jackson does not understand Kotlin constructors or default values and — worse — can write null straight into a non-nullable property through reflection, producing an "impossible" NPE. Three defences: always have the Kotlin module; put @Valid and validation annotations on inbound DTOs (validation-mapstruct); and explicitly test deserialization of an incomplete payload. Details in jackson-json. Spring Boot 4 also ships a spring-boot-kotlinx-serialization-json starter; when it coexists with Jackson it only handles types annotated @Serializable.
One small but daily benefit of Kotlin is that raw SQL no longer has to be glued together with +:
SELECT id, total, created_at
FROM orders
WHERE customer_id = :cid
ORDER BY created_at DESC
OFFSET :skip LIMIT :take;SELECT id, total, created_at
FROM orders
WHERE customer_id = :cid
ORDER BY created_at DESC
OFFSET :skip ROWS FETCH FIRST :take ROWS ONLY;A small but frequent gotcha: you cannot call trimIndent() inside an annotation, because annotations accept only compile-time constants — so the multiline string inside @Query stays raw. Extra whitespace is harmless in SQL but not in whitespace-sensitive text. And the dialect differences remain what they always were; see sql-mastery and oracle-postgres-dialects.
Name five things: (1) compiler plugins — kotlin-spring (all-open) is mandatory because classes are final and AOP/@Transactional need proxies, plus kotlin-jpa (no-arg) for entities. (2) Modelling — data class for DTOs and @ConfigurationProperties, but a plain class for JPA entities. (3) Injection — constructor only, all val, no @Autowired. (4) Null safety at the boundaries — jackson-module-kotlin, JSpecify on internal Java APIs, and -Xjsr305=strict. (5) Testing — MockK instead of Mockito.
If WebFlux is in play, add: suspend controllers, Flow↔Flux conversion, and context propagation for MDC/tracing via spring.reactor.context-propagation=auto.
8. Idiomatic style and the classic Java-developer mistakes
The list below is what you actually see over and over in real code reviews:
!!instead of thinking — every!!means "I silenced the compiler". Use?:,requireNotNullwith a message, or redesign the type.- Everything is
var— Kotlin classes written exactly like Java POJOs. If your class is only data, make it adata classwithvals. - A
Utilsclass made ofobject—object StringUtils { ... }instead of extensions or top-level functions. data classfor JPA entities — the most dangerous item on this list.Optional<T>instead ofT?— an extra boxing layer and unreadable. KeepOptionalonly at the Java boundary and convert toT?immediately.- Nested
if/elseinstead ofwhen—whenis both an expression and an exhaustiveness check. - Hand-rolled loops instead of collection operators.
GlobalScope.launch— in production, almost always a bug.runBlockingin a service — throws away the entire benefit of coroutines.- Nested
?.letchains — more than two levels means it is time to extract a named function. companion objectas a static dumping ground — with 15 constants and 8 methods it is the old God class. File-levelprivate const valand extensions are usually cleaner.- Ignoring
internal— if you have modules, it draws the API boundary far more clearly than packages. catch (e: Exception)without rethrowingCancellationException— a genuine bug in coroutine code.applyfor everything —applyis for configuration; if you want a value, useletorrun.
Three tools worth setting up: ktlint or ktfmt for automatic formatting (it removes style debates from code review), detekt for quality rules (you can forbid !!, cap cyclomatic complexity and reject GlobalScope), and explicitApi() in library modules.
The senior rule: anything repeated three times in code review should become an automated rule in CI (cicd-pipelines).
9. A migration strategy for an existing codebase
Nobody will let you freeze product development for six months to rewrite 200,000 lines. A correct migration is like replacing the rails under a moving train: one section at a time, always with a way back.
Diagram: مسیر مهاجرت تدریجی از Java به Kotlin · Incremental migration path from Java to Kotlin.
flowchart LR
A["1. Add Kotlin plugin<br/>zero .kt files"] --> B["2. New tests in Kotlin"]
B --> C["3. New code in Kotlin<br/>leaf classes first"]
C --> D["4. Convert DTOs, value objects,<br/>utils to Kotlin"]
D --> E["5. Convert services<br/>keep entities last"]
E --> F["6. Optional: coroutines<br/>only where they pay"]
Step 1 — add the plugin with zero Kotlin files. Just add kotlin("jvm") to the build and make sure CI stays green. Build time rises a little; measure exactly that and report it to the team. Zero risk, and it breaks the organisational resistance.
Step 2 — write new tests in Kotlin. Tests are consumers of the API, not part of it; if they break, production is unaffected. This is where the team learns data class, named arguments and multiline strings in the safest possible environment — and backtick test names are a nice carrot: fun `rejects order when credit limit exceeded`().
Step 3 — write new code in Kotlin, starting from the leaves. Classes with few dependencies that nobody inherits from.
Step 4 — convert DTOs, value objects and utility classes. The most benefit for the least risk. The IDE's "Convert Java File to Kotlin" is a fine starting point, but never merge its output unreviewed; it is usually full of !!, var and gratuitously nullable types.
Step 5 — convert services and the web layer; leave entities for last. Entities interact most with reflection and proxies and gain least from Kotlin.
Step 6 — introduce coroutines only where they pay for themselves. Migrating to coroutines is a separate architectural decision, not part of the language migration.
| Stage | Risk | Benefit | Reversibility |
|---|---|---|---|
| Add the plugin | negligible | none (preparation) | total |
| Kotlin tests | very low | team learning | total |
| New leaf classes | low | less code, null-safe | easy |
| DTOs / value objects | low | removes a lot of boilerplate | easy |
| Services | medium | readability, null safety | medium |
| JPA entities | high | low | hard |
| Coroutines | high | depends on the load | hard |
1. Build time. Kotlin compilation is slower than javac, especially for a clean build. K2 improved it a lot and incremental builds are good, but expect full builds to get longer. kapt (Kotlin's annotation processor) is the slowest part; migrate to KSP wherever possible — it is several times faster.
2. Surrounding tooling. Code coverage (JaCoCo), static analysis (SonarQube), mutation testing and lint rules all need to be verified to understand Kotlin. They usually do, but they need configuration.
3. Team skill. Bad Kotlin is worse than good Java. Without training you end up with one codebase in two dialects. Before step 3, write a team style guide and turn on detekt.
The first and most important point: never a big-bang rewrite. Kotlin's bidirectional interop exists precisely for this.
My order: add the plugin and keep the build green; write new tests in Kotlin so the team learns; new code and leaf classes; DTOs and utility classes (most benefit, least risk); services; and entities last, because they interact most with reflection.
Metrics I track: build time (especially if we use kapt), the number of !! in the codebase as a quality indicator, and the production NPE rate. Plus one explicit decision: coroutines are not part of this migration — that is a separate architectural choice justified only by a concrete performance problem.
Several real cases: a team where nobody knows Kotlin and which is mid-way through a critical deadline — the learning cost at the worst possible time. A project heavily tied to legacy annotation processing and kapt where build time is already a problem. A library whose main consumers are Java teams that will not pay the maintenance cost of @JvmOverloads/@JvmStatic and interop tests. And an organisation whose internal tooling — code generation, linters, static analysis — only understands Java.
Weak answer: "Kotlin is always better." Strong answer: "Kotlin is a good choice for most new backend projects, but a language is an organisational decision, not a matter of taste — and the team pays the cost, not me."
10. How to talk about Kotlin in an interview when Java is your main language
The biggest mistake is pretending to experience you do not have; the interviewer will find out in two questions. The winning approach has three parts.
Be honest and reframe. "My production experience is mostly Java. I can write services and tests in Kotlin and I know its mental model — null safety as part of the type system, coroutines as suspension rather than threads, and the fact that in Spring nothing works properly without the all-open and no-arg plugins. Since both run on the same JVM, my knowledge of GC, transactions and system design transfers unchanged." That sentence shows three things: honesty, real depth, and low hiring risk.
Pull the question toward your strong ground. If asked "how do coroutines work", explain CPS and then connect it to virtual threads and the thread model.
Have one real example ready, even a small personal project: "the first thing I learned was that @Transactional silently does nothing without the kotlin-spring plugin." That kind of detail proves you actually wrote code rather than read an article.
A deliberate design based on "design for inheritance, or prohibit it". Inheriting from a class not designed for inheritance is the most fragile form of coupling: the base-class author can no longer change the internals without breaking subclasses. Kotlin made the safe option the default; opening a class requires writing open, which is a conscious decision.
The practical consequence in Spring: CGLIB proxies need subclasses, so the all-open plugin is required. The alternatives are interface-based proxies or not relying on AOP at all.
One more difference that burns people often: in Kotlin a == b compiles to a?.equals(b) ?: (b === null) — structural, null-safe equality — and referential equality is written ===. That is the exact opposite of Java. The good news: comparing Strings with == in Kotlin is correct. And never use === to compare values; on boxed Ints it depends on the JVM's small-value cache, the same Integer cache trap as Java.
Two of them. First, copy() is a shallow copy. If a field is a MutableList, the copy shares the same list and a change in one is visible in the other — your "immutability" is an illusion. The fix: keep collection fields read-only and take a toList() in the constructor.
Second, copy() runs the init block with the new values, which is good — but if you put validation outside the constructor (in a factory method, say), copy() bypasses it and you can construct an invalid object. The rule: always validate value objects in the init block, never only in a factory.
Short answer: I do not trust it; I make the boundary explicit. Three layers: (1) if the Java code is ours, we annotate it with JSpecify — @NullMarked on the package and @Nullable on the exceptions; the Kotlin compiler honours it and stops producing platform types. (2) If it is a third-party library, I write an explicit type on the line where the value enters (val x: Foo?) so that an NPE — if it must happen — happens at the boundary rather than twenty frames deeper. (3) I turn on -Xjsr305=strict so existing annotations are actually enforced.
Closing line: "Platform types are the only remaining hole in null safety, and the only way to close it is discipline at the boundary, not hope."
Kotlin is the JVM. Same bytecode, same GC, same ecosystem. Everything you know about the JVM stays valid; only the language layer changes.
Interop is the heart of the story. From the Java side, watch out for platform types and make boundaries explicit with JSpecify; from the Kotlin side, use @JvmStatic, @JvmOverloads, @JvmField, @JvmName and @Throws to make the API pleasant for Java — and write a Java test that proves it.
Null safety is a change of mental model, not of syntax. ?, ?., ?:, as? are the tools; !! and lateinit are debt. NPEs are not eliminated, they are pushed to the boundaries.
The language removes boilerplate: val by default, data class, sealed plus exhaustive when, default and named arguments (the end of builders), extensions, scope functions, by delegation, and inline/reified as the escape hatch from type erasure.
Coroutines are three things: suspend (suspension, not threads), structured concurrency (lifetime bound to a scope), and Flow (a cold stream with free backpressure). GlobalScope and runBlocking in service code are almost always bugs.
Be honest about the concurrency model: virtual threads are enough for Java and ordinary I/O-bound load; coroutines shine when you need Flow, structured cancellation, or Kotlin Multiplatform.
In Spring: the kotlin-spring and kotlin-jpa plugins are non-negotiable; data class for DTOs and configuration, never for entities; constructor injection with val; MockK instead of Mockito.
Make the migration incremental and reversible: plugin → tests → leaf classes → DTOs → services → entities. Coroutines are a separate architectural decision.
Be honest in the interview. Present your Java experience as an asset and show that you have the Kotlin mental model — that is far more convincing than reciting the names of the scope functions.