Trilha de Kotlin · parte 39 de 59
Spring Boot com controllers suspend
Aula da fase 6 da trilha de Kotlin: Como validar entrada com Bean Validation e como controllers `suspend` se comportam no Spring MVC?
intermediárioPublicada em · 17 min de leitura
Pense num balcão de atendimento com um atendente só. Cada pessoa entrega um formulário, o atendente confere se os campos estão preenchidos e, se tudo estiver certo, encaminha o pedido para o setor responsável. Ele não fica parado olhando a fila do setor, porque isso travaria o balcão inteiro. Um serviço web escrito com Spring Boot funciona de forma parecida: existe uma classe que recebe as requisições HTTP, confere os dados e chama a regra de negócio. Essa classe se chama controller. Esta aula mostra como escrever controllers em Kotlin com funções suspend, o que o Spring faz com elas, como validar a entrada com Bean Validation e o que foi observado ao subir um servidor local com esse código.
Spring Boot é um conjunto de ferramentas sobre o Spring Framework que monta um serviço pronto para rodar, com servidor embutido, configuração padrão e dependências escolhidas para você. Função suspend é uma função que pode pausar sua execução sem bloquear a thread que a executa. A ideia foi explicada na aula sobre suspend e a máquina de estados. Aqui ela ganha um papel prático: o método do controller que espera um banco de dados pode liberar a thread enquanto espera.
o mapa da aula
A aula começa pelo problema que o Spring Boot resolve, porque sem essa base a pergunta sobre controllers suspend não faz sentido. Depois mostra um controller que lê e outro que cria um recurso, e explica o que o Spring MVC faz com uma função suspend antes de chamá-la. Em seguida trata da validação de entrada com Bean Validation, com saídas de execução. Na seção de bordas entram os status HTTP das exceções de validação, que antes estavam em aberto e agora têm fonte no código do Spring. A aula termina com erros comuns, exercícios, glossário, fontes e o que continua sem verificação.
Versões desta aula: Spring Boot 4.1.1, marcado como “Stable” na documentação, que exige Java 17 ou superior e Spring Framework 7.0.9 ou superior. O BOM gerencia Kotlin 2.3.21, Jackson 3.1.5, Hibernate Validator 9.1.3.Final e kotlinx-coroutines 1.10.2. Os exemplos foram compilados com o Maven, usando o compilador Kotlin 2.3.21 e o plugin Spring. O servidor foi iniciado com o jar gerado pelo spring-boot-maven-plugin, em Java 21 (Temurin 21.0.8). O kotlinc 2.1.21 do IntelliJ não foi usado nesta aula.
1. o problema que o Spring Boot resolve
Um serviço HTTP precisa de várias peças antes de responder a qualquer coisa: um servidor que aceite conexões, um mecanismo que associe cada caminho de URL a um método, uma conversão entre o JSON que chega e um objeto Kotlin, uma forma de validar os campos e uma forma de devolver erros. Escrever tudo isso à mão é possível, e foi o que se fazia antes de frameworks como o Spring. O custo está na quantidade de decisões repetidas em cada projeto e na chance de cada equipe tomar uma decisão diferente para a mesma peça.
O Spring Boot reduz esse custo com dois mecanismos. O primeiro são os starters, que são descritores de dependência: cada um traz um conjunto de bibliotecas que combinam entre si. A documentação define os starters como “a set of convenient dependency descriptors that you can include in your application”. O segundo é a configuração automática, que cria os beans padrão quando eles fazem sentido. Os nomes dos starters mudam entre versões. A documentação de construção de projetos mostra o starter de Spring MVC com Tomcat como spring-boot-starter-webmvc e marca o spring-boot-starter-web como deprecated em favor dele. A escolha dos starters faz parte do build, e a aula Gradle Kotlin DSL e multi-módulo trata dessa parte.
O Spring Boot também não pede que você escreva a versão de cada biblioteca. Cada release traz uma lista curada de dependências, publicada no BOM spring-boot-dependencies. Foi desse arquivo que vieram as versões citadas nesta série de aulas.
2. um controller em Kotlin
Um controller é uma classe anotada com @RestController. Cada método anotado com @GetMapping, @PostMapping ou similares responde a um caminho e a um verbo HTTP. A anotação @PathVariable pega um pedaço da URL, e @RequestBody converte o corpo JSON em um objeto. O código abaixo foi compilado e executado. O DTO NovoPedido tem regras de validação, sem as quais a anotação @Valid não teria o que verificar.
import jakarta.validation.Valid
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import kotlinx.coroutines.delay
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
data class Pedido(val id: Long, val cliente: String)
data class NovoPedido(
@field:NotBlank(message = "cliente é obrigatório")
val cliente: String,
@field:Min(value = 1, message = "quantidade deve ser pelo menos 1")
val quantidade: Int,
)
@RestController
@RequestMapping("/pedidos")
class PedidoController {
@GetMapping("/{id}")
suspend fun buscar(@PathVariable id: Long): Pedido {
delay(10)
return Pedido(id, "Ana")
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
suspend fun criar(@Valid @RequestBody corpo: NovoPedido): Pedido {
delay(10)
return Pedido(1L, corpo.cliente)
}
}
O método buscar lê o identificador da URL e devolve um Pedido. O método criar recebe um NovoPedido no corpo da requisição, marca o parâmetro com @Valid para que as regras de validação rodem antes do método, e devolve o pedido com o status 201 indicado por @ResponseStatus. O delay(10) é um substituto para uma chamada ao banco. Ele dá à função um ponto de suspensão e não mede nada.
Para ver o controller respondendo, a aplicação de verificação foi iniciada com a configuração de segurança da aula Segurança com JWT e OpenAPI, que exige um token em /pedidos. Com um token válido, a leitura respondeu assim:
HTTP/1.1 200
Content-Type: application/json
{"id":1,"cliente":"Ana"}
Sem token, a mesma rota respondeu 401 (HTTP/1.1 401). A aula Segurança com JWT e OpenAPI explica por que a regra de segurança aparece nesta aplicação.
Repare que a classe PedidoController não foi declarada como open. Em Kotlin toda classe é final por padrão, como visto na aula sobre Interfaces, delegação e classes open. A documentação do Spring Boot diz que, “Since Kotlin classes are final by default, you are likely to want to configure kotlin-spring plugin in order to automatically open Spring-annotated classes so that they can be proxied”. O plugin spring do compilador, que foi usado nesta compilação, abre as classes anotadas. A aplicação de verificação usou esse plugin, então os controllers acima não dependem de uma configuração escondida.
3. o que acontece com uma função suspend
Uma função suspend no Spring MVC é um método que o framework chama como qualquer outro, mas que pode pausar no meio. A documentação do Spring Framework diz que há “Suspending function support in Spring MVC and WebFlux annotated @Controller”, e que o suporte a retornos Deferred e Flow existe no mesmo contexto. Para que o suporte funcione, o classpath precisa de kotlinx-coroutines-core e kotlinx-coroutines-reactor, na versão 1.4.0 ou superior. A aplicação de verificação tem as duas dependências e foi iniciada sem erros.
Para ver o que muda no bytecode, o compilador foi inspecionado com javap. O resultado foi este:
public java.lang.Object buscar(long, kotlin.coroutines.Continuation<? super aula.l51.Pedido>);
public java.lang.Object criar(aula.l51.NovoPedido, kotlin.coroutines.Continuation<? super aula.l51.Pedido>);
O método declarado como Pedido buscar(id: Long) virou um método que devolve Object e recebe um parâmetro extra do tipo Continuation. Esse parâmetro guarda o estado que o compilador usa para retomar a função depois da suspensão. É a mesma transformação da aula sobre suspend e a máquina de estados, vista agora na fronteira com o framework.
A pergunta que mais interessa é em qual thread o corpo do método roda depois de cada retomada. O código-fonte do spring-web 7.0.9 responde em parte. Em InvocableHandlerMethod, o método que invoca a função chama CoroutinesUtils.invokeSuspendingFunction(method, target, args). O spring-core 7.0.9 implementa essa chamada com Dispatchers.getUnconfined(). O spring-webmvc não sobrescreve esse ponto, pelo que foi possível ver no código baixado. Um dispatcher “unconfined” não troca a thread de partida: a continuação é retomada na thread que a retomou.
O servidor confirmou o efeito. Um endpoint que lê o nome da thread antes e depois de um delay(10) respondeu assim, em uma execução:
{"antes":"http-nio-8099-exec-9","depois":"kotlinx.coroutines.DefaultExecutor"}
O nome da thread muda a cada execução, então o que importa é a troca: depois do delay, o código continuou numa thread do executor interno das coroutines, e não na thread do Tomcat que iniciou a requisição. Isso é coerente com a thread original ter sido liberada durante a espera. A aula não mediu a ocupação das threads sob carga, então a afirmação fica nesse nível.
A figura compara as duas formas de atender a mesma requisição. No lado esquerdo, a thread chama o banco e fica presa até a resposta chegar. A requisição B espera uma thread livre, e a seta com ✕ mostra que ela fica barrada na porta da thread. No lado direito, a coroutine chega ao ponto de espera, guarda o seu estado e libera a thread, que atende a requisição B. O pulso na coroutine marca o ponto de suspensão. A diferença está em quem ocupa a thread durante a espera.
Uma consequência prática sai daí. Um controller suspend só ajuda se a espera dentro dele for uma espera suspensa de verdade. Um Thread.sleep ou uma chamada JDBC bloqueante dentro de um método suspend continua prendendo a thread, porque a função não tem onde pausar. A aula sobre Dispatchers e bloqueio mostra o mesmo problema em outro contexto.
4. validação com Bean Validation
Validar a entrada significa garantir que o corpo da requisição tem o formato que a regra de negócio espera antes que o código da regra rode. No ecossistema Java e Kotlin, o padrão é a Bean Validation, cuja implementação de referência é o Hibernate Validator. A documentação do Spring diz que “A Bean Validation provider, such as the Hibernate Validator, is expected to be present in the classpath and is automatically detected.” Então a dependência precisa estar no classpath, e o Spring a encontra sem configuração adicional.
As regras ficam nas anotações do DTO. No exemplo abaixo, cliente não pode ser vazio, quantidade precisa ser pelo menos 1 e cupom tem no máximo 10 caracteres. O programa roda o validador sobre três casos e imprime as violações. A saída foi copiada da execução:
import jakarta.validation.Validation
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
data class CriarPedido(
@field:NotBlank(message = "cliente é obrigatório")
val cliente: String?,
@field:Min(value = 1, message = "quantidade deve ser pelo menos 1")
val quantidade: Int,
@field:Size(max = 10, message = "cupom tem no máximo 10 caracteres")
val cupom: String?,
)
fun main() {
val validator = Validation.buildDefaultValidatorFactory().validator
val casos = listOf(
CriarPedido("Ana", 2, "BEM10"),
CriarPedido(" ", 0, "CUPOM-MUITO-LONGO"),
CriarPedido(null, 1, null),
)
for (c in casos) {
val violacoes = validator.validate(c).map { "${it.propertyPath}: ${it.message}" }.sorted()
println("$c -> ${if (violacoes.isEmpty()) "válido" else violacoes.joinToString("; ")}")
}
}
CriarPedido(cliente=Ana, quantidade=2, cupom=BEM10) -> válido
CriarPedido(cliente= , quantidade=0, cupom=CUPOM-MUITO-LONGO) -> cliente: cliente é obrigatório; cupom: cupom tem no máximo 10 caracteres; quantidade: quantidade deve ser pelo menos 1
CriarPedido(cliente=null, quantidade=1, cupom=null) -> cliente: cliente é obrigatório
O primeiro caso passa. O segundo acumula três violações, uma por campo, então o validador não para na primeira falha. O terceiro mostra que null em um campo marcado com @NotBlank é violação. O validador só confere as anotações. Ele não conhece a regra de negócio, e por isso cada regra precisa ser escrita com intenção.
A sintaxe @field:NotBlank merece atenção. Em uma propriedade de data class, uma anotação sem o alvo field: pode ir para o parâmetro do construtor, e a Bean Validation lê o campo. O prefixo field: manda a anotação para o campo, que é onde o validador procura. Esquecer esse detalhe faz a regra parecer ativa no código sem funcionar.
No controller, a validação acontece quando o Spring converte o JSON e encontra @Valid no parâmetro. A requisição com corpo inválido nunca chega ao método criar. Quando a aplicação de verificação recebeu {"cliente":" ","quantidade":0} em POST /pedidos com um token válido, a resposta foi:
HTTP/1.1 400
Content-Type: application/json
{"timestamp":"2026-10-20T17:11:55.226Z","status":400,"error":"Bad Request","path":"/pedidos"}
Com spring.mvc.problemdetails.enabled=true, a mesma requisição respondeu com application/problem+json e o detalhe Invalid request content., como a aula Tratamento de erros com ProblemDetail mostra. A figura abaixo resume o caminho.
Os losangos mostram a única decisão que importa aqui: se o validador encontra violações. A seta verde leva o NovoPedido ao controller quando não há violações. A seta rosa leva a requisição a uma resposta 400, e o código de criar não roda. Como a regra está na própria anotação, o controller só recebe dados que já passaram por ela.
Pergunta de entrevistaO que acontece quando um controller devolve uma função suspend?
O Spring trata a função suspend como uma operação assíncrona: a thread do servidor é liberada enquanto a coroutine espera, e a resposta é escrita quando ela termina. Isso ajuda com I/O não bloqueante, mas não torna o banco assíncrono. Com JDBC, a espera continua bloqueando a thread do pool, então o ganho depende da stack. Eu confirmo com uma carga antes de afirmar que o serviço escala.
5. detalhes e bordas
Quando a validação falha, o Spring lança uma exceção, e pode ser uma entre duas. A documentação do Spring MVC diz que “Applications should handle both MethodArgumentNotValidException and HandlerMethodValidationException since either may be raised depending on the controller method signature.” MethodArgumentNotValidException aparece quando um @RequestBody marcado com @Valid falha. HandlerMethodValidationException aparece na validação de método, quando as constraints ficam nos parâmetros ou no retorno do método. A documentação não diz qual status cada uma produz. Essa informação está no código-fonte do spring-web 7.0.9.
Para MethodArgumentNotValidException, o método getStatusCode() devolve HttpStatus.BAD_REQUEST, e o detalhe da resposta é Invalid request content.. O HTTP confirmou o 400 com a mesma exceção.
Para HandlerMethodValidationException, o construtor chama initHttpStatus, e esse método devolve INTERNAL_SERVER_ERROR quando validationResult.isForReturnValue() é verdadeiro e BAD_REQUEST nos demais casos. O Javadoc diz o mesmo: “The getStatusCode() is 400 for input validation errors, and 500 for validation errors on a return value.” O servidor de verificação confirmou os dois casos. Uma constraint no parâmetro (@Min(1) em /teste/param/0) respondeu 400. Uma constraint no retorno (@Min(1) no método que devolve 0) respondeu 500. Em ambos, a mensagem do ProblemDetail foi Validation failure.
Esse resultado tem uma consequência para o cliente. Uma falha na saída do servidor aparece como 500, e não como erro de quem enviou a requisição. Uma validação de retorno que falha é um bug do servidor, e tratá-la como 400 esconderia isso.
Duas outras bordas apareceram no servidor. A primeira é que a aplicação de verificação precisou de um token em POST /pedidos para chegar à validação. Sem token, a mesma requisição recebeu 403 por causa do CSRF, antes de qualquer checagem de token. A aula Segurança com JWT e OpenAPI explica essa configuração. A segunda é que o status de erro sem handler não revela a mensagem interna: GET /teste/generico respondeu 500 com timestamp, status, error e path, e o texto da exceção (segredo interno do banco) não apareceu na resposta. A aula Tratamento de erros com ProblemDetail mostra o tratamento completo.
Esta aula não cobriu MockMvc nem testes de integração com banco. Esses testes dependem de Testcontainers e ficam para a aula Testes com Kotest, MockK e Testcontainers. Os resultados desta seção vêm de requisições reais feitas a um servidor local em localhost, com a aplicação iniciada por java -jar, e não de um ambiente de produção.
6. erros comuns e boas práticas
O erro mais frequente é envolver o corpo de um método suspend em runBlocking para “resolver” um tipo de retorno. Isso bloqueia a thread que chamou o método e anula o ganho da suspensão. Um controller suspend deve ser chamado pelo framework, que já sabe lidar com a continuação. A aula sobre CoroutineScope e concorrência estruturada explica por que runBlocking em um handler HTTP é um sinal de alerta.
O segundo erro é colocar a validação dentro do método, com if para cada campo. Isso repete a regra em cada controller e deixa casos de fora. Com Bean Validation, a regra fica no DTO, e o controller só recebe objetos que já passaram por ela. Um if continua necessário para regras que dependem do estado do sistema, como verificar se um cliente existe, porque essas regras não cabem em uma anotação.
O terceiro erro é devolver a entidade de banco diretamente do controller. O contrato da API passa a depender da estrutura da tabela, e qualquer mudança no banco vira uma mudança pública. O controller deve converter o resultado para um objeto de resposta próprio. A aula data class como entidade trata de entidades e data class, e o mesmo cuidado vale aqui.
A boa prática é deixar o controller pequeno: ele recebe, valida, chama a regra e converte o resultado. O restante fica em classes de serviço, que podem ser testadas sem o Spring. Em Go, a mesma divisão aparece em um handler HTTP que chama uma função de negócio sem conhecer o banco, como mostram as notas sobre Servidor HTTP Nativo Moderno e Middlewares em Go.
7. exercícios
- Adicione um método
listaraoPedidoControllerque devolva umFlow<Pedido>com três pedidos. Confira na documentação do Spring Framework que o retornoFlowé suportado no MVC, suba a aplicação e registre a resposta real do endpoint. - Crie um DTO com um campo
emailmarcado com@field:Emaile outro com@field:Positive. Rode o validador sobre três casos e anote a mensagem de cada violação. - Troque o
delay(10)do métodocriarporThread.sleep(10). Suba a aplicação e leia o nome da thread antes e depois da espera, como no endpoint de teste da seção 3. Explique com suas palavras por que o método deixa de se comportar como função suspensa, mesmo que o código continue compilando.
8. glossário
Controller: classe que recebe requisições HTTP e chama a regra de negócio. No Spring, é anotada com @RestController.
Starter: descritor de dependências do Spring Boot que traz um conjunto de bibliotecas que funcionam juntas.
BOM: arquivo que lista as versões de um conjunto de dependências, para que o projeto não repita cada versão.
Continuação (Continuation): objeto que guarda o estado de uma função suspend para que ela seja retomada depois da pausa.
Dispatcher unconfined: dispatcher que não troca a thread na retomada: a continuação roda na thread que a retomou.
Bean Validation: especificação Java para declarar regras em anotações, como @NotBlank e @Min. A implementação usada aqui é o Hibernate Validator.
DTO: objeto que transporta dados entre a camada HTTP e a regra de negócio, separado da entidade do banco.
Proxy: objeto criado pelo framework que intercepta chamadas a um bean. Classes finais não podem ser proxiadas por herança, e por isso o plugin spring do compilador abre as classes anotadas.
fontes
Consultadas em 20/10/2026:
- Spring Boot, requisitos de sistema, com o status “Stable” de 4.1.1, Java 17 ou superior e Spring Framework 7.0.9 ou superior: https://docs.spring.io/spring-boot/system-requirements.html
- Spring Boot, construção de projetos, com a definição de starters, o starter
spring-boot-starter-webmvc, a marcação despring-boot-starter-webcomo deprecated e o BOMspring-boot-dependencies: https://docs.spring.io/spring-boot/reference/using/build-systems.html - Spring Boot, página de Kotlin, com “Kotlin 2.2.x” como mínimo, o plugin kotlin-spring e as classes finais: https://docs.spring.io/spring-boot/reference/features/kotlin.html
- Spring Framework, suporte a coroutines em controllers, com
Deferred,Flowe a versão 1.4.0 do kotlinx-coroutines: https://docs.spring.io/spring-framework/reference/languages/kotlin/coroutines.html - Spring Framework, validação em controllers, com a frase sobre
MethodArgumentNotValidExceptioneHandlerMethodValidationException: https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-validation.html - Spring Framework, validação com Bean Validation, com o provedor detectado no classpath: https://docs.spring.io/spring-framework/reference/core/validation/beanvalidation.html
- Código-fonte do spring-web 7.0.9 (
HandlerMethodValidationException,MethodArgumentNotValidException,InvocableHandlerMethod), baixado do Maven Central: https://repo1.maven.org/maven2/org/springframework/spring-web/7.0.9/spring-web-7.0.9-sources.jar - Código-fonte do spring-core 7.0.9 (
CoroutinesUtils), baixado do Maven Central: https://repo1.maven.org/maven2/org/springframework/spring-core/7.0.9/spring-core-7.0.9-sources.jar - BOM do Spring Boot 4.1.1 (
spring-boot-dependencies-4.1.1.pom), para as versões gerenciadas: https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-dependencies/4.1.1/spring-boot-dependencies-4.1.1.pom
Verificações locais, feitas em 20/10/2026 com Temurin 21.0.8, Maven 3.9.11 e as dependências resolvidas pelo BOM 4.1.1: a compilação do controller e da validação, o javap do bytecode, as requisições HTTP ao servidor local (iniciado com o jar do Spring Boot, em localhost) e a execução do validador sem servidor. As saídas acima são as reais, com os valores que mudam a cada execução (timestamps e nomes de thread) apontados no texto.
Não verificado nesta aula: o dispatcher de controllers suspend em outras versões do Spring, a ocupação das threads sob carga, e o comportamento do Flow como retorno de controller, que ficou no exercício 1.
relacionados
- Trilha de Kotlin
- Curriculo de um Desenvolvedor Kotlin
- suspend e a máquina de estados
- Dispatchers e bloqueio
- CoroutineScope e concorrência estruturada
- Interfaces, delegação e classes open
- Tratamento de erros com ProblemDetail
- Starters e configuração por ambiente
- Segurança com JWT e OpenAPI
- Servidor HTTP Nativo Moderno
- Middlewares em Go