Kotlin improves on Java for Spring Boot development: data classes replace POJOs, sealed classes model error states explicitly, extension functions add readability, and coroutines provide structured concurrency that’s cleaner than reactive chains. Claude Code writes idiomatic Kotlin — not Java transliterated to Kotlin — with proper use of null safety, coroutines, and the standard library.
CLAUDE.md for Kotlin/Spring Projects
## Stack
- Kotlin 2.x, Spring Boot 3.x
- Reactive: Spring WebFlux with Kotlin coroutines (suspend fun in controllers)
- Database: Spring Data R2DBC (reactive) or Spring Data JPA (blocking)
- Error handling: sealed classes + Result type, not exceptions for business logic
- Null safety: no !! operator in production code — use safe calls and elvis
- Data classes: prefer immutable (val), use copy() for updates
- Coroutines: structured concurrency, always propagate CoroutineScope, never GlobalScope
- Testing: kotest for assertions, MockK for mocking, @SpringBootTest for integration
Application Setup with Coroutines
// Main application
@SpringBootApplication
class OrderApiApplication
fun main(args: Array<String>) {
runApplication<OrderApiApplication>(*args)
}
// application.yml
// spring:
// r2dbc:
// url: r2dbc:postgresql://localhost:5432/orders
// webflux:
// base-path: /api
@Configuration
class AppConfig {
@Bean
fun coroutineExceptionHandler() = CoroutineExceptionHandler { _, exception ->
LoggerFactory.getLogger("CoroutineException").error("Uncaught exception", exception)
}
}
Data Classes and Sealed Classes
// Domain models: immutable data classes
data class Order(
val id: String = UUID.randomUUID().toString(),
val customerId: String,
val status: OrderStatus = OrderStatus.PENDING,
val totalCents: Long,
val items: List<OrderItem> = emptyList(),
val createdAt: Instant = Instant.now(),
)
data class OrderItem(
val productId: String,
val quantity: Int,
val unitPriceCents: Long,
)
enum class OrderStatus { PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED }
// Sealed classes for error modeling — exhaustive when expressions
sealed class OrderError {
data class NotFound(val orderId: String) : OrderError()
data class InvalidStatus(val current: OrderStatus, val attempted: OrderStatus) : OrderError()
data class InsufficientStock(val productId: String, val requested: Int, val available: Int) : OrderError()
object PaymentDeclined : OrderError()
}
// Result type using Kotlin's built-in Result or custom
typealias OrderResult<T> = Result<T> // Result.success() or Result.failure()
// Or a custom Either
sealed class Either<out L, out R> {
data class Left<L>(val value: L) : Either<L, Nothing>()
data class Right<R>(val value: R) : Either<Nothing, R>()
}
WebFlux Controller with Coroutines
// Coroutine-style: suspend functions + CoRouter (or @RestController)
@RestController
@RequestMapping("/api/orders")
class OrderController(
private val orderService: OrderService,
) {
// suspend fun = coroutine, Spring handles the thread switching
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
suspend fun createOrder(
@Validated @RequestBody request: CreateOrderRequest,
@AuthenticationPrincipal principal: UserPrincipal,
): OrderResponse {
return when (val result = orderService.placeOrder(principal.userId, request)) {
is Either.Right -> OrderResponse.from(result.value)
is Either.Left -> when (val err = result.value) {
is OrderError.InsufficientStock ->
throw ResponseStatusException(HttpStatus.CONFLICT, "Insufficient stock for ${err.productId}")
is OrderError.PaymentDeclined ->
throw ResponseStatusException(HttpStatus.PAYMENT_REQUIRED, "Payment declined")
else ->
throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Order failed")
}
}
}
@GetMapping("/{id}")
suspend fun getOrder(
@PathVariable id: String,
@AuthenticationPrincipal principal: UserPrincipal,
): OrderResponse {
val order = orderService.findByIdForCustomer(id, principal.userId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Order $id not found")
return OrderResponse.from(order)
}
// Streaming endpoint using Flow
@GetMapping("/stream", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun orderStatusStream(@AuthenticationPrincipal principal: UserPrincipal): Flow<ServerSentEvent<OrderStatusUpdate>> {
return orderService.watchOrders(principal.userId)
.map { update -> ServerSentEvent.builder(update).build() }
}
}
// DSL-style routing alternative (for functional style)
@Configuration
class RouterConfig(private val orderHandler: OrderHandler) {
@Bean
fun routerFunction() = coRouter {
POST("/api/orders", orderHandler::createOrder)
GET("/api/orders/{id}", orderHandler::getOrder)
}
}
Service with Coroutines and R2DBC
@Service
class OrderService(
private val orderRepo: OrderRepository,
private val inventoryClient: InventoryClient,
private val paymentClient: PaymentClient,
) {
suspend fun placeOrder(customerId: String, request: CreateOrderRequest): Either<OrderError, Order> {
// Parallel calls with coroutines
val (inventoryCheck, customerCheck) = coroutineScope {
val inv = async { inventoryClient.checkAvailability(request.items) }
val cust = async { orderRepo.findCustomer(customerId) }
inv.await() to cust.await()
}
// Check availability
inventoryCheck.unavailableItems.firstOrNull()?.let { item ->
return Either.Left(OrderError.InsufficientStock(item.productId, item.requested, item.available))
}
// Create order
val order = Order(
customerId = customerId,
totalCents = request.items.sumOf { it.quantity.toLong() * it.unitPriceCents },
items = request.items.map { OrderItem(it.productId, it.quantity, it.unitPriceCents) },
)
// Process payment
val payment = paymentClient.charge(customerId, order.totalCents, request.paymentMethodId)
if (!payment.success) return Either.Left(OrderError.PaymentDeclined)
val savedOrder = orderRepo.save(order.copy(status = OrderStatus.CONFIRMED))
return Either.Right(savedOrder)
}
fun watchOrders(customerId: String): Flow<OrderStatusUpdate> =
orderRepo.watchByCustomer(customerId)
.map { OrderStatusUpdate(it.id, it.status) }
.catch { e -> emit(OrderStatusUpdate.error(e.message ?: "Unknown error")) }
}
R2DBC Repository
@Repository
interface OrderRepository : CoroutineCrudRepository<Order, String> {
// Spring Data generates the query from the method name
suspend fun findByCustomerIdAndStatus(customerId: String, status: OrderStatus): List<Order>
// Custom query
@Query("SELECT * FROM orders WHERE customer_id = :customerId ORDER BY created_at DESC LIMIT :limit")
fun findRecentByCustomer(customerId: String, limit: Int): Flow<Order>
// Reactive change stream (database-specific)
@Query("SELECT * FROM orders WHERE customer_id = :customerId")
fun watchByCustomer(customerId: String): Flow<Order>
}
Extension Functions for Readability
// Extension functions: add methods to existing types
fun Order.toResponse() = OrderResponse(
id = id,
customerId = customerId,
status = status.name,
totalDollars = totalCents / 100.0,
createdAt = createdAt.toString(),
)
// Extension on String for parsing
fun String.toOrderStatus(): OrderStatus? = runCatching {
OrderStatus.valueOf(uppercase())
}.getOrNull()
// Scope functions: let, apply, also, run, with
val enrichedOrder = order.copy(
items = order.items.map { item ->
item.also { log.debug("Processing item: ${it.productId}") }
}
)
// Inline lambda arguments (trailing lambda syntax)
val totalRevenue = orders.sumOf { it.totalCents }
val confirmedOrders = orders.filter { it.status == OrderStatus.CONFIRMED }
val orderMap = orders.associateBy { it.id }
Testing with Kotest and MockK
// OrderServiceTest.kt
class OrderServiceTest : DescribeSpec({
val orderRepo = mockk<OrderRepository>()
val inventoryClient = mockk<InventoryClient>()
val paymentClient = mockk<PaymentClient>()
val service = OrderService(orderRepo, inventoryClient, paymentClient)
describe("placeOrder") {
context("when payment succeeds") {
it("saves the order with CONFIRMED status") {
coEvery { inventoryClient.checkAvailability(any()) } returns InventoryResult(emptyList())
coEvery { paymentClient.charge(any(), any(), any()) } returns PaymentResult(success = true)
coEvery { orderRepo.save(any()) } answers { firstArg() }
val result = service.placeOrder("cust-1", createOrderRequest())
result.shouldBeInstanceOf<Either.Right<Order>>()
result.value.status shouldBe OrderStatus.CONFIRMED
coVerify { orderRepo.save(match { it.customerId == "cust-1" }) }
}
}
context("when inventory is insufficient") {
it("returns InsufficientStock error without charging") {
coEvery { inventoryClient.checkAvailability(any()) } returns
InventoryResult(listOf(UnavailableItem("prod-1", 5, 2)))
val result = service.placeOrder("cust-1", createOrderRequest())
result.shouldBeInstanceOf<Either.Left<OrderError>>()
result.value.shouldBeInstanceOf<OrderError.InsufficientStock>()
coVerify(exactly = 0) { paymentClient.charge(any(), any(), any()) }
}
}
}
})
For the gRPC service patterns that Kotlin Spring apps can expose alongside REST, see the Protocol Buffers guide. For reactive stream processing that complements Kotlin Flow, the RxJS reactive guide covers the same concepts in the browser context. The Claude Skills 360 bundle includes Kotlin skill sets covering coroutines, sealed classes, WebFlux patterns, and Kotest. Start with the free tier to try Kotlin Spring Boot generation.