Spring Boot with Kotlin — Complete Backend Development Guide
In this tutorial, you will learn about Spring Boot with Kotlin. We cover key concepts, practical examples, and best practices to help you master this topic.
Spring Boot with Kotlin combines Spring's battle-tested dependency injection, data access, and security frameworks with Kotlin's concise syntax, null safety, and coroutine support for building robust backend applications.
What You Will Learn
- Setting up a Spring Boot project with Kotlin and Gradle
- Defining JPA entities and repositories with Kotlin data classes
- Building REST controllers using Kotlin's expressive syntax
- Implementing Spring Security with JWT authentication
- Writing reactive endpoints with Spring WebFlux and coroutines
- Configuring validation, exception handling, and testing
Why It Matters
Spring Boot is the most widely used Java backend framework, but its verbosity and checked exceptions make Java code feel heavy. Kotlin addresses these pain points directly: data classes replace boilerplate POJOs, null safety eliminates null pointer exceptions, extension functions add behavior without inheritance, and coroutines provide natural async support. When you combine Spring Boot with Kotlin, you get the ecosystem maturity of Spring with the developer productivity of a modern language. This combination is increasingly adopted in production systems at companies like Pinterest, Uber, and Netflix.
Real-World Use
The DodaTech API gateway uses Spring Boot with Kotlin to route requests to microservices. The gateway validates JWT tokens using Spring Security, transforms request payloads using Kotlin's data classes, and forwards requests to Ktor-based microservices using WebClient. The use of coroutines allows the gateway to handle thousands of concurrent requests with minimal thread overhead.
Learning Path
flowchart LR A[Ktor REST API + Testing] --> B[Spring Boot with Kotlin\nYou are here] B --> C[Arrow Functional Programming] style B fill:#f90,color:#fff
Setting Up the Project
Use the Spring Initializr or configure manually with Gradle Kotlin DSL. The minimum requirements are Spring Boot 3.x and Kotlin 1.9+:
// build.gradle.kts
plugins {
id("org.springframework.boot") version "3.2.1"
id("io.spring.dependency-management") version "1.1.4"
kotlin("jvm") version "1.9.22"
kotlin("plugin.spring") version "1.9.22"
kotlin("plugin.jpa") version "1.9.22"
}
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("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test")
}
The kotlin(plugin.spring) plugin automatically opens Kotlin classes (making them non-final) so Spring can create proxies for them. The kotlin(plugin.jpa) plugin enables JPA entity scanning without requiring open modifiers on every class.
Defining JPA Entities
Kotlin data classes work naturally with JPA when you configure default values and use @Table and @Column annotations:
// src/main/kotlin/com/dodatech/blog/entity/Post.kt
import jakarta.persistence.*
import jakarta.validation.constraints.NotBlank
import org.hibernate.annotations.CreationTimestamp
import java.time.Instant
@Entity
@Table(name = "posts")
data class Post(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@NotBlank
@Column(nullable = false)
val title: String = "",
@NotBlank
@Column(nullable = false, columnDefinition = "TEXT")
val content: String = "",
@Column(nullable = false)
val authorId: Long = 0,
@CreationTimestamp
@Column(nullable = false, updatable = false)
val createdAt: Instant = Instant.now(),
@Column(nullable = false)
val published: Boolean = false
)
Data classes provide equals, hashCode, toString, and copy automatically. Default values ensure the entity can be constructed without specifying every field. The @CreationTimestamp annotation sets the timestamp automatically when the entity is persisted.
Creating Spring Data JPA Repositories
Spring Data JPA repositories are defined as interfaces that extend JpaRepository. In Kotlin, you can add extension functions for custom queries:
// src/main/kotlin/com/dodatech/blog/repository/PostRepository.kt
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface PostRepository : JpaRepository<Post, Long> {
fun findByAuthorId(authorId: Long): List<Post>
fun findByPublishedTrue(): List<Post>
fun findByTitleContainingIgnoreCase(keyword: String): List<Post>
}
Spring Data JPA derives the query from the method name. findByTitleContainingIgnoreCase translates to WHERE LOWER(title) LIKE LOWER(CONCAT('%', :keyword, '%')). The @Repository annotation is optional in Spring Boot but clarifies intent.
Building REST Controllers
Spring MVC controllers in Kotlin use the same annotations as Java but benefit from Kotlin's conciseness:
// src/main/kotlin/com/dodatech/blog/controller/PostController.kt
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import java.net.URI
@RestController
@RequestMapping("/api/posts")
class PostController(private val repository: PostRepository) {
@GetMapping
fun getAllPosts(): List<Post> = repository.findByPublishedTrue()
@GetMapping("/{id}")
fun getPostById(@PathVariable id: Long): ResponseEntity<Post> {
val post = repository.findById(id)
return if (post.isPresent) ResponseEntity.ok(post.get())
else ResponseEntity.notFound().build()
}
@PostMapping
fun createPost(@Valid @RequestBody post: Post): ResponseEntity<Post> {
val saved = repository.save(post)
return ResponseEntity.created(URI.create("/api/posts/${saved.id}")).body(saved)
}
@PutMapping("/{id}")
fun updatePost(
@PathVariable id: Long,
@Valid @RequestBody updatedPost: Post
): ResponseEntity<Post> {
if (!repository.existsById(id)) {
return ResponseEntity.notFound().build()
}
val saved = repository.save(updatedPost.copy(id = id))
return ResponseEntity.ok(saved)
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deletePost(@PathVariable id: Long) {
repository.deleteById(id)
}
}
Key Kotlin advantages: ResponseEntity.created(...) uses the URI.create factory method without new keyword. The copy method on the data class creates an updated entity with the correct ID. @Valid triggers Jakarta Bean Validation, which checks the @NotBlank constraints on the Post entity.
Configuring Spring Security with JWT
Spring Security with JWT requires a security configuration class, a JWT utility class, and a filter for token validation:
// src/main/kotlin/com/dodatech/blog/config/SecurityConfig.kt
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
@Configuration
@EnableWebSecurity
class SecurityConfig(
private val jwtAuthFilter: JwtAuthFilter
) {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/posts", "/api/posts/*").hasRole("USER")
.anyRequest().authenticated()
}
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter::class.java)
return http.build()
}
}
The filter chain disables CSRF (stateless APIs do not need it), sets session management to stateless (each request carries its own JWT), and defines public and protected endpoints. The JwtAuthFilter extracts the JWT from the Authorization header, validates it, and sets the authentication context.
JWT Authentication Filter
The filter extracts the token from the request header and validates it:
// src/main/kotlin/com/dodatech/blog/security/JwtAuthFilter.kt
import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
import javax.crypto.SecretKey
@Component
class JwtAuthFilter : OncePerRequestFilter() {
private val secretKey: SecretKey = Keys.hmacShaKeyFor(
System.getenv("JWT_SECRET")?.toByteArray()
?: "default-secret-key-that-is-long-enough-for-hmac-sha256".toByteArray()
)
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val authHeader = request.getHeader("Authorization")
if (authHeader != null && authHeader.startsWith("Bearer ")) {
val token = authHeader.substring(7)
try {
val claims: Claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.payload
val userId = claims.subject
val authentication = UsernamePasswordAuthenticationToken(
userId, null, emptyList()
)
SecurityContextHolder.getContext().authentication = authentication
} catch (e: Exception) {
SecurityContextHolder.clearContext()
}
}
filterChain.doFilter(request, response)
}
}
OncePerRequestFilter ensures the filter executes exactly once per request. The SecurityContextHolder is populated with the authenticated user, which can then be accessed in controllers via @AuthenticationPrincipal or SecurityContextHolder.getContext().
Reactive Endpoints with WebFlux and Coroutines
Spring WebFlux supports Kotlin coroutines natively. Define reactive controllers using suspend functions:
// src/main/kotlin/com/dodatech/blog/controller/ReactivePostController.kt
import org.springframework.web.bind.annotation.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.reactive.asFlow
import org.springframework.data.r2dbc.repository.R2dbcRepository
@RestController
@RequestMapping("/api/reactive/posts")
class ReactivePostController(
private val reactiveRepository: ReactivePostRepository
) {
@GetMapping
suspend fun getAllPosts(): Flow<Post> =
reactiveRepository.findAll().asFlow()
@GetMapping("/{id}")
suspend fun getPostById(@PathVariable id: Long): Post? =
reactiveRepository.findById(id).awaitSingleOrNull()
}
suspend functions in controllers are executed on the WebFlux event loop without blocking threads. Flow replaces Flux as the return type for streaming responses. The reactive stack is ideal for high-concurrency scenarios like real-time dashboards.
Validation and Exception Handling
Use @ExceptionHandler in a @ControllerAdvice to return consistent error responses:
// src/main/kotlin/com/dodatech/blog/exception/GlobalExceptionHandler.kt
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.*
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidationErrors(ex: MethodArgumentNotValidException): ResponseEntity<Map<String, Any>> {
val errors = ex.bindingResult.fieldErrors.associate {
it.field to (it.defaultMessage ?: "Invalid value")
}
return ResponseEntity.badRequest().body(
mapOf("error" to "Validation failed", "details" to errors)
)
}
@ExceptionHandler(NoSuchElementException::class)
fun handleNotFound(ex: NoSuchElementException): ResponseEntity<Map<String, String>> {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
mapOf("error" to ex.message ?: "Resource not found")
)
}
}
Spring automatically invokes the appropriate handler based on the exception type. Validation errors return a 400 status with field-level error messages. Missing resources return 404.
Testing Spring Boot with Kotlin
Spring Boot test support works seamlessly with Kotest:
// src/test/kotlin/com/dodatech/blog/controller/PostControllerTest.kt
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.http.HttpEntity
import org.springframework.http.HttpMethod
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PostControllerTest : StringSpec({
@Autowired
lateinit var restTemplate: TestRestTemplate
"create and retrieve a post" {
val newPost = Post(title = "Test Post", content = "Test content", authorId = 1L)
val createResponse = restTemplate.postForEntity("/api/posts", newPost, Post::class.java)
createResponse.statusCode.value() shouldBe 201
val id = createResponse.body!!.id
val getResponse = restTemplate.getForEntity("/api/posts/$id", Post::class.java)
getResponse.body!!.title shouldBe "Test Post"
}
})
TestRestTemplate (or WebTestClient for reactive tests) makes HTTP requests to the embedded server. The RANDOM_PORT environment avoids port conflicts in CI pipelines.
Running the Application
Start the application with Gradle:
./gradlew bootRun
Test the endpoints with curl:
curl -X POST http://localhost:8080/api/posts \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"title":"Hello Spring","content":"Kotlin + Spring Boot","authorId":1}'
# Returns: {"id":1,"title":"Hello Spring","content":"Kotlin + Spring Boot",...}
curl http://localhost:8080/api/posts
# Returns: [{"id":1,"title":"Hello Spring","content":"Kotlin + Spring Boot",...}]
Common Mistakes
Forgetting to add
kotlin(plugin.spring): Without this plugin, all Kotlin classes are final by default, and Spring cannot create proxies for@Configuration,@Transactional, or@Asyncclasses. The application will fail at startup with proxy-related errors.Using
@Autowiredon constructor parameters withoutrequired = false: Kotlin's null safety requires explicit handling. When using constructor injection, add@Autowired(required = false)for optional dependencies, or better yet, use Kotlin's default parameter values.Not configuring Jackson for Kotlin: Spring Boot uses Jackson for JSON Serialization. Without the
jackson-module-kotlindependency, Jackson cannot deserialize data classes with default parameter values, resulting inInvalidDefinitionException.Mutating JPA entities outside transactions: JPA entities must be modified within a Transaction. Calling setter-like
copy()on a detached entity creates a new instance; you must merge it back usingrepository.save().Blocking calls in WebFlux controllers: Using
repository.findById()(blocking JPA) in a WebFlux controller defeats the purpose of Reactive Programming. Use R2DBC or wrap blocking calls inMono.fromCallable { ... }.subscribeOn(Schedulers.boundedElastic()).
Practice Questions
- Why does the
kotlin(plugin.spring)plugin mark Kotlin classes asopen? - How does
ResponseEntity.created()differ from returning the entity directly in a POST endpoint? - What is the advantage of using Kotlin data classes for JPA entities over traditional Java POJOs?
- How would you add pagination to the
GET /api/postsendpoint using Spring Data'sPageable? - Challenge: Add a comment system to the blog API. Create
Commententity with a@ManyToOnerelationship toPost. Implement a nested REST endpointPOST /api/posts/{id}/comments. Add validation that comment content is not empty.
Mini Project
Build a Task Management API with Spring Boot and Kotlin:
- User registration and login with JWT
- Task entity with title, description, due date, priority (enum), and status (enum)
- CRUD endpoints for tasks with filtering by status and priority
- Pagination and sorting support
- Integration tests using TestRestTemplate and Kotest
- Swagger documentation using springdoc-openapi
FAQ
What is Next
Now that you can build Spring Boot applications with Kotlin, explore advanced topics in Functional Programming with Arrow for writing type-safe, composable business logic. You can also study Testing with Kotest to write comprehensive integration tests, and Reactive Programming with WebFlux for high-concurrency reactive APIs.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro