skills/ar4mirez/samuel/spring-boot-kotlin

spring-boot-kotlin

SKILL.md

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

Weekly Installs
5
Repository
ar4mirez/samuel
GitHub Stars
3
First Seen
13 days ago
Installed on
gemini-cli5
opencode5
codebuddy5
github-copilot5
codex5
kimi-cli5