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: