Nesta aula o projeto das Trilhas da Ilha passa para o Spring Boot. Antes de usar o contêiner do Spring, você vai construir um em miniatura, com setenta e cinco linhas de reflexão, para entender o que ele faz. Depois vêm as anotações, a configuração, os perfis, o JdbcClient, as transações declarativas e os testes.
O repositório de "Dados que Sobrevivem" funciona, mas o programa que o usa tem um problema que cresce com o sistema: alguém precisa montar os objetos. O Principal criava a fábrica de conexões, passava-a ao repositório, e se houvesse um serviço, passaria o repositório ao serviço, e o serviço a um controlador. Com cinquenta classes, esse código de montagem vira uma teia, e cada teste precisa montar a sua própria versão dela. A injeção de dependências resolve isso entregando a montagem a um contêiner, e o Spring nasceu, como "Java no Servidor" contou, exatamente em torno dessa ideia. Como o Farol fez com o HTTP, esta aula começa construindo a coisa à mão, para que o Spring deixe de ser mágica.
Um contêiner em miniatura
A ideia central cabe numa frase: cada classe declara, no seu construtor, do que precisa, e o contêiner descobre como criar tudo, na ordem certa, criando cada objeto uma única vez. Com a reflexão de "O Programa que se Examina", o contêiner lê os tipos dos parâmetros do construtor e, recursivamente, cria o que for preciso:
import java.lang.reflect.*;
import java.util.*;
/**
* Um contêiner de injeção de dependências mínimo: cria cada componente uma vez,
* pelo construtor, entregando a ele os componentes de que depende.
*/
public final class Conteiner {
private final Set<Class<?>> componentes = new LinkedHashSet<>(); // classes que o contêiner pode criar
private final Map<Class<?>, Object> prontos = new HashMap<>(); // instâncias já criadas (singletons)
private final Deque<Class<?>> emConstrucao = new ArrayDeque<>(); // para detectar ciclos
/** Registra classes que o contêiner sabe construir (o equivalente a @Component). */
public Conteiner componentes(Class<?>... classes) {
componentes.addAll(List.of(classes));
return this;
}
/** Registra um objeto pronto (o equivalente a um método @Bean). */
public <T> Conteiner registrar(Class<T> tipo, T instancia) {
prontos.put(tipo, instancia);
return this;
}
public <T> T obter(Class<T> tipo) {
Object pronto = prontos.get(tipo);
if (pronto != null) return tipo.cast(pronto);
Class<?> implementacao = escolherImplementacao(tipo);
pronto = prontos.get(implementacao);
if (pronto == null) {
pronto = construir(implementacao);
prontos.put(implementacao, pronto);
}
prontos.put(tipo, pronto);
return tipo.cast(pronto);
}
/** Uma interface pode ser pedida: o contêiner procura o único componente que a implementa. */
private Class<?> escolherImplementacao(Class<?> tipo) {
List<Class<?>> candidatas = componentes.stream().filter(tipo::isAssignableFrom).toList();
return switch (candidatas.size()) {
case 1 -> candidatas.getFirst();
case 0 -> throw new IllegalStateException("nenhum componente do tipo " + tipo.getSimpleName());
default -> throw new IllegalStateException("componentes demais do tipo " + tipo.getSimpleName() + ": "
+ candidatas.stream().map(Class::getSimpleName).toList());
};
}
private Object construir(Class<?> classe) {
if (emConstrucao.contains(classe)) {
List<String> ciclo = new ArrayList<>(emConstrucao.reversed().stream().map(Class::getSimpleName).toList());
ciclo.add(classe.getSimpleName());
throw new IllegalStateException("dependência circular: " + String.join(" → ", ciclo));
}
Constructor<?>[] construtores = classe.getConstructors();
if (construtores.length != 1)
throw new IllegalStateException(classe.getSimpleName() + " precisa de exatamente um construtor público");
emConstrucao.push(classe);
try {
Constructor<?> construtor = construtores[0];
Object[] argumentos = Arrays.stream(construtor.getParameterTypes()).map(this::obter).toArray();
Object instancia = construtor.newInstance(argumentos);
System.out.println(" criado: " + classe.getSimpleName()
+ (argumentos.length == 0 ? "" : " ← " + Arrays.stream(argumentos).map(a -> a.getClass().getSimpleName()).toList()));
return instancia;
} catch (InvocationTargetException e) {
throw new IllegalStateException("o construtor de " + classe.getSimpleName() + " falhou", e.getCause());
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("não consegui criar " + classe.getSimpleName(), e);
} finally {
emConstrucao.pop();
}
}
}
O método obter é todo o contêiner. Se o objeto já existe, devolve o mesmo, e por isso cada componente é um singleton dentro do contêiner, sem nenhum dos problemas do Singleton global de "Lições de Quem Veio Antes". Se o tipo pedido é uma interface, procura o único componente registrado que a implementa. E para construir, lê os tipos dos parâmetros do construtor e chama obter para cada um, recursivamente, até chegar a classes sem dependências. A pilha emConstrucao detecta ciclos: se A precisa de B e B precisa de A, nenhum dos dois pode ser criado primeiro.
Com componentes de exemplo:
interface RepositorioDeTrilhas { List<String> nomes(); }
class RepositorioEmMemoria implements RepositorioDeTrilhas {
public RepositorioEmMemoria() {}
public List<String> nomes() { return List.of("Lagoinha do Leste", "Morro da Coroa"); }
}
interface Notificador { void avisar(String texto); }
class NotificadorNoConsole implements Notificador {
public NotificadorNoConsole() {}
public void avisar(String texto) { System.out.println(" [aviso] " + texto); }
}
class ServicoDeTrilhas {
private final RepositorioDeTrilhas repositorio;
private final Notificador notificador;
private final Clock relogio;
public ServicoDeTrilhas(RepositorioDeTrilhas repositorio, Notificador notificador, Clock relogio) {
this.repositorio = repositorio;
this.notificador = notificador;
this.relogio = relogio;
}
public void resumoDoDia() {
notificador.avisar(LocalDate.now(relogio) + ": " + repositorio.nomes().size() + " trilhas no catálogo");
}
}
class Ovo { public Ovo(Galinha g) {} }
class Galinha { public Galinha(Ovo o) {} }
System.out.println("montando:");
Conteiner conteiner = new Conteiner()
.componentes(RepositorioEmMemoria.class, NotificadorNoConsole.class, ServicoDeTrilhas.class)
.registrar(Clock.class, Clock.fixed(Instant.parse("2026-09-29T12:00:00Z"), ZoneOffset.UTC));
ServicoDeTrilhas servico = conteiner.obter(ServicoDeTrilhas.class);
servico.resumoDoDia();
System.out.println("mesma instância? " + (servico == conteiner.obter(ServicoDeTrilhas.class)));
System.out.println("o repositório pedido pela interface é o do serviço? "
+ (conteiner.obter(RepositorioDeTrilhas.class) == conteiner.obter(RepositorioEmMemoria.class)));
montando:
criado: RepositorioEmMemoria
criado: NotificadorNoConsole
criado: ServicoDeTrilhas ← [RepositorioEmMemoria, NotificadorNoConsole, FixedClock]
[aviso] 2026-09-29: 2 trilhas no catálogo
mesma instância? true
o repositório pedido pela interface é o do serviço? true
O programa pediu só o serviço, e o contêiner criou, na ordem certa, o repositório e o notificador de que ele dependia, entregando também o relógio registrado como objeto pronto. Nenhuma classe criou as suas dependências, e nenhuma sabe quem as criou. O ServicoDeTrilhas depende de duas interfaces e de um Clock, exatamente como o ServicoDeLembretes de "Dublês e Disciplina": num teste, basta montá-lo à mão com dublês, sem contêiner nenhum.
Os três erros possíveis também merecem ser vistos:
erro: dependência circular: Ovo → Galinha → Ovo
erro: nenhum componente do tipo RepositorioDeTrilhas
erro: componentes demais do tipo Notificador: [NotificadorNoConsole, OutroNotificador]
Um ciclo, uma dependência que ninguém fornece, e duas implementações para a mesma interface, sem critério para escolher. Guarde essas três mensagens: o Spring tem exatamente os mesmos três erros, com outras palavras, e eles vão aparecer na sua vida mais de uma vez.
O contêiner do Spring faz o mesmo que este, com muito mais: descobre os componentes sozinho, examinando os pacotes do projeto; suporta vários construtores, parâmetros opcionais, listas de implementações e escopos diferentes de singleton; cria proxies que acrescentam comportamento, como transações, em volta dos objetos; e controla o ciclo de vida, chamando métodos na inicialização e no desligamento. Mas o coração é esse: um mapa de tipos para instâncias, preenchido a partir dos construtores.
O projeto Spring Boot
Um projeto Spring Boot começa, tradicionalmente, no Spring Initializr, em start.spring.io, onde você escolhe a versão, o Java, o Maven e as dependências, e baixa um projeto pronto; os IDEs principais têm o mesmo assistente embutido. O resultado é um projeto Maven comum, cujo pom.xml tem duas particularidades:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>br.com.exemplo</groupId>
<artifactId>trilhas-da-ilha</artifactId>
<version>0.1.0</version>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
O projeto pai, spring-boot-starter-parent, traz a gestão de versões de centenas de bibliotecas testadas em conjunto, e é por isso que nenhuma dependência, nem mesmo o driver do SQLite, precisa declarar versão: o Spring Boot 4.1.1 já sabe qual usar. É o BOM de "Construção Automatizada" em escala industrial. Os starters trazem conjuntos de bibliotecas: o spring-boot-starter-webmvc traz o Spring MVC, o Tomcat embutido e a biblioteca de JSON Jackson 3; o spring-boot-starter-jdbc, o suporte a JDBC do Spring e o pool de conexões HikariCP. No Spring Boot 4, o antigo spring-boot-starter-web foi renomeado para spring-boot-starter-webmvc, e cada tecnologia ganhou o seu starter de testes, como o spring-boot-starter-webmvc-test, que já inclui JUnit, AssertJ e Mockito; tutoriais anteriores a novembro de 2025 usam os nomes antigos. E o spring-boot-maven-plugin empacota a aplicação num único JAR executável, com todas as dependências dentro.
A aplicação e seus componentes
A classe principal de uma aplicação Spring Boot é curta:
package br.com.exemplo.trilhas;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class TrilhasApplication {
public static void main(String[] args) {
SpringApplication.run(TrilhasApplication.class, args);
}
}
A anotação @SpringBootApplication liga três coisas. A varredura de componentes procura, no pacote da classe e em todos os subpacotes, classes anotadas como componentes, e as registra no contêiner, que o Spring chama de contexto da aplicação (application context). A configuração automática examina o que há no classpath e as propriedades, e registra componentes prontos: com o spring-boot-starter-jdbc e o driver do SQLite presentes, ela cria um DataSource com o pool HikariCP e um JdbcClient. E a própria classe vira uma fonte de configuração. O SpringApplication.run cria o contexto, sobe o Tomcat e devolve o contexto pronto; o main termina ali, mas o processo continua vivo enquanto o servidor estiver atendendo.
Os componentes são marcados com anotações que dizem ao Spring "crie um destes". A mais genérica é @Component, e há três especializações, que funcionam igual mas documentam o papel da classe: @Repository para acesso a dados, @Service para regras de negócio e @Controller (ou @RestController) para a camada web. A @Repository tem um efeito extra: exceções das bibliotecas de persistência lançadas pela classe, como as do JPA da próxima aula, são traduzidas para a hierarquia de exceções de acesso a dados do Spring, a DataAccessException, que não é verificada. Com o JdbcClient essa tradução já vem pronta: ele mesmo converte cada SQLException numa DataAccessException.
O repositório de "Dados que Sobrevivem", reescrito com o JdbcClient, a API de JDBC do Spring introduzida no Spring Framework 6.1:
package br.com.exemplo.trilhas;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
@Repository
public class RepositorioDeTrilhas {
private final JdbcClient jdbc;
public RepositorioDeTrilhas(JdbcClient jdbc) { // o JdbcClient vem da configuração automática
this.jdbc = jdbc;
}
public long inserir(Trilha t) {
KeyHolder chave = new GeneratedKeyHolder();
jdbc.sql("""
INSERT INTO trilha (nome, regiao, distancia_km, dificuldade, duracao_min)
VALUES (:nome, :regiao, :distancia, :dificuldade, :duracao)
""")
.param("nome", t.nome())
.param("regiao", t.regiao())
.param("distancia", t.distanciaKm())
.param("dificuldade", t.dificuldade().name())
.param("duracao", t.duracao().toMinutes())
.update(chave);
long id = chave.getKey().longValue();
int ordem = 1;
for (String ponto : t.pontosDeInteresse()) {
jdbc.sql("INSERT INTO ponto_de_interesse (trilha_id, ordem, nome) VALUES (?, ?, ?)")
.params(id, ordem++, ponto)
.update();
}
return id;
}
public Optional<Trilha> buscarPorId(long id) {
return jdbc.sql("SELECT * FROM trilha WHERE id = :id")
.param("id", id)
.query(this::semPontos)
.optional()
.map(t -> comPontos(t));
}
public List<Trilha> listarPorRegiao(String regiao) {
return jdbc.sql("SELECT * FROM trilha WHERE regiao = :regiao ORDER BY nome")
.param("regiao", regiao)
.query(this::semPontos)
.list();
}
public boolean existeComNome(String nome) {
return jdbc.sql("SELECT COUNT(*) FROM trilha WHERE nome = ?")
.param(nome)
.query(Integer.class)
.single() > 0;
}
private Trilha comPontos(Trilha t) {
List<String> pontos = jdbc.sql("SELECT nome FROM ponto_de_interesse WHERE trilha_id = ? ORDER BY ordem")
.param(t.id())
.query(String.class)
.list();
return new Trilha(t.id(), t.nome(), t.regiao(), t.distanciaKm(), t.dificuldade(), t.duracao(), pontos);
}
/** Um RowMapper: converte a linha atual do ResultSet num objeto. */
private Trilha semPontos(ResultSet rs, int linha) throws SQLException {
return new Trilha(rs.getLong("id"), rs.getString("nome"), rs.getString("regiao"),
rs.getDouble("distancia_km"), Dificuldade.valueOf(rs.getString("dificuldade")),
Duration.ofMinutes(rs.getLong("duracao_min")), List.of());
}
}
Compare com a versão da aula anterior. O SQL continua à vista, e continua sendo seu, mas a cerimônia em volta sumiu: não há Connection a abrir e fechar, nem PreparedStatement, nem laço sobre o ResultSet, nem SQLException nas assinaturas. Os parâmetros podem ter nome, como :nome, o que evita contar pontos de interrogação. O RowMapper, uma interface funcional que converte uma linha num objeto, aparece como a referência this::semPontos, e a interface fluente escolhe a forma do resultado: .list(), .optional() ou .single(). As conexões vêm do pool, emprestadas e devolvidas a cada comando.
A listagem devolve as trilhas sem os pontos de interesse, deliberadamente: buscar os pontos de cada trilha da lista, um por um, seria o problema que "Objetos no Banco" vai chamar de N+1, e a tela de listagem não precisa deles. Só a busca por id os carrega.
O serviço e as transações declarativas
A regra de negócio fica num serviço, que depende do repositório e das configurações da aplicação:
package br.com.exemplo.trilhas;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
public class ServicoDeTrilhas {
private final RepositorioDeTrilhas repositorio;
private final ConfiguracaoDasTrilhas configuracao;
public ServicoDeTrilhas(RepositorioDeTrilhas repositorio, ConfiguracaoDasTrilhas configuracao) {
this.repositorio = repositorio;
this.configuracao = configuracao;
}
/** Grava a trilha e seus pontos numa única transação: ou tudo, ou nada. */
@Transactional
public Trilha cadastrar(Trilha nova) {
if (repositorio.existeComNome(nova.nome()))
throw new IllegalArgumentException("já existe uma trilha chamada " + nova.nome());
long id = repositorio.inserir(nova);
return nova.comId(id);
}
public Optional<Trilha> buscar(long id) {
return repositorio.buscarPorId(id);
}
public List<Trilha> listar(Optional<String> regiao) {
return repositorio.listarPorRegiao(regiao.orElse(configuracao.regiaoPadrao()));
}
}
A anotação @Transactional substitui todo o setAutoCommit(false), commit() e rollback() da aula anterior. O método cadastrar executa a verificação de nome, o INSERT da trilha e os INSERTs dos pontos numa única transação; se ele terminar normalmente, a transação é confirmada, e se lançar uma exceção não verificada, é desfeita. O repositório não sabe nada sobre isso: seus comandos usam a mesma conexão porque o Spring a associa à thread durante a transação.
O mecanismo é o que o contêiner em miniatura não tinha: o Spring não entrega ao controlador o ServicoDeTrilhas original, e sim um proxy, um objeto gerado em tempo de execução, uma subclasse do ServicoDeTrilhas (o Spring Boot usa a biblioteca CGLIB para isso), que abre a transação, chama o método verdadeiro e confirma ou desfaz no fim. É o Decorator de "Lições de Quem Veio Antes", criado automaticamente. Esse desenho tem uma consequência famosa, que o exercício 3 explora: um método @Transactional chamado de dentro da própria classe não passa pelo proxy, e a transação não acontece.
Duas regras do @Transactional pegam muita gente. Por padrão, só exceções não verificadas causam rollback; uma exceção verificada lançada do método faz a transação ser confirmada, o que pode ser mudado com @Transactional(rollbackFor = Exception.class). E a anotação só funciona em métodos chamados por outro objeto, através do proxy.
Configuração e perfis
Os valores que mudam entre ambientes, como o endereço do banco, ficam fora do código, no arquivo src/main/resources/application.properties:
spring.application.name=trilhas-da-ilha
# O banco: um arquivo SQLite na pasta onde a aplicação roda
spring.datasource.url=jdbc:sqlite:trilhas.db
# Cada conexão do pool liga as chaves estrangeiras ao nascer
spring.datasource.hikari.connection-init-sql=PRAGMA foreign_keys = ON
# O SQLite aceita um escritor por vez: um pool de uma conexão evita esperas e erros de "banco ocupado"
spring.datasource.hikari.maximum-pool-size=1
# Executa o schema.sql na inicialização
spring.sql.init.mode=always
# Uma thread virtual por requisição, como no Farol
spring.threads.virtual.enabled=true
# Configurações da própria aplicação
trilhas.regiao-padrao=Sul
O esquema vai para src/main/resources/schema.sql, com CREATE TABLE IF NOT EXISTS e CREATE INDEX IF NOT EXISTS, para que possa ser executado a cada inicialização sem erro. A propriedade connection-init-sql resolve, para todas as conexões do pool, o problema do PRAGMA da aula anterior. E o pool de uma única conexão é uma concessão ao SQLite, que só permite uma escrita por vez no arquivo; com bancos servidores, o padrão do HikariCP, dez conexões, é o ponto de partida.
As configurações da própria aplicação ganham um record, preenchido automaticamente pelo Spring a partir das propriedades com o prefixo trilhas:
package br.com.exemplo.trilhas;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
@ConfigurationProperties(prefix = "trilhas")
public record ConfiguracaoDasTrilhas(
@DefaultValue("Sul") String regiaoPadrao,
@DefaultValue("20") int limiteDaListagem) {
}
O @ConfigurationPropertiesScan da classe principal encontra o record e o registra como componente, e o serviço o recebe no construtor como qualquer outra dependência. Os nomes são convertidos com uma regra flexível, chamada de relaxed binding: regiaoPadrao no Java casa com trilhas.regiao-padrao no arquivo, e com a variável de ambiente TRILHAS_REGIAO_PADRAO. As fontes têm uma ordem de prioridade: argumentos de linha de comando, como --trilhas.regiao-padrao=Norte, vencem variáveis de ambiente, que vencem o arquivo. Isso permite que o mesmo JAR rode em desenvolvimento, em teste e em produção, mudando só o ambiente, que é um dos princípios da metodologia conhecida como twelve-factor app.
Os perfis agrupam configurações por ambiente. Um arquivo application-test.properties só é lido quando o perfil test está ativo, e componentes podem ser condicionados a um perfil. Um exemplo útil é carregar trilhas de exemplo quando o banco está vazio, mas nunca nos testes:
package br.com.exemplo.trilhas;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import java.time.Duration;
import java.util.Optional;
@Configuration
public class DadosDeExemplo {
@Bean
@Profile("!test") // em qualquer perfil, menos o de testes
ApplicationRunner carregarExemplos(ServicoDeTrilhas servico) {
return argumentos -> {
if (!servico.listar(Optional.of("Sul")).isEmpty()) return;
servico.cadastrar(Trilha.nova("Lagoinha do Leste", "Sul", 4.5, Dificuldade.MODERADA,
Duration.ofMinutes(120), "Mirante do Matadeiro", "Pedra do Urubu", "Lagoinha"));
servico.cadastrar(Trilha.nova("Praia do Saquinho", "Sul", 2.1, Dificuldade.FACIL, Duration.ofMinutes(60)));
};
}
}
Um método anotado com @Bean, numa classe @Configuration, é a forma de registrar no contêiner um objeto que você cria à mão, como o registrar(Clock.class, ...) do contêiner em miniatura. É o caminho para classes de bibliotecas, que você não pode anotar com @Component, e para objetos que precisam de uma montagem especial. Os parâmetros do método são injetados como os de um construtor. Um ApplicationRunner é executado uma vez, logo depois de a aplicação subir.
A camada web, por enquanto mínima
Um controlador expõe o serviço por HTTP. "A API das Trilhas" vai tratar a API a fundo, com validação, erros padronizados e documentação; por enquanto, duas rotas bastam:
package br.com.exemplo.trilhas;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/trilhas")
public class ControladorDeTrilhas {
private final ServicoDeTrilhas servico;
public ControladorDeTrilhas(ServicoDeTrilhas servico) {
this.servico = servico;
}
@GetMapping
public List<Trilha> listar(@RequestParam Optional<String> regiao) {
return servico.listar(regiao);
}
@GetMapping("/{id}")
public ResponseEntity<Trilha> buscar(@PathVariable long id) {
return ResponseEntity.of(servico.buscar(id)); // Optional vazio → 404
}
}
Com mvn spring-boot:run, ou java -jar target/trilhas-da-ilha-0.1.0.jar depois do mvn package, a aplicação sobe, e o registro termina com linhas como estas (os tempos mudam de uma máquina para outra):
... Tomcat started on port 8080 (http) with context path '/'
... Started TrilhasApplication in 1.9 seconds (process running for 2.3)
E curl "http://localhost:8080/trilhas?regiao=Sul" devolve as duas trilhas de exemplo em JSON, convertidas a partir dos records pelo Jackson, sem uma linha de código de conversão. O formato exato do JSON, especialmente o da duração, vai ser definido com cuidado em "A API das Trilhas", com objetos próprios para a API.
Por baixo, o que aconteceu na inicialização é o que o contêiner em miniatura fazia, em grande escala: o Spring encontrou RepositorioDeTrilhas, ServicoDeTrilhas, ControladorDeTrilhas, DadosDeExemplo e ConfiguracaoDasTrilhas, criou o DataSource, o JdbcClient e o Tomcat pela configuração automática, examinou os construtores, criou tudo na ordem certa, envolveu o serviço num proxy de transações e executou o ApplicationRunner. E os três erros do contêiner em miniatura têm equivalentes no Spring, que interrompem a inicialização com mensagens como "The dependencies of some of the beans in the application context form a cycle", "Parameter 0 of constructor in ... required a bean of type ... that could not be found" e "... required a single bean, but 2 were found".
Por que injeção pelo construtor
O Spring aceita três formas de injeção: pelo construtor, por métodos setter e diretamente em campos, com @Autowired. A documentação do próprio Spring recomenda o construtor, e todas as classes desta aula o usam. Com ele, as dependências podem ser final, e o objeto nasce completo e imutável, como "Contratos à Vista" recomenda. As dependências ficam visíveis na assinatura, e uma classe com oito parâmetros no construtor denuncia, sozinha, que tem responsabilidades demais. Os testes de unidade não precisam do Spring: basta chamar o construtor com dublês. E dependências circulares são detectadas na inicialização, em vez de produzirem objetos pela metade. Uma classe com um único construtor nem precisa de anotação: o Spring o usa automaticamente.
Testes
O ServicoDeTrilhas pode ser testado sem Spring, passando ao construtor dublês do repositório, como em "Dublês e Disciplina". Mas é importante ter pelo menos um teste que suba a aplicação de verdade, com o banco de verdade, para verificar que a configuração, o esquema e o SQL funcionam juntos:
package br.com.exemplo.trilhas;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest
@ActiveProfiles("test")
class ServicoDeTrilhasTest {
@TempDir
static Path pasta;
/** Cada execução dos testes usa um arquivo novo, numa pasta temporária. */
@DynamicPropertySource
static void banco(DynamicPropertyRegistry propriedades) {
propriedades.add("spring.datasource.url", () -> "jdbc:sqlite:" + pasta.resolve("teste.db"));
}
@Autowired
ServicoDeTrilhas servico;
@Test
void cadastraEBuscaComOsPontosNaOrdem() {
Trilha gravada = servico.cadastrar(Trilha.nova("Costão do Santinho", "Norte", 3.2, Dificuldade.FACIL,
Duration.ofMinutes(90), "Inscrições rupestres", "Mirante do Costão"));
Trilha lida = servico.buscar(gravada.id()).orElseThrow();
assertThat(lida).isEqualTo(gravada);
assertThat(lida.pontosDeInteresse()).containsExactly("Inscrições rupestres", "Mirante do Costão");
}
@Test
void recusaNomeRepetido() {
servico.cadastrar(Trilha.nova("Morro da Coroa", "Leste", 6.8, Dificuldade.DIFICIL, Duration.ofMinutes(210)));
assertThatThrownBy(() -> servico.cadastrar(
Trilha.nova("Morro da Coroa", "Leste", 1, Dificuldade.FACIL, Duration.ofMinutes(10))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("já existe");
}
@Test
void semRegiaoUsaAPadrao() {
servico.cadastrar(Trilha.nova("Praia do Saquinho", "Sul", 2.1, Dificuldade.FACIL, Duration.ofMinutes(60)));
assertThat(servico.listar(Optional.empty())).extracting(Trilha::nome).contains("Praia do Saquinho");
}
}
O @SpringBootTest sobe o contexto inteiro, e o @Autowired num campo de teste é o uso aceitável da injeção por campo, porque é o framework de testes que cria a classe. O @ActiveProfiles("test") ativa o perfil de testes, e com ele o ApplicationRunner das trilhas de exemplo fica de fora. O @DynamicPropertySource troca o endereço do banco por um arquivo numa pasta temporária criada pelo @TempDir do JUnit, e cada execução começa com um banco limpo. Como os três testes compartilham o mesmo contexto, e portanto o mesmo arquivo, cada um usa nomes de trilha diferentes, para não depender da ordem em que rodam.
O projeto agora tem estrutura: configuração fora do código, componentes que se encontram sozinhos, transações declaradas com uma anotação e um teste que prova que tudo funciona junto. Mas o SQL continua escrito à mão, e a conversão entre linhas e objetos também: cada coluna nova na tabela exige mudanças no INSERT, no SELECT e no RowMapper.
Fontes e leituras recomendadas
- Spring Boot, "Reference Documentation": a documentação oficial, com as seções sobre configuração externa, perfis,
@ConfigurationPropertiese acesso a dados SQL. https://docs.spring.io/spring-boot/reference/ - Spring Framework, "The IoC Container": o capítulo sobre o contêiner de injeção de dependências, com a recomendação da injeção pelo construtor. https://docs.spring.io/spring-framework/reference/core/beans.html
- Spring Framework, "Using JdbcClient": a documentação do
JdbcClient, com parâmetros nomeados,RowMappere chaves geradas. https://docs.spring.io/spring-framework/reference/data-access/jdbc/core.html#jdbc-JdbcClient - Spring Boot 4.0, "Migration Guide": as mudanças do Spring Boot 4, incluindo os starters renomeados e os novos starters de teste. https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide
- Martin Fowler, "Inversion of Control Containers and the Dependency Injection pattern" (2004): o artigo que deu nome à injeção de dependências e comparou as formas de fazê-la. https://martinfowler.com/articles/injection.html
- Adam Wiggins, "The Twelve-Factor App": os princípios de configuração por ambiente que o Spring Boot segue. https://12factor.net/pt_br/
Exercícios
Exercício 1
Reescreva o ServicoDeTrilhas com injeção por campo (@Autowired private RepositorioDeTrilhas repositorio;) e liste o que se perde em relação à versão com construtor, pensando num teste de unidade sem Spring.
Ver resposta
✓ Resposta: Com injeção por campo, a classe fica com um construtor sem parâmetros, e o Spring preenche o campo por reflexão depois de criá-la. Num teste de unidade, new ServicoDeTrilhas() cria um objeto com o repositório nulo, e o primeiro método chamado lança NullPointerException; para colocar um dublê no lugar, o teste precisaria usar reflexão, ou o Mockito com @InjectMocks, que faz a mesma coisa escondido. Os campos não podem ser final, então o objeto é mutável e pode ser observado pela metade. As dependências somem da assinatura: quem lê a classe não vê, no construtor, do que ela precisa, e uma classe pode acumular quinze dependências sem que ninguém perceba o problema de desenho. E uma dependência circular deixa de ser impossível: como os objetos podem ser criados antes de receberem as dependências, o Spring conseguiria montar o ciclo; desde o Spring Boot 2.6 ele o recusa por padrão, mas só porque a propriedade spring.main.allow-circular-references vem desligada, e com ela ligada o ciclo passa sem aviso. Com o construtor, new ServicoDeTrilhas(repositorioFalso, configuracao) é tudo o que o teste precisa, exatamente como o ServicoDeLembretes de "Dublês e Disciplina", e o contêiner em miniatura desta aula, que só sabe injetar por construtor, funciona com a classe sem nenhuma adaptação.
Exercício 2
A aplicação ganhou duas implementações de uma interface Notificador, uma por e-mail e outra por SMS, ambas anotadas com @Component, e o Spring deixou de subir. Explique o erro, dê duas formas de resolvê-lo no Spring e implemente a primeira delas no contêiner em miniatura.
Ver resposta
✓ Resposta: O serviço pede um Notificador no construtor, e há dois componentes que o implementam: é o terceiro erro do contêiner em miniatura, "componentes demais", que o Spring relata como "required a single bean, but 2 were found". A primeira solução é marcar uma das implementações como a preferida, com @Primary: ela é usada sempre que não há outra indicação. A segunda é dizer, no ponto de injeção, qual se quer, com @Qualifier("porSms") no parâmetro do construtor, usando o nome do componente. Uma terceira, quando o serviço realmente precisa de todas, é pedir uma List<Notificador>, e o Spring injeta as duas. No contêiner em miniatura, a primeira solução é uma anotação e um if:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Principal {}
// em escolherImplementacao, antes do switch:
if (candidatas.size() > 1) {
List<Class<?>> principais = candidatas.stream().filter(c -> c.isAnnotationPresent(Principal.class)).toList();
if (principais.size() == 1) return principais.getFirst();
}
Com @Principal na classe PorSms, pedir um Notificador devolve o de SMS, e pedir PorEmail pelo nome da classe continua devolvendo o de e-mail. A anotação precisa de RetentionPolicy.RUNTIME para ser vista pela reflexão, como "O Programa que se Examina" explicou.
Exercício 3
Um colega acrescentou ao ServicoDeTrilhas um método cadastrarVarias(List<Trilha>), sem anotação, que chama this.cadastrar(t) para cada trilha. A terceira trilha da lista tem nome repetido. O que fica gravado no banco? Por quê?
Ver resposta
✓ Resposta: As duas primeiras trilhas ficam gravadas, e a terceira não, mesmo que a intenção fosse gravar a lista inteira ou nada. E, mais sutil, cada uma das duas primeiras nem foi gravada numa transação própria. O @Transactional funciona através do proxy que o Spring coloca em volta do serviço: quem chama o serviço de fora, como o controlador, chama o proxy, que abre a transação e então chama o método real. Mas this.cadastrar(t), dentro do próprio objeto, chama o método real diretamente, sem passar pelo proxy, e a anotação é ignorada. Os comandos rodam em modo auto-commit, um por vez: se um ponto de interesse de uma trilha falhasse, a trilha ficaria gravada sem ele. A correção é anotar o próprio cadastrarVarias com @Transactional: a transação é aberta na entrada, pelo proxy, e as chamadas internas participam dela, porque a conexão está associada à thread; a exceção da terceira trilha desfaz as três. Essa armadilha, a autoinvocação, é uma consequência direta do padrão Decorator: o decorador só age em chamadas que passam por ele.
Exercício 4
A aplicação precisa rodar em produção com o banco em /var/lib/trilhas/trilhas.db e a região padrão "Norte", sem mudar o JAR. Mostre duas formas de fazer isso e diga qual vence se as duas forem usadas ao mesmo tempo.
Ver resposta
✓ Resposta: A primeira forma é por argumentos de linha de comando: java -jar trilhas-da-ilha-0.1.0.jar --spring.datasource.url=jdbc:sqlite:/var/lib/trilhas/trilhas.db --trilhas.regiao-padrao=Norte. A segunda é por variáveis de ambiente, com os nomes convertidos pelo relaxed binding, em maiúsculas e com _ no lugar de . e -: SPRING_DATASOURCE_URL=jdbc:sqlite:/var/lib/trilhas/trilhas.db e TRILHAS_REGIAO_PADRAO=Norte. As variáveis de ambiente são a forma preferida em contêineres Docker e em plataformas de nuvem, e é a que "Do Laptop ao Servidor" vai usar. Uma terceira forma é um arquivo application.properties na pasta de onde a aplicação é executada, que o Spring Boot também lê e que vence o que está dentro do JAR. Se argumentos de linha de comando e variáveis de ambiente definirem a mesma propriedade, os argumentos vencem: a ordem de prioridade do Spring Boot coloca a linha de comando acima do ambiente, e o ambiente acima dos arquivos. Essa ordem permite, por exemplo, que um valor padrão fique no arquivo, o ambiente de produção o sobrescreva, e uma pessoa investigando um problema o mude mais uma vez numa única execução.
Exercício 5
O application.properties limita o pool a uma única conexão, por causa do SQLite. Imagine que alguém aumente para dez e dispare cinquenta cadastros simultâneos. Que erro é provável, e como isso se relaciona com "Ferramentas de Precisão"?
Ver resposta
✓ Resposta: O SQLite permite várias leituras ao mesmo tempo, mas só uma escrita por vez no arquivo inteiro: uma conexão que começa a gravar obtém uma trava sobre o banco, e as outras que tentam gravar esperam por ela. O driver espera por um tempo limitado (o busy timeout) e, se a trava não for liberada, a operação falha com um erro SQLITE_BUSY, "database is locked". Com dez conexões e cinquenta transações de escrita simultâneas, várias transações esperam a trava ao mesmo tempo, e as que esperam demais falham, produzindo erros 500 intermitentes, que aparecem sob carga e desaparecem nos testes manuais, o pior tipo de defeito. Com uma única conexão no pool, as transações esperam na fila do próprio pool, que é um recurso limitado como o Semaphore de "Ferramentas de Precisão" e de "Um Milhão de Threads": as threads virtuais das requisições são baratas e podem ser milhares, mas o recurso escasso, a escrita no arquivo, é protegido por um limite explícito, e a espera acontece num lugar previsível, com prazo configurável. É a mesma lição de "limite o recurso, não as threads", e é um dos motivos pelos quais a trilha troca o SQLite por bancos servidores, que permitem escritas concorrentes em linhas diferentes, a partir de "PostgreSQL".