Extensão — Spring Boot pela Primeira Vez: Injeção de Dependência

Extensão — Spring Boot pela Primeira Vez: Injeção de Dependência

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.
Java

• • 30 min de leitura

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

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".

Comentários

Mais em Java

Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Asteroids
Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Asteroids

Nesta aula você aprende a detectar colisões com círculos, retângulos e…

Extensão — MySQL: Driver, Dialeto e as Diferenças que Importam
Extensão — MySQL: Driver, Dialeto e as Diferenças que Importam

As Trilhas da Ilha no MySQL 9.7, com ou sem Docker: colação que ignora acentos…

O Projeto Final Começa — Arquitetando um Servidor HTTP sobre Sockets
O Projeto Final Começa — Arquitetando um Servidor HTTP sobre Sockets

Começa o projeto final: o que trafega entre navegador e servidor no HTTP/1.1…