Skip to content

Spring Boot (Kotlin) Framework Guide

Applies to: Spring Boot 3.x, Kotlin 1.9+, REST APIs, Microservices Use with: .claude/skills/kotlin-guide/SKILL.md


Overview

Spring Boot with Kotlin combines the power of Spring's ecosystem with Kotlin's modern language features. Spring Boot 3.x offers first-class Kotlin support including coroutines, null safety, and DSL-style configuration.

When to Use Spring Boot (Kotlin)

  • Enterprise Applications: Proven enterprise-grade framework
  • Microservices: Excellent with Spring Cloud
  • Existing Spring Ecosystem: Leverage vast Spring libraries
  • Team Familiarity: Teams with Java/Spring background
  • Complex Business Logic: Rich feature set for complex domains

When NOT to Use

  • Lightweight Services: Consider Ktor for simpler services
  • Mobile Backend: Ktor is more lightweight
  • Minimal Dependencies: Spring Boot has a larger footprint

Project Structure

myproject/
├── build.gradle.kts
├── settings.gradle.kts
├── src/
│   ├── main/
│   │   ├── kotlin/
│   │   │   └── com/example/myproject/
│   │   │       ├── MyProjectApplication.kt
│   │   │       ├── config/
│   │   │       │   ├── SecurityConfig.kt
│   │   │       │   └── WebConfig.kt
│   │   │       ├── controller/
│   │   │       │   └── UserController.kt
│   │   │       ├── service/
│   │   │       │   └── UserService.kt
│   │   │       ├── repository/
│   │   │       │   └── UserRepository.kt
│   │   │       ├── model/
│   │   │       │   ├── entity/
│   │   │       │   │   └── User.kt
│   │   │       │   └── dto/
│   │   │       │       └── UserDto.kt
│   │   │       ├── exception/
│   │   │       │   ├── GlobalExceptionHandler.kt
│   │   │       │   └── Exceptions.kt
│   │   │       └── security/
│   │   │           └── JwtService.kt
│   │   └── resources/
│   │       ├── application.yml
│   │       ├── application-dev.yml
│   │       └── db/migration/
│   └── test/
│       └── kotlin/
│           └── com/example/myproject/
│               ├── controller/
│               ├── service/
│               └── integration/
└── README.md

Dependencies

// build.gradle.kts
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "3.2.0"
    id("io.spring.dependency-management") version "1.1.4"
    kotlin("jvm") version "1.9.21"
    kotlin("plugin.spring") version "1.9.21"
    kotlin("plugin.jpa") version "1.9.21"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"

java {
    sourceCompatibility = JavaVersion.VERSION_21
}

repositories {
    mavenCentral()
}

dependencies {
    // Spring Boot
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")

    // Kotlin
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")

    // Coroutines (optional but recommended)
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")

    // Database
    runtimeOnly("org.postgresql:postgresql")

    // JWT
    implementation("io.jsonwebtoken:jjwt-api:0.12.3")
    runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3")
    runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3")

    // Development
    developmentOnly("org.springframework.boot:spring-boot-devtools")

    // Testing
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.springframework.security:spring-security-test")
    testImplementation("io.mockk:mockk:1.13.8")
    testImplementation("com.ninja-squad:springmockk:4.0.2")
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs += "-Xjsr305=strict"
        jvmTarget = "21"
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

Application Entry Point

// src/main/kotlin/com/example/myproject/MyProjectApplication.kt
package com.example.myproject

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class MyProjectApplication

fun main(args: Array<String>) {
    runApplication<MyProjectApplication>(*args)
}

Configuration

# src/main/resources/application.yml
spring:
  application:
    name: myproject
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: ${DB_USERNAME:postgres}
    password: ${DB_PASSWORD:postgres}
    driver-class-name: org.postgresql.Driver
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect
        format_sql: true

server:
  port: 8080
  error:
    include-message: always
    include-binding-errors: always

jwt:
  secret: ${JWT_SECRET:your-256-bit-secret-key-here}
  expiration: 86400000  # 24 hours in milliseconds

logging:
  level:
    com.example.myproject: DEBUG
    org.springframework.security: DEBUG
// src/main/kotlin/com/example/myproject/config/JwtProperties.kt
package com.example.myproject.config

import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties(prefix = "jwt")
data class JwtProperties(
    val secret: String,
    val expiration: Long,
)

Entity and DTOs

// src/main/kotlin/com/example/myproject/model/entity/User.kt
package com.example.myproject.model.entity

import jakarta.persistence.*
import org.hibernate.annotations.CreationTimestamp
import org.hibernate.annotations.UpdateTimestamp
import java.time.Instant
import java.util.*

@Entity
@Table(name = "users")
data class User(
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    val id: UUID? = null,

    @Column(unique = true, nullable = false)
    val email: String,

    @Column(nullable = false)
    val passwordHash: String,

    @Column(nullable = false)
    val name: String,

    @Column(nullable = false)
    val role: String = "USER",

    @CreationTimestamp
    @Column(updatable = false)
    val createdAt: Instant? = null,

    @UpdateTimestamp
    val updatedAt: Instant? = null,
)

// src/main/kotlin/com/example/myproject/model/dto/UserDto.kt
package com.example.myproject.model.dto

import com.example.myproject.model.entity.User
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
import java.time.Instant
import java.util.*

data class CreateUserRequest(
    @field:NotBlank(message = "Email is required")
    @field:Email(message = "Invalid email format")
    val email: String,

    @field:NotBlank(message = "Password is required")
    @field:Size(min = 8, message = "Password must be at least 8 characters")
    val password: String,

    @field:NotBlank(message = "Name is required")
    @field:Size(min = 1, max = 100, message = "Name must be between 1 and 100 characters")
    val name: String,
)

data class UpdateUserRequest(
    @field:Size(min = 1, max = 100, message = "Name must be between 1 and 100 characters")
    val name: String? = null,

    @field:Email(message = "Invalid email format")
    val email: String? = null,
)

data class LoginRequest(
    @field:NotBlank(message = "Email is required")
    @field:Email(message = "Invalid email format")
    val email: String,

    @field:NotBlank(message = "Password is required")
    val password: String,
)

data class LoginResponse(
    val token: String,
    val user: UserResponse,
)

data class UserResponse(
    val id: UUID,
    val email: String,
    val name: String,
    val role: String,
    val createdAt: Instant,
) {
    companion object {
        fun from(user: User): UserResponse = UserResponse(
            id = user.id!!,
            email = user.email,
            name = user.name,
            role = user.role,
            createdAt = user.createdAt!!,
        )
    }
}

Repository

// src/main/kotlin/com/example/myproject/repository/UserRepository.kt
package com.example.myproject.repository

import com.example.myproject.model.entity.User
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.util.*

@Repository
interface UserRepository : JpaRepository<User, UUID> {
    fun findByEmail(email: String): User?
    fun existsByEmail(email: String): Boolean
}

Service Layer

// src/main/kotlin/com/example/myproject/service/UserService.kt
package com.example.myproject.service

import com.example.myproject.exception.ConflictException
import com.example.myproject.exception.NotFoundException
import com.example.myproject.exception.UnauthorizedException
import com.example.myproject.model.dto.*
import com.example.myproject.model.entity.User
import com.example.myproject.repository.UserRepository
import com.example.myproject.security.JwtService
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.*

@Service
class UserService(
    private val userRepository: UserRepository,
    private val passwordEncoder: PasswordEncoder,
    private val jwtService: JwtService,
) {
    @Transactional
    fun createUser(request: CreateUserRequest): UserResponse {
        if (userRepository.existsByEmail(request.email)) {
            throw ConflictException("Email already registered")
        }

        val user = User(
            email = request.email,
            passwordHash = passwordEncoder.encode(request.password),
            name = request.name,
        )

        val savedUser = userRepository.save(user)
        return UserResponse.from(savedUser)
    }

    fun login(request: LoginRequest): LoginResponse {
        val user = userRepository.findByEmail(request.email)
            ?: throw UnauthorizedException("Invalid credentials")

        if (!passwordEncoder.matches(request.password, user.passwordHash)) {
            throw UnauthorizedException("Invalid credentials")
        }

        val token = jwtService.generateToken(user)
        return LoginResponse(token = token, user = UserResponse.from(user))
    }

    @Transactional(readOnly = true)
    fun getUserById(id: UUID): UserResponse {
        val user = userRepository.findById(id)
            .orElseThrow { NotFoundException("User not found: $id") }
        return UserResponse.from(user)
    }

    @Transactional(readOnly = true)
    fun getAllUsers(pageable: Pageable): Page<UserResponse> {
        return userRepository.findAll(pageable).map { UserResponse.from(it) }
    }

    @Transactional
    fun updateUser(id: UUID, request: UpdateUserRequest): UserResponse {
        val user = userRepository.findById(id)
            .orElseThrow { NotFoundException("User not found: $id") }

        val updatedUser = user.copy(
            name = request.name ?: user.name,
            email = request.email ?: user.email,
        )

        return UserResponse.from(userRepository.save(updatedUser))
    }

    @Transactional
    fun deleteUser(id: UUID) {
        if (!userRepository.existsById(id)) {
            throw NotFoundException("User not found: $id")
        }
        userRepository.deleteById(id)
    }
}

Controller

// src/main/kotlin/com/example/myproject/controller/UserController.kt
package com.example.myproject.controller

import com.example.myproject.model.dto.*
import com.example.myproject.security.CurrentUser
import com.example.myproject.service.UserService
import jakarta.validation.Valid
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.web.PageableDefault
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import java.util.*

@RestController
@RequestMapping("/api")
class UserController(
    private val userService: UserService,
) {
    @PostMapping("/register")
    fun register(@Valid @RequestBody request: CreateUserRequest): ResponseEntity<UserResponse> {
        val user = userService.createUser(request)
        return ResponseEntity.status(HttpStatus.CREATED).body(user)
    }

    @PostMapping("/login")
    fun login(@Valid @RequestBody request: LoginRequest): ResponseEntity<LoginResponse> {
        val response = userService.login(request)
        return ResponseEntity.ok(response)
    }

    @GetMapping("/users/me")
    fun getCurrentUser(@CurrentUser userId: UUID): ResponseEntity<UserResponse> {
        val user = userService.getUserById(userId)
        return ResponseEntity.ok(user)
    }

    @PutMapping("/users/me")
    fun updateCurrentUser(
        @CurrentUser userId: UUID,
        @Valid @RequestBody request: UpdateUserRequest,
    ): ResponseEntity<UserResponse> {
        val user = userService.updateUser(userId, request)
        return ResponseEntity.ok(user)
    }

    @GetMapping("/users/{id}")
    @PreAuthorize("hasRole('ADMIN') or @userSecurity.isOwner(#id, authentication)")
    fun getUser(@PathVariable id: UUID): ResponseEntity<UserResponse> {
        val user = userService.getUserById(id)
        return ResponseEntity.ok(user)
    }

    @GetMapping("/users")
    @PreAuthorize("hasRole('ADMIN')")
    fun getAllUsers(
        @PageableDefault(size = 20) pageable: Pageable,
    ): ResponseEntity<Page<UserResponse>> {
        val users = userService.getAllUsers(pageable)
        return ResponseEntity.ok(users)
    }

    @DeleteMapping("/users/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    fun deleteUser(@PathVariable id: UUID): ResponseEntity<Unit> {
        userService.deleteUser(id)
        return ResponseEntity.noContent().build()
    }
}

// Health controller
@RestController
@RequestMapping("/health")
class HealthController {
    @GetMapping
    fun health() = mapOf(
        "status" to "ok",
        "timestamp" to System.currentTimeMillis(),
    )
}

Security Configuration

// src/main/kotlin/com/example/myproject/config/SecurityConfig.kt
package com.example.myproject.config

import com.example.myproject.security.JwtAuthenticationFilter
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
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.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig(
    private val jwtAuthenticationFilter: JwtAuthenticationFilter,
) {
    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        return http
            .csrf { it.disable() }
            .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
            .authorizeHttpRequests { auth ->
                auth
                    .requestMatchers("/api/register", "/api/login").permitAll()
                    .requestMatchers("/health/**").permitAll()
                    .requestMatchers("/actuator/**").permitAll()
                    .anyRequest().authenticated()
            }
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
            .build()
    }

    @Bean
    fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder(12)
}
// src/main/kotlin/com/example/myproject/security/JwtService.kt
package com.example.myproject.security

import com.example.myproject.config.JwtProperties
import com.example.myproject.model.entity.User
import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.springframework.stereotype.Service
import java.util.*
import javax.crypto.SecretKey

@Service
class JwtService(
    private val jwtProperties: JwtProperties,
) {
    private val secretKey: SecretKey = Keys.hmacShaKeyFor(jwtProperties.secret.toByteArray())

    fun generateToken(user: User): String {
        val now = Date()
        val expiration = Date(now.time + jwtProperties.expiration)

        return Jwts.builder()
            .subject(user.id.toString())
            .claim("email", user.email)
            .claim("role", user.role)
            .issuedAt(now)
            .expiration(expiration)
            .signWith(secretKey)
            .compact()
    }

    fun validateToken(token: String): Boolean {
        return try {
            val claims = extractAllClaims(token)
            !claims.expiration.before(Date())
        } catch (e: Exception) {
            false
        }
    }

    fun extractUserId(token: String): UUID {
        val subject = extractAllClaims(token).subject
        return UUID.fromString(subject)
    }

    fun extractRole(token: String): String {
        return extractAllClaims(token)["role"] as String
    }

    private fun extractAllClaims(token: String): Claims {
        return Jwts.parser()
            .verifyWith(secretKey)
            .build()
            .parseSignedClaims(token)
            .payload
    }
}

// src/main/kotlin/com/example/myproject/security/JwtAuthenticationFilter.kt
package com.example.myproject.security

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.authority.SimpleGrantedAuthority
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter

@Component
class JwtAuthenticationFilter(
    private val jwtService: JwtService,
) : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain,
    ) {
        val authHeader = request.getHeader("Authorization")

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response)
            return
        }

        val token = authHeader.substring(7)

        if (jwtService.validateToken(token)) {
            val userId = jwtService.extractUserId(token)
            val role = jwtService.extractRole(token)

            val authorities = listOf(SimpleGrantedAuthority("ROLE_$role"))
            val authentication = UsernamePasswordAuthenticationToken(userId, null, authorities)

            SecurityContextHolder.getContext().authentication = authentication
        }

        filterChain.doFilter(request, response)
    }
}

// src/main/kotlin/com/example/myproject/security/CurrentUser.kt
package com.example.myproject.security

import org.springframework.security.core.annotation.AuthenticationPrincipal

@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
@AuthenticationPrincipal
annotation class CurrentUser

Exception Handling

// src/main/kotlin/com/example/myproject/exception/Exceptions.kt
package com.example.myproject.exception

class NotFoundException(message: String) : RuntimeException(message)
class UnauthorizedException(message: String) : RuntimeException(message)
class ConflictException(message: String) : RuntimeException(message)
class ValidationException(message: String) : RuntimeException(message)

// src/main/kotlin/com/example/myproject/exception/GlobalExceptionHandler.kt
package com.example.myproject.exception

import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.access.AccessDeniedException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import java.time.Instant

data class ErrorResponse(
    val timestamp: Instant = Instant.now(),
    val status: Int,
    val error: String,
    val message: String,
    val details: List<String>? = null,
)

@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(NotFoundException::class)
    fun handleNotFound(ex: NotFoundException): ResponseEntity<ErrorResponse> {
        return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(ErrorResponse(
                status = 404,
                error = "NOT_FOUND",
                message = ex.message ?: "Resource not found",
            ))
    }

    @ExceptionHandler(UnauthorizedException::class)
    fun handleUnauthorized(ex: UnauthorizedException): ResponseEntity<ErrorResponse> {
        return ResponseEntity
            .status(HttpStatus.UNAUTHORIZED)
            .body(ErrorResponse(
                status = 401,
                error = "UNAUTHORIZED",
                message = ex.message ?: "Unauthorized",
            ))
    }

    @ExceptionHandler(ConflictException::class)
    fun handleConflict(ex: ConflictException): ResponseEntity<ErrorResponse> {
        return ResponseEntity
            .status(HttpStatus.CONFLICT)
            .body(ErrorResponse(
                status = 409,
                error = "CONFLICT",
                message = ex.message ?: "Resource conflict",
            ))
    }

    @ExceptionHandler(AccessDeniedException::class)
    fun handleAccessDenied(ex: AccessDeniedException): ResponseEntity<ErrorResponse> {
        return ResponseEntity
            .status(HttpStatus.FORBIDDEN)
            .body(ErrorResponse(
                status = 403,
                error = "FORBIDDEN",
                message = "Access denied",
            ))
    }

    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ErrorResponse> {
        val details = ex.bindingResult.fieldErrors.map { "${it.field}: ${it.defaultMessage}" }
        return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(ErrorResponse(
                status = 400,
                error = "VALIDATION_ERROR",
                message = "Validation failed",
                details = details,
            ))
    }

    @ExceptionHandler(Exception::class)
    fun handleGeneric(ex: Exception): ResponseEntity<ErrorResponse> {
        ex.printStackTrace()
        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse(
                status = 500,
                error = "INTERNAL_ERROR",
                message = "An unexpected error occurred",
            ))
    }
}

Testing

// src/test/kotlin/com/example/myproject/service/UserServiceTest.kt
package com.example.myproject.service

import com.example.myproject.exception.ConflictException
import com.example.myproject.exception.UnauthorizedException
import com.example.myproject.model.dto.CreateUserRequest
import com.example.myproject.model.dto.LoginRequest
import com.example.myproject.model.entity.User
import com.example.myproject.repository.UserRepository
import com.example.myproject.security.JwtService
import io.mockk.*
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.MockK
import io.mockk.junit5.MockKExtension
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.security.crypto.password.PasswordEncoder
import java.time.Instant
import java.util.*
import kotlin.test.assertEquals
import kotlin.test.assertNotNull

@ExtendWith(MockKExtension::class)
class UserServiceTest {

    @MockK
    lateinit var userRepository: UserRepository

    @MockK
    lateinit var passwordEncoder: PasswordEncoder

    @MockK
    lateinit var jwtService: JwtService

    @InjectMockKs
    lateinit var userService: UserService

    private val testUser = User(
        id = UUID.randomUUID(),
        email = "test@example.com",
        passwordHash = "hashedPassword",
        name = "Test User",
        role = "USER",
        createdAt = Instant.now(),
        updatedAt = Instant.now(),
    )

    @BeforeEach
    fun setUp() {
        clearAllMocks()
    }

    @Test
    fun `createUser should create user successfully`() {
        val request = CreateUserRequest(
            email = "new@example.com",
            password = "password123",
            name = "New User",
        )

        every { userRepository.existsByEmail(request.email) } returns false
        every { passwordEncoder.encode(request.password) } returns "hashedPassword"
        every { userRepository.save(any()) } returns testUser.copy(
            email = request.email,
            name = request.name,
        )

        val result = userService.createUser(request)

        assertNotNull(result)
        verify { userRepository.save(any()) }
    }

    @Test
    fun `createUser should throw ConflictException when email exists`() {
        val request = CreateUserRequest(
            email = "existing@example.com",
            password = "password123",
            name = "User",
        )

        every { userRepository.existsByEmail(request.email) } returns true

        assertThrows<ConflictException> {
            userService.createUser(request)
        }
    }

    @Test
    fun `login should return token for valid credentials`() {
        val request = LoginRequest(
            email = "test@example.com",
            password = "password123",
        )

        every { userRepository.findByEmail(request.email) } returns testUser
        every { passwordEncoder.matches(request.password, testUser.passwordHash) } returns true
        every { jwtService.generateToken(testUser) } returns "jwt-token"

        val result = userService.login(request)

        assertEquals("jwt-token", result.token)
        assertNotNull(result.user)
    }

    @Test
    fun `login should throw UnauthorizedException for invalid password`() {
        val request = LoginRequest(
            email = "test@example.com",
            password = "wrongpassword",
        )

        every { userRepository.findByEmail(request.email) } returns testUser
        every { passwordEncoder.matches(request.password, testUser.passwordHash) } returns false

        assertThrows<UnauthorizedException> {
            userService.login(request)
        }
    }
}

// src/test/kotlin/com/example/myproject/controller/UserControllerIntegrationTest.kt
package com.example.myproject.controller

import com.example.myproject.model.dto.CreateUserRequest
import com.example.myproject.model.dto.LoginRequest
import com.fasterxml.jackson.databind.ObjectMapper
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.post
import org.springframework.test.web.servlet.get
import org.springframework.transaction.annotation.Transactional

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Transactional
class UserControllerIntegrationTest {

    @Autowired
    lateinit var mockMvc: MockMvc

    @Autowired
    lateinit var objectMapper: ObjectMapper

    @Test
    fun `register should create new user`() {
        val request = CreateUserRequest(
            email = "newuser@example.com",
            password = "password123",
            name = "New User",
        )

        mockMvc.post("/api/register") {
            contentType = MediaType.APPLICATION_JSON
            content = objectMapper.writeValueAsString(request)
        }.andExpect {
            status { isCreated() }
            jsonPath("$.email") { value("newuser@example.com") }
            jsonPath("$.name") { value("New User") }
        }
    }

    @Test
    fun `register should fail with invalid email`() {
        val request = CreateUserRequest(
            email = "invalid-email",
            password = "password123",
            name = "User",
        )

        mockMvc.post("/api/register") {
            contentType = MediaType.APPLICATION_JSON
            content = objectMapper.writeValueAsString(request)
        }.andExpect {
            status { isBadRequest() }
        }
    }

    @Test
    fun `getCurrentUser should require authentication`() {
        mockMvc.get("/api/users/me")
            .andExpect {
                status { isUnauthorized() }
            }
    }
}

Commands

# Development
./gradlew bootRun

# Build
./gradlew build

# Test
./gradlew test

# Test with coverage
./gradlew test jacocoTestReport

# Format (with ktlint)
./gradlew ktlintFormat

# Lint
./gradlew ktlintCheck

# Build JAR
./gradlew bootJar

# Run JAR
java -jar build/libs/myproject-0.0.1-SNAPSHOT.jar

# Docker build
docker build -t myproject .

Best Practices

DO

  • ✓ Use data classes for DTOs and entities
  • ✓ Use Kotlin null safety features
  • ✓ Use @Transactional(readOnly = true) for read operations
  • ✓ Use constructor injection (default in Kotlin)
  • ✓ Use coroutines for async operations where beneficial
  • ✓ Use @Valid for request validation
  • ✓ Use Spring profiles for environment configuration

DON'T

  • ✗ Use lateinit var for dependencies (use constructor injection)
  • ✗ Ignore null safety in Spring Data repositories
  • ✗ Use !! operator without proper null checks
  • ✗ Mix reactive and blocking code
  • ✗ Store secrets in configuration files

References