Basics
The things that change your day immediately. Kotlin pushes you toward
immutability: val is an immutable reference, the equivalent of final, var is
mutable. With type inference you rarely write explicit types locally, and the
code gets tighter without turning cryptic.
Equality trips muscle memory. == is structural equality and calls equals,
=== checks reference identity. That is what we usually meant in Java, but the
operators sit the other way round from what your fingers expect.
Collections are where half-remembered facts live. Kotlin separates read-only
interfaces from mutable ones: listOf gives you a read-only List,
mutableListOf a mutable one. Read-only here means no mutators in the type, not
a deeply immutable persistent structure. It lets you state intent at the type
level and avoid accidental changes, and that is all it promises.
Lambdas you will use constantly. A single-parameter lambda can use it
implicitly, which keeps pipelines compact as long as you do not stack them. With
trailing-lambda syntax, filter and map end up quieter than Java Streams.
Functions are declared with fun, and the language keeps nudging you toward
expressions. You catch yourself returning a value where you would have set a
mutable var, because it reads better.
val names = listOf("Ana", "Bob", "Clara")
val shortUpper = names.filter { it.length <= 4 }.map { it.uppercase() }
Null-safety
Kotlin puts nullability into the type. String cannot be null, String? can,
and the compiler makes you handle absence at the point where it can happen. You
get the safe-call operator ?. for “call only if non-null”, the Elvis operator
?: for defaults, and !! for “I know this is not null, crash if I am wrong”.
val domain = user.email?.substringAfter('@') ?: "unknown"
Java interop is where that gets thinner. Types coming from Java arrive as
platform types, and the compiler does not know their nullability. What works:
design your Kotlin APIs with honest nullability, annotate the Java code you own
with @Nullable and @NonNull, and lean on constructor injection so your
Spring beans are non-null by construction. That combination keeps most of your
code NPE-free without gymnastics.
Default parameters
No telescoping constructors, no dozen overloads. You define sensible defaults
and the call site reads like documentation. When Java callers need your Kotlin
API, @JvmOverloads generates the Java-style overloads. Inside Kotlin, defaults
plus named arguments cover it 99% of the time.
Scope functions
They are everywhere and easy to overdo. The mental model that stuck for me:
letpasses the value as it and returns the block’s result, good for null-safety and small transformations.applyruns a block with this as the receiver and returns the receiver, good for configuration.alsopasses it and returns the receiver, use it for side effects like logging.runandwithareletwiththis, returning the block result.
Used thoughtfully they read cleanly. Nest them recklessly and your teammates will invent creative names for you. My rule of thumb: when a lambda grows beyond a line or two, give the parameter a name and stop leaning on it.
val request = HttpRequest().apply {
method = "POST"
path = "/users"
}.also { logger.debug("Built request {}", it) }
With Spring Boot
Almost everything works the same. MVC or WebFlux, Spring Data JPA, Spring Security with OAuth2 or Keycloak: your existing knowledge carries straight across. The differences are idiomatic and mostly in your favour.
Constructor injection becomes your default everywhere. Kotlin’s primary constructors make DI neat and self-documenting, and you will not miss Lombok.
@Service
class InvoiceService(
private val repo: InvoiceRepository,
private val clock: Clock
) {
fun totalFor(customerId: Long) =
repo.findByCustomerId(customerId).sumOf { it.amount }
}
For configuration, @Value is fine for simple scalars and clumsy for lists and
maps, and it costs you type-safe binding and metadata. Use a separate class
annotated with @ConfigurationProperties and bind through the constructor. It
reads cleanly in Kotlin, supports defaults and works with validation. That pays
off the first time you have to evolve the config surface.
JPA has a few gotchas, all of them fixable with plugins. Kotlin classes are final by default, while Hibernate wants to proxy entities and wants a no-arg constructor. The kotlin-spring and kotlin-jpa Gradle plugins open the right classes and generate the constructors.
One rule I would not bend: entities are not data classes. Data classes generate equals and hashCode from the properties, which fights JPA’s identity model and its proxies. Data classes for DTOs and API payloads, regular classes for entities.
The security DSL reads nicely in Kotlin thanks to lambdas, but it is the same Spring Security underneath. Your OAuth2 and Keycloak setup and your JWT story carry over unchanged.
A few habits kept my Kotlin and Spring codebase pleasant to work in:
- Prefer explicit names over heroic chains. Introducing a local
valto break a pipeline is fine. - Use named arguments when a call would otherwise be ambiguous. Multiple booleans are the classic trap.
- Keep nullability honest at module boundaries. If something can be absent,
model it as
T?and let the compiler guide you. - For Java interop, annotate thrown exceptions with
@Throwswhen you expect Java callers to handle them, and expose defaults with@JvmOverloadswhen you publish Kotlin APIs to Java code.
The verdict
Null-safety removes a whole class of bugs, and that is the win. Default parameters and named arguments make APIs simpler to design and safer to call. Data classes take the DTO boilerplate out. In Spring Boot specifically, constructor injection plus type-safe configuration plus Jackson’s Kotlin module let you drop Lombok without growing ceremony somewhere else.
What to watch is readability. Kotlin hands you a lot of stylistic tools and the
slide from expressive to clever is short. Nested scope functions, dense
pipelines and it everywhere turn code into a puzzle. Agree on a style in the
team: when apply and when let, when to extract a local name, how many
chained operations are acceptable, how explicit you want to be with parameters.
If you write some TypeScript, a lot of this will feel familiar. Default parameters, non-null types by default, and the same bias toward making the common path simple. That makes the switch smoother for anyone hopping between frontend and backend.
After a focused week, Kotlin earns its spot in a backend Spring project. It reduces noise without magic, and the type system catches things my tests missed. The framework side carries over unchanged, minus a couple of Kotlin-aware plugins and the Jackson module. Take the obvious wins, null-safety, default parameters and constructor injection, and treat the fancier stylistic tools as optional.

