Basics
Let’s start with the basics that actually change your day-to-day. Kotlin pushes
you toward immutability: val is an immutable reference (like final), var is
mutable. With type inference - local code gets tighter without becoming cryptic.
Most of the time, you won’t write explicit types, and that’s fine. Equality is
another small but important difference: ==
means structural equality (calls
equals), while ===
checks reference identity. That matches what we usually
meant in Java, but the operators are different enough to trip muscle memory.
Collections are a frequent source of half-remembered “facts”. Kotlin
distinguishes read-only interfaces and mutable ones. listOf
gives you a
read-only List, mutableListOf
gives you a mutable list. Read-only here means
“no mutators in the type,” not “deeply immutable persistent structure.” It’s a
practical design that lets you communicate intent at the type level and avoid
accidental changes.
Then there are lambdas. You’ll use them a lot. Single-parameter lambdas can use
it
implicitly, which makes pipelines compact but still readable when used
sparingly. Combined with trailing-lambda syntax, higher‑order functions like
filter/map feel less noisy than Java Streams.
Kotlin functions (methods in Java) are literally declared with fun
, and the
language nudges you to write more expression-style code. You’ll notice yourself
returning values instead of setting mutable vars just because it reads better.
Here’s a tiny taste, keeping it simple:
val names = listOf("Ana", "Bob", "Clara")
val shortUpper = names.filter { it.length <= 4 }.map { it.uppercase() }
Null-safety
Now, the headline feature: null-safety. Kotlin bakes nullability into types.
String
is non-null while String?
can be null. The compiler forces you to
deal with 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 the nuclear option !!
for “I know this isn’t null - crash if I’m
wrong.”
val domain = user.email?.substringAfter('@') ?: "unknown"
Interop with Java is where theory meets reality. Types from Java come in as
“platform types,” meaning the compiler doesn’t know their nullability. Practical
fix: design your Kotlin APIs with honest nullability, annotate Java code you own
with @Nullable
/ @NonNull
, and lean on constructor injection so your Spring
beans are non-null by design. That combination keeps most of your code NPE-free
without gymnastics.
Default parameters
Default parameters and named arguments are another big quality-of-life win. No
more telescoping constructors or a dozen overloads. You define sensible
defaults, and call sites read like documentation. When Java callers need to use
your Kotlin APIs, add @JvmOverloads
to generate Java-style overloads. Within
Kotlin, defaults + named args are enough 99% of the time.
Scope functions
Scope functions deserve a quick tour because they’re everywhere and easy to overdo. The mental model that stuck for me:
let
passes the value as it and returns the block’s result - great for null-safety and small transformations.apply
runs a block with this as the receiver and returns the receiver - perfect for configuration.also
passes it and returns the receiver - use it for side effects like logging.run
andwith
are likelet
but withthis
, returning the block result. Used thoughtfully, they make code 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; don’t lean on it everywhere.
A small, non-clever example:
val request = HttpRequest().apply {
method = "POST"
path = "/users"
}.also { logger.debug("Built request {}", it) }
Spring Boot Apps
So how does all this play with Spring Boot? The reassuring answer is: almost everything works the same. MVC or WebFlux, Spring Data JPA, Spring Security with OAuth2 or Keycloak - you can bring your existing knowledge straight across. The differences are idiomatic and mostly helpful.
Constructor injection becomes your default everywhere. Kotlin’s primary constructors make DI neat and self-documenting, and you won’t miss Lombok.
@Service
class InvoiceService(
private val repo: InvoiceRepository,
private val clock: Clock
) {
fun totalFor(customerId: Long) =
repo.findByCustomerId(customerId).sumOf { it.amount }
}
Configuration is where Kotlin nudges you toward better patterns. @Value
is
fine for simple scalars, but it gets clumsy for lists and maps, and you lose
type-safe binding and metadata. Prefer a separate class annotated with
@ConfigurationProperties
and bind via the constructor. It reads cleanly in
Kotlin, supports defaults, and plays nicely with validation. You’ll thank
yourself later when you need to evolve the config surface.
JPA with Kotlin has a few gotchas you can neutralize with the right plugins. Kotlin classes are final by default, but Hibernate needs to proxy entities and likes a no-arg constructor. The kotlin-spring and kotlin-jpa Gradle plugins take care of that by opening the right classes and generating no-arg constructors. One more tip: Don’t make entities data classes. Data classes generate equals/hashCode based on properties, which fights JPA’s identity model and proxies. Use data classes for DTOs and API payloads; keep entities as regular classes.
On the security side, the DSL reads nicely in Kotlin thanks to lambdas, but it’s the same Spring Security under the hood. Your OAuth2/Keycloak setup and JWT story carry over unchanged.
A few habits kept my Kotlin + Spring codebase pleasant to work in:
- Prefer explicit names over heroic chains - it’s fine to introduce a local
val
and break a pipeline. - 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
@Throws
when you expect Java callers to handle them, and expose defaults with@JvmOverloads
when you’re publishing Kotlin APIs to Java code.
Closing thoughts
What feels better than Java? Null-safety is the obvious win - not because it’s trendy, but because it removes a whole class of bugs. Default parameters and named arguments make APIs simpler to design and safer to call. Data classes obliterate DTO boilerplate. And in Spring Boot specifically, constructor injection plus type-safe configuration and Jackson’s Kotlin module let you ditch Lombok without growing ceremony.
What to watch for? Readability. Kotlin gives you many stylistic tools, and it’s
very easy to slide from expressive to clever. Nested scope functions, dense
pipelines, and overuse of it can turn code into a puzzle. Teams should agree on
a style: when to use apply
vs let
, when to extract a local name, how many
chained operations are acceptable, how explicit you want to be with parameters.
Used with restraint, Kotlin’s expressiveness is a superpower. Used
indiscriminately, you’ll find yourself missing Java’s “boring on purpose” vibe.
If you speak some TypeScript, Kotlin will feel cozy: default parameters, non-null types by default, and a general “make the common path simple” ethos. That familiarity makes the switch smoother, especially for folks who hop between frontend and backend.
My verdict after a focused look at it: Kotlin earns its spot in backend Spring projects. It reduces noise without magic, and the type system catches things tests often miss. On the Spring side, your frameworks and mental model carry straight over - add a couple of Kotlin-aware plugins and the Jackson module, and you’re in business. Grab the obvious wins - null-safety, default parameters, constructor injection - and treat the fancier stylistic tools as just that: tools. Keep readability as the north star, and Kotlin gives you less ceremony, more intent, and fewer late-night NPE hunts.