JVM: Kotlin, Scala, Java
Modern patterns, tooling, and best practices for the JVM ecosystem.
Language Comparison
| Aspect |
Kotlin |
Scala 3 |
Java 21+ |
| Paradigm |
Multi-paradigm (OO + FP) |
Functional-first |
OO + FP (records, patterns) |
| Null Safety |
Built-in (?, !!) |
Option / Null |
Annotations (@Nullable) |
| Data Classes |
data class |
case class |
record |
| Pattern Matching |
when + sealed classes |
match + ADTs |
switch + patterns (preview) |
| Coroutines |
First-class (suspend) |
ZIO / Cats Effect / Ox |
Virtual threads (Loom) |
| Type Inference |
Local |
Global (improved in 3) |
Local (var) |
| Compilation |
JVM / JS / Native / WASM |
JVM / JS / Native |
JVM |
| Learning Curve |
Low (Java-like) |
Medium-High |
Low (familiar) |
Kotlin (Recommended for Most Teams)
Idiomatic Patterns
// Null safety
fun findUser(id: UserId): User? = repository.findById(id)
val name = user?.name ?: "Unknown"
// Sealed classes for state machines
sealed interface OrderState {
data class Pending(val createdAt: Instant) : OrderState
data class Confirmed(val confirmedAt: Instant) : OrderState
data class Shipped(val tracking: String) : OrderState
data class Delivered(val deliveredAt: Instant) : OrderState
data class Cancelled(val reason: String) : OrderState
}
// Extension functions
fun User.fullName() = "$firstName $lastName"
// Scope functions
val user = userRepository.findById(id)
?.also { log.info("Found user: ${it.id}") }
?: throw NotFoundException(id)
// Result type (Arrow or custom)
sealed class Result<out T, out E> {
data class Success<out T>(val value: T) : Result<T, Nothing>()
data class Failure<out E>(val error: E) : Result<Nothing, E>()
}
fun createUser(cmd: CreateUserCmd): Result<User, DomainError> =
Email(cmd.email).map { User(cmd.name, it) }
.flatMap { repo.save(it).map { it } }
Coroutines (Structured Concurrency)
// suspend functions = async without callback hell
suspend fun getDashboard(userId: UserId): Dashboard = coroutineScope {
val (profile, orders, recs) = coroutineScope {
async { profileService.get(userId) }
async { orderService.recent(userId) }
async { recService.forUser(userId) }
}.awaitAll()
Dashboard(profile, orders, recs)
}
// Supervision + timeout
withTimeout(5_000) {
supervisorScope {
children.forEach { child ->
launch { child.process() }
}
}
}
// Flow (cold async streams)
fun userUpdates(userId: UserId): Flow<User> = callbackFlow {
val listener = UserListener { offer(it) }
repo.addListener(userId, listener)
awaitClose { repo.removeListener(userId, listener) }
}
Spring Boot 3 + Kotlin
// Controller
@RestController
@RequestMapping("/api/v1/users")
class UserController(private val service: UserService) {
@GetMapping("/{id}")
fun getUser(@PathVariable id: UserId): ResponseEntity<User> =
service.getUser(id).fold(
{ ResponseEntity.notFound().build() },
{ ResponseEntity.ok(it) }
)
@PostMapping
fun createUser(@Valid @RequestBody cmd: CreateUserCmd): ResponseEntity<User> =
ResponseEntity.status(HttpStatus.CREATED).body(service.createUser(cmd))
}
// Configuration
@Configuration
@ConfigurationProperties("app")
data class AppConfig(
val database: DatabaseConfig,
val redis: RedisConfig,
val kafka: KafkaConfig
)
// Virtual threads (Java 21+)
@Bean
fun taskExecutor(): ExecutorService = Executors.newVirtualThreadPerTaskExecutor()
Scala 3 (For FP-Heavy Teams)
Effect Systems
// ZIO 2 (recommended for production)
import zio.*
trait UserRepository {
def findById(id: UserId): IO[RepoError, Option[User]]
def save(user: User): IO[RepoError, Unit]
}
case class UserService(repo: UserRepository) {
def createUser(cmd: CreateUserCmd): ZIO[Any, DomainError, User] =
for {
email <- Email.make(cmd.email).mapError(DomainError.InvalidEmail)
user = User(cmd.name, email)
_ <- repo.save(user).mapError(DomainError.RepoError)
} yield user
}
// Structured concurrency
val dashboard = for {
fiber1 <- profileService.get(userId).fork
fiber2 <- orderService.recent(userId).fork
fiber3 <- recService.forUser(userId).fork
profile <- fiber1.join
orders <- fiber2.join
recs <- fiber3.join
} yield Dashboard(profile, orders, recs)
Type-Level Programming
// Phantom types for compile-time state tracking
trait State
trait Draft extends State
trait Published extends State
case class Article[S <: State](title: String, content: String)
def publish(article: Article[Draft]): Article[Published] =
article.asInstanceOf[Article[Published]] // safe: only called after validation
// GADTs for type-safe DSL
enum Expr[A] {
case Lit(value: Int) extends Expr[Int]
case Add(l: Expr[Int], r: Expr[Int]) extends Expr[Int]
case If[B](cond: Expr[Boolean], t: Expr[B], f: Expr[B]) extends Expr[B]
}
HTTP (http4s + Cats Effect)
object UserRoutes {
def apply[F[_]: Async](service: UserService[F]): HttpRoutes[F] = HttpRoutes.of {
case GET -> Root / "users" / UUIDVar(id) =>
service.getUser(UserId(id)).flatMap {
case Some(user) => Ok(user.asJson)
case None => NotFound()
}
case req @ POST -> Root / "users" =>
req.asJsonDecode[CreateUserCmd].flatMap(cmd =>
service.createUser(cmd).flatMap(Created(_))
)
}
}
Java 21+ (Modern Java)
Records + Pattern Matching
// Immutable data carriers
public record UserId(UUID value) {}
public record User(UserId id, String email, String name, Instant createdAt) {}
// Sealed interfaces for exhaustive matching
public sealed interface OrderState permits Pending, Confirmed, Shipped {
record Pending(Instant createdAt) implements OrderState {}
record Confirmed(Instant confirmedAt) implements OrderState {}
record Shipped(String tracking) implements OrderState {}
}
// Pattern matching (preview)
String describe(OrderState state) = switch (state) {
case Pending(var created) -> "Pending since " + created;
case Confirmed(var confirmed) -> "Confirmed at " + confirmed;
case Shipped(var tracking) -> "Shipped: " + tracking;
};
// Virtual threads (Project Loom)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<User>> futures = userIds.stream()
.map(id -> executor.submit(() -> userService.getUser(id)))
.toList();
// ...
}
Spring Boot 3 + Virtual Threads
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@Bean
public ExecutorService virtualThreadExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
// Controller uses virtual threads automatically for blocking calls
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService service;
@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable UserId id) {
return service.getUser(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
Gradle (Kotlin DSL) — Recommended
// build.gradle.kts
plugins {
id("org.springframework.boot") version "3.2.0"
id("io.spring.dependency-management") version "1.1.4"
kotlin("jvm") version "1.9.0"
kotlin("plugin.spring") version "1.9.0"
kotlin("plugin.serialization") version "1.9.0"
}
dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:3.2.0"))
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("io.arrow-kt:arrow-core:1.2.0")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.kotest:kotest-runner-junit5:5.8.0")
testImplementation("io.kotest:kotest-assertions-core:5.8.0")
testImplementation("io.mockk:mockk:1.13.13")
}
tasks.withType<Test> {
useJUnitPlatform()
testLogging { events("passed", "skipped", "failed") }
}
Maven (if preferred)
<!-- pom.xml -->
<properties>
<java.version>21</java.version>
<kotlin.version>1.9.0</kotlin.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.2.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Database Access
Spring Data (R2DBC for Reactive)
// Kotlin + R2DBC
interface UserRepository : ReactiveCrudRepository<User, UserId> {
fun findByEmail(email: String): Mono<User>
}
// Service
@Service
class UserService(private val repo: UserRepository) {
fun getUser(id: UserId): Mono<User> = repo.findById(id)
fun createUser(cmd: CreateUserCmd): Mono<User> =
Email(cmd.email).fold(
{ Mono.error(DomainError.InvalidEmail(it)) },
{ email -> repo.save(User(UserId.random(), email, cmd.name, Instant.now())) }
)
}
jOOQ (Type-Safe SQL) — Alternative
// Generated code from DSLContext
dsl.insertInto(USERS)
.set(USERS.ID, UUID.randomUUID())
.set(USERS.EMAIL, email)
.set(USERS.NAME, name)
.execute()
val user = dsl.selectFrom(USERS)
.where(USERS.ID.eq(id))
.fetchOptionalInto(User::class.java)
Testing
Kotest (Kotlin)
class UserServiceTest : BehaviorSpec() {
init {
Given("a user repository") {
val repo = mockk<UserRepository>()
val service = UserService(repo)
When("creating a user with valid email") {
val cmd = CreateUserCmd("John", "john@example.com")
every { repo.save(any()) } returns Result.Success(Unit)
Then("user is created") {
val result = service.createUser(cmd)
result.shouldBeSuccess()
}
}
}
}
}
ScalaTest + ZIO Test
object UserServiceSpec extends ZIOSpecDefault {
def spec = suite("UserService")(
test("create user succeeds with valid email") {
for {
repo <- ZIO.service[UserRepository]
service = UserService(repo)
result <- service.createUser(CreateUserCmd("John", "john@example.com"))
.exit
} yield assert(result)(succeeds(anything))
}
).provide(InMemoryUserRepository.layer)
}
JUnit 5 + Mockito (Java)
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock UserRepository repo;
@InjectMocks UserService service;
@Test
void createUser_savesUser() {
when(repo.save(any())).thenReturn(Mono.empty());
StepVerifier.create(service.createUser(new CreateUserCmd("John", "john@example.com")))
.expectNextMatches(u -> u.email().equals("john@example.com"))
.verifyComplete();
}
}
Observability
Micrometer + OpenTelemetry (All Languages)
// Spring Boot 3 auto-configures Micrometer + OTel
@Configuration
class ObservabilityConfig {
@Bean
fun meterFilter(): MeterFilter =
MeterFilter.deny(id -> id.getName().startsWith("jvm."))
}
# application.yml
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
metrics:
tags:
application: ${spring.application.name}
otel:
metrics:
export:
interval: 10s
traces:
exporter: otlp
| Category |
Kotlin |
Scala |
Java |
| Build |
Gradle (Kotlin DSL) |
Mill / sbt |
Gradle / Maven |
| Linting |
detekt + ktlint |
scalafix + scalafmt |
Checkstyle / SpotBugs |
| Formatting |
ktlint |
scalafmt |
google-java-format |
| Testing |
Kotest / JUnit 5 |
munit / ZIO Test / ScalaTest |
JUnit 5 + Mockito |
| Property Testing |
kotest-property |
ScalaCheck |
jqwik |
| DI |
Spring / Koin / Kodein |
ZIO Layer / MacWire |
Spring / Dagger |
| Serialization |
kotlinx.serialization |
circe / uPickle |
Jackson |
| HTTP Client |
ktor.client / Spring WebClient |
sttp / http4s client |
Spring WebClient |
| Observability |
Micrometer + OTel |
OpenTelemetry Java |
Micrometer + OTel |
| Vulnerability Scan |
OWASP Dependency Check |
OWASP Dependency Check |
OWASP Dependency Check |
When to Choose What
| Scenario |
Recommendation |
| Team knows Java, wants modern |
Kotlin + Spring Boot 3 |
| FP-first, complex domain modeling |
Scala 3 + ZIO / Cats Effect |
| Enterprise, long-term support, conservative |
Java 21 LTS + Spring Boot 3 |
| High throughput, low latency |
Java 21 Virtual Threads / Kotlin Coroutines |
| Data engineering, Spark |
Scala 3 |
| Android + Backend shared code |
Kotlin Multiplatform |
| Team split on FP vs OO |
Kotlin (bridges both) |
- Language Agnostic Principles — Universal patterns
- TypeScript JavaScript — JS/TS patterns
- Go — Go patterns
- Rust — Rust patterns
- Architectural Styles — Hexagonal on JVM
- Design Patterns — Patterns in JVM languages
- CI CD Best Practices — Pipeline for JVM
- Test Pyramid — Testing strategy
Sources: Kotlin Docs, Scala 3 Docs, Java 21 Docs, Spring Boot 3 Reference, ZIO Docs, Cats Effect Docs, Arrow Docs, Kotest, ScalaTest, JUnit 5, Micrometer, OpenTelemetry Java, Gradle, Mill, sbt, detekt, ktlint, scalafix, scalafmt, Error Prone