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!