If Java is your responsible older sibling who wears a suit to family gatherings, Kotlin is the younger one who shows up in sneakers and somehow still impresses everyone.
Spring Boot + Java works great. But Spring Boot + Kotlin? That's like upgrading from a reliable matatu to one with working AC, phone chargers, and a conductor who actually gives change without attitude.
Kotlin brings null safety, data classes, extension functions, and way less boilerplate to your Spring Boot APIs. Today we're building a production-ready REST API that would take 2x the code in Java. This follows API-first development principles — build the backend right, then connect any frontend.
Why Kotlin for Spring Boot?
Less boilerplate:
// Kotlin data class
data class User(val id: Long, val name: String, val email: String)
// Equivalent Java code (ugh)
public class User {
private final Long id;
private final String name;
private final String email;
public User(Long id, String name, String email) { ... }
public Long getId() { ... }
public String getName() { ... }
public String getEmail() { ... }
@Override public boolean equals(Object o) { ... }
@Override public int hashCode() { ... }
@Override public String toString() { ... }
// 50+ lines for what Kotlin does in 1
}
Null safety built-in: No more NullPointerExceptions at 3 AM.
Extension functions: Add methods to existing classes without inheritance. For advanced patterns, see Kotlin DSL builders.
Coroutines support: Better async handling than CompletableFuture. Learn more in Kotlin coroutines for Android.
Setup: Create Spring Boot + Kotlin Project
Using Spring Initializr (start.spring.io):
Select:
- Language: Kotlin
- Spring Boot: 3.2.x
- Dependencies:
- Spring Web - Spring Data JPA - H2 Database (for development) - Validation
Or use this build.gradle.kts:
plugins {
kotlin("jvm") version "1.9.22"
kotlin("plugin.spring") version "1.9.22"
kotlin("plugin.jpa") version "1.9.22"
id("org.springframework.boot") version "3.2.2"
id("io.spring.dependency-management") version "1.1.4"
}
group = "com.example"
version = "1.0.0"
java.sourceCompatibility = JavaVersion.VERSION_17
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "17"
}
}
Building a Product Management API
Let's build a complete API for managing products.
1. Entity (Domain Model):
// src/main/kotlin/com/example/api/model/Product.kt
package com.example.api.model
import jakarta.persistence.*
import jakarta.validation.constraints.*
import java.math.BigDecimal
import java.time.LocalDateTime
@Entity
@Table(name = "products")
data class Product(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(nullable = false)
@NotBlank(message = "Product name is required")
var name: String,
@Column(length = 1000)
var description: String? = null,
@Column(nullable = false)
@Min(value = 0, message = "Price must be positive")
var price: BigDecimal,
@Column(nullable = false)
@Min(value = 0, message = "Stock cannot be negative")
var stock: Int = 0,
@Column(nullable = false)
val createdAt: LocalDateTime = LocalDateTime.now(),
var updatedAt: LocalDateTime = LocalDateTime.now()
)
2. Repository (Data Access):
// src/main/kotlin/com/example/api/repository/ProductRepository.kt
package com.example.api.repository
import com.example.api.model.Product
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface ProductRepository : JpaRepository<Product, Long> {
fun findByNameContainingIgnoreCase(name: String): List<Product>
fun findByPriceLessThan(price: BigDecimal): List<Product>
}
3. DTOs (Data Transfer Objects):
// src/main/kotlin/com/example/api/dto/ProductDTOs.kt
package com.example.api.dto
import jakarta.validation.constraints.*
import java.math.BigDecimal
data class CreateProductRequest(
@field:NotBlank(message = "Name is required")
val name: String,
val description: String? = null,
@field:Min(value = 0, message = "Price must be positive")
val price: BigDecimal,
@field:Min(value = 0, message = "Stock cannot be negative")
val stock: Int = 0
)
data class UpdateProductRequest(
val name: String? = null,
val description: String? = null,
val price: BigDecimal? = null,
val stock: Int? = null
)
data class ProductResponse(
val id: Long,
val name: String,
val description: String?,
val price: BigDecimal,
val stock: Int,
val createdAt: String,
val updatedAt: String
)
data class ErrorResponse(
val message: String,
val errors: Map<String, String>? = null
)
4. Service Layer:
// src/main/kotlin/com/example/api/service/ProductService.kt
package com.example.api.service
import com.example.api.dto.*
import com.example.api.model.Product
import com.example.api.repository.ProductRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
class ProductService(private val repository: ProductRepository) {
fun getAllProducts(): List<ProductResponse> {
return repository.findAll().map { it.toResponse() }
}
fun getProductById(id: Long): ProductResponse {
val product = repository.findById(id)
.orElseThrow { ProductNotFoundException("Product with id $id not found") }
return product.toResponse()
}
fun searchProducts(query: String): List<ProductResponse> {
return repository.findByNameContainingIgnoreCase(query)
.map { it.toResponse() }
}
@Transactional
fun createProduct(request: CreateProductRequest): ProductResponse {
val product = Product(
name = request.name,
description = request.description,
price = request.price,
stock = request.stock
)
return repository.save(product).toResponse()
}
@Transactional
fun updateProduct(id: Long, request: UpdateProductRequest): ProductResponse {
val product = repository.findById(id)
.orElseThrow { ProductNotFoundException("Product with id $id not found") }
// Apply updates only for non-null fields
request.name?.let { product.name = it }
request.description?.let { product.description = it }
request.price?.let { product.price = it }
request.stock?.let { product.stock = it }
product.updatedAt = LocalDateTime.now()
return repository.save(product).toResponse()
}
@Transactional
fun deleteProduct(id: Long) {
if (!repository.existsById(id)) {
throw ProductNotFoundException("Product with id $id not found")
}
repository.deleteById(id)
}
// Extension function for mapping
private fun Product.toResponse() = ProductResponse(
id = id!!,
name = name,
description = description,
price = price,
stock = stock,
createdAt = createdAt.toString(),
updatedAt = updatedAt.toString()
)
}
class ProductNotFoundException(message: String) : RuntimeException(message)
5. Controller (REST Endpoints):
// src/main/kotlin/com/example/api/controller/ProductController.kt
package com.example.api.controller
import com.example.api.dto.*
import com.example.api.service.ProductService
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.validation.FieldError
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/products")
class ProductController(private val productService: ProductService) {
@GetMapping
fun getAllProducts() = productService.getAllProducts()
@GetMapping("/{id}")
fun getProductById(@PathVariable id: Long) = productService.getProductById(id)
@GetMapping("/search")
fun searchProducts(@RequestParam query: String) = productService.searchProducts(query)
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createProduct(@Valid @RequestBody request: CreateProductRequest) =
productService.createProduct(request)
@PutMapping("/{id}")
fun updateProduct(
@PathVariable id: Long,
@Valid @RequestBody request: UpdateProductRequest
) = productService.updateProduct(id, request)
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteProduct(@PathVariable id: Long) = productService.deleteProduct(id)
}
// Global exception handler
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException::class)
fun handleNotFound(ex: ProductNotFoundException): ResponseEntity<ErrorResponse> {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse(message = ex.message ?: "Not found"))
}
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidationErrors(ex: MethodArgumentNotValidException): ResponseEntity<ErrorResponse> {
val errors = ex.bindingResult.allErrors.associate {
val fieldName = (it as FieldError).field
val errorMessage = it.defaultMessage ?: "Invalid value"
fieldName to errorMessage
}
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponse(message = "Validation failed", errors = errors))
}
@ExceptionHandler(Exception::class)
fun handleGenericError(ex: Exception): ResponseEntity<ErrorResponse> {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse(message = "An error occurred: ${ex.message}"))
}
}
6. Application Configuration:
# src/main/resources/application.properties
spring.application.name=product-api
# H2 Database
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# JPA/Hibernate
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
# H2 Console
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
Testing the API
Using curl:
# Create product
curl -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-d '{"name": "Laptop", "description": "Gaming laptop", "price": 150000, "stock": 10}'
# Get all products
curl http://localhost:8080/api/products
# Get product by ID
curl http://localhost:8080/api/products/1
# Search products
curl http://localhost:8080/api/products/search?query=laptop
# Update product
curl -X PUT http://localhost:8080/api/products/1 \
-H "Content-Type: application/json" \
-d '{"price": 140000, "stock": 15}'
# Delete product
curl -X DELETE http://localhost:8080/api/products/1
Kotlin-Specific Advantages
1. Extension Functions:
fun Product.toResponse() = ProductResponse(...)
// Cleaner than static utility methods in Java
2. Null Safety:
request.name?.let { product.name = it } // Only updates if not null
3. Smart Casts:
if (product is DiscountedProduct) {
product.applyDiscount() // Automatically cast
}
4. Named Parameters:
Product(
name = "Laptop",
price = BigDecimal("150000"),
stock = 10
)
Testing with Kotlin
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProductControllerTest {
@Autowired
lateinit var restTemplate: TestRestTemplate
@Test
fun `should create product successfully`() {
val request = CreateProductRequest(
name = "Test Product",
price = BigDecimal("100"),
stock = 5
)
val response = restTemplate.postForEntity(
"/api/products",
request,
ProductResponse::class.java
)
assertEquals(HttpStatus.CREATED, response.statusCode)
assertNotNull(response.body?.id)
assertEquals("Test Product", response.body?.name)
}
@Test
fun `should return 404 for non-existent product`() {
val response = restTemplate.getForEntity(
"/api/products/999",
ErrorResponse::class.java
)
assertEquals(HttpStatus.NOT_FOUND, response.statusCode)
}
}
The Bottom Line
Spring Boot + Kotlin gives you:
- Less boilerplate (data classes, no getters/setters)
- Null safety built-in
- Modern syntax (extension functions, smart casts)
- Full Spring ecosystem support
You just built a production-ready REST API with CRUD operations, validation, exception handling, and proper DTOs - in far less code than Java would require.
Next steps: Add JWT authentication with Spring Security to protect endpoints, or scale to microservices with Spring Boot and Eureka. For a simpler alternative, check out building APIs with PHP.
Sources: