Spring Boot é o framework mais popular do ecossistema Java para backend, e ele funciona maravilhosamente bem com Kotlin. Na verdade, a combinação Kotlin + Spring Boot é tão boa que a equipe do Spring tem suporte oficial e dedicado à linguagem. Vamos construir uma API REST do zero neste tutorial.

Por que Kotlin com Spring Boot?

A pergunta melhor seria: por que não? Kotlin traz benefícios concretos para projetos Spring Boot:

  • Menos boilerplate: data classes, default parameters, extension functions
  • Null Safety: menos bugs em runtime
  • Coroutines: suporte nativo para programação reativa com WebFlux
  • DSLs: configurações declarativas e elegantes
  • Suporte oficial: a equipe do Spring mantém integração de primeira classe

Criando o projeto

Acesse o Spring Initializr e selecione:

  • Language: Kotlin
  • Build tool: Gradle - Kotlin
  • Dependencies: Spring Web, Spring Data JPA, H2 Database, Spring Validation

Ou configure manualmente o build.gradle.kts:

plugins {
    id("org.springframework.boot") version "3.4.0"
    id("io.spring.dependency-management") version "1.1.6"
    kotlin("jvm") version "2.1.0"
    kotlin("plugin.spring") version "2.1.0"
    kotlin("plugin.jpa") version "2.1.0"
}

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")
    runtimeOnly("com.h2database:h2")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

Os plugins plugin.spring e plugin.jpa são essenciais: eles geram construtores sem argumentos e abrem classes automaticamente (Spring precisa disso para proxies).

Definindo a entidade

@Entity
@Table(name = "produtos")
data class Produto(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,

    @field:NotBlank(message = "Nome é obrigatório")
    val nome: String,

    @field:Size(max = 500, message = "Descrição deve ter no máximo 500 caracteres")
    val descricao: String = "",

    @field:Positive(message = "Preço deve ser positivo")
    val preco: BigDecimal,

    @field:PositiveOrZero(message = "Estoque não pode ser negativo")
    val estoque: Int = 0,

    val ativo: Boolean = true
)

Repare nos valores padrão — isso elimina a necessidade de múltiplos construtores.

Repositório

Com Spring Data JPA, o repositório é simples assim:

@Repository
interface ProdutoRepository : JpaRepository<Produto, Long> {
    fun findByAtivoTrue(): List<Produto>
    fun findByNomeContainingIgnoreCase(nome: String): List<Produto>
    fun findByPrecoLessThanEqual(precoMaximo: BigDecimal): List<Produto>
}

Sem implementação necessária — o Spring gera tudo automaticamente a partir dos nomes dos métodos.

DTOs com data class

Use data classes para separar a camada de transporte da entidade:

data class ProdutoRequest(
    @field:NotBlank(message = "Nome é obrigatório")
    val nome: String,
    val descricao: String = "",
    @field:Positive(message = "Preço deve ser positivo")
    val preco: BigDecimal,
    val estoque: Int = 0
)

data class ProdutoResponse(
    val id: Long,
    val nome: String,
    val descricao: String,
    val preco: BigDecimal,
    val estoque: Int,
    val ativo: Boolean
)

fun Produto.toResponse() = ProdutoResponse(
    id = id,
    nome = nome,
    descricao = descricao,
    preco = preco,
    estoque = estoque,
    ativo = ativo
)

A extension function toResponse() é o jeito Kotlin de fazer mapeamento entre entidade e DTO sem precisar de bibliotecas como MapStruct.

Service

@Service
class ProdutoService(private val repository: ProdutoRepository) {

    fun listarTodos(): List<ProdutoResponse> =
        repository.findByAtivoTrue().map { it.toResponse() }

    fun buscarPorId(id: Long): ProdutoResponse =
        repository.findByIdOrNull(id)?.toResponse()
            ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Produto não encontrado")

    fun buscarPorNome(nome: String): List<ProdutoResponse> =
        repository.findByNomeContainingIgnoreCase(nome).map { it.toResponse() }

    @Transactional
    fun criar(request: ProdutoRequest): ProdutoResponse {
        val produto = Produto(
            nome = request.nome,
            descricao = request.descricao,
            preco = request.preco,
            estoque = request.estoque
        )
        return repository.save(produto).toResponse()
    }

    @Transactional
    fun atualizar(id: Long, request: ProdutoRequest): ProdutoResponse {
        val existente = repository.findByIdOrNull(id)
            ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Produto não encontrado")

        val atualizado = existente.copy(
            nome = request.nome,
            descricao = request.descricao,
            preco = request.preco,
            estoque = request.estoque
        )
        return repository.save(atualizado).toResponse()
    }

    @Transactional
    fun deletar(id: Long) {
        val produto = repository.findByIdOrNull(id)
            ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Produto não encontrado")
        repository.save(produto.copy(ativo = false)) // soft delete
    }
}

Veja como o copy() da data class facilita a atualização: criamos uma nova instância alterando só o que mudou.

Controller

@RestController
@RequestMapping("/api/produtos")
class ProdutoController(private val service: ProdutoService) {

    @GetMapping
    fun listarTodos() = service.listarTodos()

    @GetMapping("/{id}")
    fun buscarPorId(@PathVariable id: Long) = service.buscarPorId(id)

    @GetMapping("/busca")
    fun buscarPorNome(@RequestParam nome: String) = service.buscarPorNome(nome)

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun criar(@Valid @RequestBody request: ProdutoRequest) = service.criar(request)

    @PutMapping("/{id}")
    fun atualizar(
        @PathVariable id: Long,
        @Valid @RequestBody request: ProdutoRequest
    ) = service.atualizar(id, request)

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    fun deletar(@PathVariable id: Long) = service.deletar(id)
}

Tratamento global de erros

@RestControllerAdvice
class GlobalExceptionHandler {

    data class ErroResponse(
        val status: Int,
        val mensagem: String,
        val timestamp: LocalDateTime = LocalDateTime.now()
    )

    @ExceptionHandler(ResponseStatusException::class)
    fun handleResponseStatus(ex: ResponseStatusException): ResponseEntity<ErroResponse> {
        val erro = ErroResponse(
            status = ex.statusCode.value(),
            mensagem = ex.reason ?: "Erro desconhecido"
        )
        return ResponseEntity.status(ex.statusCode).body(erro)
    }

    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ErroResponse> {
        val mensagens = ex.bindingResult.fieldErrors.joinToString("; ") {
            "${it.field}: ${it.defaultMessage}"
        }
        val erro = ErroResponse(status = 400, mensagem = mensagens)
        return ResponseEntity.badRequest().body(erro)
    }
}

Testando a API

Com Spring Boot Test e Kotlin, os testes ficam bem expressivos:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProdutoControllerTest(@Autowired val restTemplate: TestRestTemplate) {

    @Test
    fun `deve criar produto com sucesso`() {
        val request = ProdutoRequest(
            nome = "Teclado Mecânico",
            descricao = "Switch Cherry MX Blue",
            preco = BigDecimal("349.90"),
            estoque = 50
        )

        val response = restTemplate.postForEntity(
            "/api/produtos",
            request,
            ProdutoResponse::class.java
        )

        assertThat(response.statusCode).isEqualTo(HttpStatus.CREATED)
        assertThat(response.body?.nome).isEqualTo("Teclado Mecânico")
        assertThat(response.body?.id).isGreaterThan(0)
    }
}

Kotlin permite usar crases nos nomes dos testes, o que deixa a descrição muito mais legível.

Conclusão

Kotlin e Spring Boot formam uma dupla espetacular para backend. Você tem toda a maturidade e estabilidade do ecossistema Spring com a expressividade e segurança de Kotlin. Se você já conhece Spring com Java, a transição para Kotlin é suavíssima — e os ganhos em produtividade aparecem logo nas primeiras linhas de código.

Monta seu projeto e bora codar!