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 for base Kotlin patterns
When to Use
-
Enterprise Applications: Proven enterprise-grade framework with vast ecosystem
-
Microservices: Excellent with Spring Cloud for distributed systems
-
Existing Spring Ecosystem: Leverage Spring libraries, security, data, cloud
-
Complex Business Logic: Rich feature set for complex domains
-
Team Familiarity: Teams with Java/Spring background transitioning to Kotlin
When NOT to Use
-
Lightweight Services: Consider Ktor for simpler, minimal-dependency services
-
Mobile Backend: Ktor is more lightweight for mobile-only backends
-
Minimal Footprint: Spring Boot has a larger memory and startup footprint
Project Structure
myproject/ ├── build.gradle.kts ├── settings.gradle.kts ├── src/ │ ├── main/ │ │ ├── kotlin/com/example/myproject/ │ │ │ ├── MyProjectApplication.kt │ │ │ ├── config/ │ │ │ │ ├── SecurityConfig.kt │ │ │ │ ├── WebConfig.kt │ │ │ │ └── JwtProperties.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 │ │ │ ├── JwtAuthenticationFilter.kt │ │ │ └── CurrentUser.kt │ │ └── resources/ │ │ ├── application.yml │ │ ├── application-dev.yml │ │ └── db/migration/ │ └── test/kotlin/com/example/myproject/ │ ├── controller/ │ ├── service/ │ └── integration/ └── README.md
Guardrails
Spring-Kotlin Specific
-
Use constructor injection (Kotlin default, no @Autowired needed)
-
Never use lateinit var for dependencies -- constructor injection is idiomatic
-
Use @Transactional(readOnly = true) for all read-only operations
-
Use @Valid on all request body parameters for automatic validation
-
Use Spring profiles (application-{profile}.yml ) for environment config
-
Never hardcode secrets in config files -- use ${ENV_VAR:default} syntax
-
Use @ConfigurationProperties data classes for type-safe configuration
-
Prefer Kotlin data classes for DTOs with @field: annotation target for validation
-
Use open modifier or plugin.spring (auto-opens @Component classes)
Entity Design
-
Use data class for JPA entities with plugin.jpa (auto-generates no-arg constructors)
-
Prefer val for entity properties, use copy() for updates
-
Use UUID or auto-generated IDs; nullable val id: UUID? = null for new entities
-
Use @CreationTimestamp and @UpdateTimestamp for audit fields
-
Keep entities in model/entity/ , DTOs in model/dto/
Service Layer
-
Mark all service classes with @Service
-
Use @Transactional for write operations, @Transactional(readOnly = true) for reads
-
Throw domain-specific exceptions (NotFoundException , ConflictException )
-
Return DTOs from services, never expose entities to controllers
-
Use Kotlin null safety: repository methods return T? for nullable finds
Controller Layer
-
Use @RestController with @RequestMapping for base paths
-
Return ResponseEntity<T> with explicit status codes
-
Use @Valid @RequestBody for all incoming request bodies
-
Use @PreAuthorize for method-level security
-
Use @PageableDefault for pagination parameters
Key Patterns
Application Entry Point
@SpringBootApplication class MyProjectApplication
fun main(args: Array<String>) { runApplication<MyProjectApplication>(*args) }
Type-Safe Configuration
@ConfigurationProperties(prefix = "jwt") data class JwtProperties( val secret: String, val expiration: Long, )
application.yml
jwt: secret: ${JWT_SECRET:your-256-bit-secret-key-here} expiration: 86400000
Entity with Data Class
@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,
)
DTO with Validation
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)
val name: String,
)
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
@Repository interface UserRepository : JpaRepository<User, UUID> { fun findByEmail(email: String): User? fun existsByEmail(email: String): Boolean }
Service
@Service class UserService( private val userRepository: UserRepository, private val passwordEncoder: PasswordEncoder, ) { @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, ) return UserResponse.from(userRepository.save(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
fun updateUser(id: UUID, request: UpdateUserRequest): UserResponse {
val user = userRepository.findById(id)
.orElseThrow { NotFoundException("User not found: $id") }
val updated = user.copy(
name = request.name ?: user.name,
email = request.email ?: user.email,
)
return UserResponse.from(userRepository.save(updated))
}
}
Controller
@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) }
@GetMapping("/users/{id}")
@PreAuthorize("hasRole('ADMIN') or @userSecurity.isOwner(#id, authentication)")
fun getUser(@PathVariable id: UUID): ResponseEntity<UserResponse> {
return ResponseEntity.ok(userService.getUserById(id))
}
@GetMapping("/users")
@PreAuthorize("hasRole('ADMIN')")
fun getAllUsers(
@PageableDefault(size = 20) pageable: Pageable,
): ResponseEntity<Page<UserResponse>> {
return ResponseEntity.ok(userService.getAllUsers(pageable))
}
}
Exception Handling
class NotFoundException(message: String) : RuntimeException(message) class UnauthorizedException(message: String) : RuntimeException(message) class ConflictException(message: String) : RuntimeException(message)
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 .status(HttpStatus.NOT_FOUND) .body(ErrorResponse(status = 404, error = "NOT_FOUND", message = ex.message ?: "Not found"))
@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))
}
}
Security Configuration
@Configuration @EnableWebSecurity @EnableMethodSecurity class SecurityConfig( private val jwtAuthenticationFilter: JwtAuthenticationFilter, ) { @Bean fun securityFilterChain(http: HttpSecurity): SecurityFilterChain = http .csrf { it.disable() } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .authorizeHttpRequests { auth -> auth .requestMatchers("/api/register", "/api/login").permitAll() .requestMatchers("/health/", "/actuator/").permitAll() .anyRequest().authenticated() } .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) .build()
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder(12)
}
Custom Annotation for Current User
@Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.RUNTIME) @AuthenticationPrincipal annotation class CurrentUser
Coroutines Integration
Spring Boot supports Kotlin coroutines in controllers and services. Add these dependencies:
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
Suspend functions in controllers are automatically handled by Spring WebFlux:
@GetMapping("/users/{id}") suspend fun getUser(@PathVariable id: UUID): ResponseEntity<UserResponse> { val user = userService.getUserById(id) return ResponseEntity.ok(user) }
For detailed coroutine, WebFlux, testing, and advanced security patterns, see references/patterns.md.
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 (?. , ?: , requireNotNull )
-
Use @Transactional(readOnly = true) for read operations
-
Use constructor injection (default in Kotlin)
-
Use @Valid for request validation
-
Use Spring profiles for environment configuration
-
Use @field: annotation target for validation annotations on data class properties
-
Use coroutines for async operations where beneficial
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 in the same call chain
-
Store secrets in configuration files
-
Expose JPA entities directly in controller responses
References
For detailed patterns and examples, see:
- references/patterns.md -- WebFlux reactive patterns, JPA advanced patterns, testing, security advanced patterns
External References
-
Spring Boot Documentation
-
Spring Kotlin Support
-
Spring Security
-
Spring Data JPA
-
Kotlin + Spring Boot Guide