🚀 Controle de Gastos com Spring Boot e HTMX
v3.0
Trilha de Aprendizado — Projeto 3 de 3
- v1: CRUD basico (Criar, Listar, Excluir) · H2 local · Deploy Docker + Render
- v2: + Editar · Tema claro/escuro · padrao
innerHTML- ➡️ v3 (este): + Paginacao · Saldo em tempo real · Bean Validation · CSS externo
Pre-requisito: Ter concluido o Projeto 2 com o deploy funcionando no Render + Neon.
Bem-vindo ao guia da Versao 3. Neste projeto, voce vai trabalhar como em um time de desenvolvimento real: usando Metodologia Agil para evoluir a aplicacao, adicionar novas features e acompanhar a esteira de CI/CD.
O que ha de novo em relacao ao Projeto 2:
| Recurso | v2 | v3 |
|---|---|---|
| CSS | Inline no HTML | Arquivo externo (static/css/style.css) |
| Saldo | Nao havia | Card com saldo calculado em tempo real |
| Tamanho da lista | Ilimitado | Paginacao (5 itens/pagina) |
| Validacao de dados | Nenhuma | Bean Validation (@Valid, @NotBlank, @Positive) |
| Botao Cancelar | GET /lancamentos (endpoint dedicado) |
GET / com deteccao do header HX-Request |
Tecnologias:
- Backend: Java 17, Spring Boot 3.x, Spring Data JPA
- Frontend: Thymeleaf + HTMX + CSS Separado
- Banco de Dados: H2 (Desenvolvimento), PostgreSQL (Produção no Neon)
- Deploy: Docker (Java 17), Render
🏔️ O Desafio Ágil (Product Backlog)
Para esta versão, o nosso Product Owner priorizou as seguintes Histórias de Usuário:
[US01] Cálculo de Saldo em Tempo Real
- Como usuário do controle de gastos,
- Quero ver meu saldo atualizado automaticamente a cada operação,
- Para que eu saiba exatamente quanto dinheiro tenho.
- Critérios de Aceite:
- O saldo deve ser a soma das Receitas menos as Despesas.
- O saldo deve aparecer em destaque na tela e atualizar via HTMX junto com a tabela.
[US02] Refatoração: Separação de CSS
- Como desenvolvedor da equipe,
- Quero que o código CSS esteja em um arquivo separado do HTML,
- Para que o projeto siga as boas práticas.
[US03] Automatização da Esteira (CI/CD)
- Como equipe de DevOps,
- Quero que o deploy seja acionado automaticamente a cada push na branch
main, - Para que possamos ver a esteira funcionando no Render.
[US04] API REST com Swagger
- Como desenvolvedor ou integrador externo,
- Quero acessar os lancamentos via API REST documentada com Swagger,
- Para que outros sistemas possam consumir os dados e o time possa testar endpoints interativamente — sem precisar da interface web.
- Criterios de Aceite:
- Endpoints CRUD disponíveis em
/api/v1/lancamentos - Swagger UI acessível em
/swagger-ui/index.html - Todos os endpoints documentados com summary e response codes
- Interface web Thymeleaf continua funcionando normalmente (os dois coexistem)
- Endpoints CRUD disponíveis em
📋 Guia de Implementação (As Tarefas da Sprint)
Aqui está o passo a passo técnico para implementar as histórias acima.
Fase 1: Ajuste de Versão (Garantindo Java 17)
Como nosso ambiente de produção está rodando com Java 17, garanta que seu pom.xml esteja configurado corretamente:
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
Fase 2: Lógica de Saldo (Atendendo a US01)
No seu LancamentoController.java, refatore o método de carregar dados para calcular o saldo e passá-lo para a View.
// Método auxiliar para carregar dados e calcular o saldo
private void carregarDados(Model model) {
List<Lancamento> lancamentos = lancamentoRepository.findAll();
lancamentos.sort(Comparator.comparing(Lancamento::getData).reversed());
// Cálculo do Saldo (Receitas - Despesas)
BigDecimal saldo = lancamentos.stream()
.map(l -> l.getTipo() == TipoLancamento.RECEITA ? l.getValor() : l.getValor().negate())
.reduce(BigDecimal.ZERO, BigDecimal::add);
model.addAttribute("lancamentos", lancamentos);
model.addAttribute("saldo", saldo);
}
Fase 3: Refatoração do CSS (Atendendo a US02)
Crie o arquivo src/main/resources/static/css/style.css e mova todos os estilos para lá. No seu index.html, remova a tag <style> e linke o arquivo externo:
<link rel="stylesheet" th:href="@{/css/style.css}">
Fase 4: Atualização da View com Saldo
Insira o card de saldo dentro do fragmento que o HTMX atualiza no index.html:
<div th:fragment="lista-lancamentos">
<!-- Card de Saldo -->
<div class="saldo-container">
<div class="saldo-card" th:classappend="${saldo >= 0 ? 'positivo' : 'negativo'}">
<h3>Saldo Atual</h3>
<p th:text="|R$ ${#numbers.formatDecimal(saldo, 1, 'POINT', 2, 'COMMA')}|"></p>
</div>
</div>
...
Fase 5: Containerização e CI/CD (Atendendo a US03)
Para que a esteira funcione corretamente no Render e use a versão correta do Java, atualize o seu Dockerfile para usar a imagem do Java 17.
Arquivo: Dockerfile
# Estágio 1: Build da Aplicação
FROM eclipse-temurin:17-jdk-jammy as builder
WORKDIR /app
COPY .mvn/ .mvn
COPY mvnw .
COPY pom.xml .
RUN chmod +x mvnw
RUN ./mvnw dependency:go-offline
COPY src ./src
RUN ./mvnw clean package -DskipTests
# Estágio 2: Imagem Final (Alterado para Java 17)
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
🔄 Acompanhando a Esteira
Após terminar as implementações:
- Faça o commit e o push:
git push origin main. - Abra o painel do Render e acompanhe os logs do build Docker. Você verá a esteira trabalhando e o projeto subindo sozinho!
⚠️ Configuração inicial do Render (se ainda não foi feita)
Se é a primeira vez que você faz o deploy deste projeto, configure as variáveis de ambiente no serviço do Render:
Variável Valor SPRING_PROFILES_ACTIVEprodDB_URLjdbc:postgresql://ep-xxx.neon.tech/neondb?sslmode=requireDB_USERNAMEneondb_ownerDB_PASSWORDsua senha do Neon Atenção com a
DB_URL: o Neon exibe a Connection String no formatopostgresql://usuario:senha@host/db. Para o ecossistema Java/Spring, você deve:
- Adicionar o prefixo
jdbc:→jdbc:postgresql://...- Remover
usuario:senha@da URL (esses valores devem ser passados separadamente emDB_USERNAMEeDB_PASSWORD)- Remover
&channel_binding=requirese estiver presente na URLPara criar o serviço no Render e o banco no Neon do zero, consulte a Fase de criação do Banco de Dados Neon (ou a Fase 7 do Projeto 1).
💡 Recomendações Importantes para Render & Neon:
- Connection Pooling: O Neon possui suporte nativo a pooling de conexões (via PgBouncer). Em projetos de produção ou com alta concorrência, utilize a porta do pooler (geralmente
6543) em vez da porta padrão (5432) para evitar exaustão de conexões.- Auto-suspend (Cold Start) do Neon: Na modalidade gratuita, o Neon suspende a instância de computação após 5 minutos de inatividade. O primeiro acesso após esse período pode demorar alguns segundos a mais (tempo de inicialização). Certifique-se de configurar um timeout de conexão adequado no Spring Boot (ex:
connectionTimeout = 30000msvia HikariCP) para evitar falhas durante o aquecimento.- Spin-down do Render (Free Tier): Os serviços web gratuitos do Render entram em modo de suspensão após 15 minutos de inatividade. O primeiro request pode demorar até 50 segundos para subir o contêiner. Para evitar isso em demonstrações ou ambientes de teste críticos, configure um pinger simples (como o UptimeRobot) para fazer requisições periódicas a cada 10-14 minutos.
- Tratamento de SSL/TLS: Certifique-se de manter o parâmetro
?sslmode=requirena URL de conexão para garantir que todo o tráfego entre a aplicação no Render e o banco de dados Neon seja criptografado de forma segura.
✅ Checkpoint — Após o deploy
- Aguarde a finalização do build no painel do Render (pode levar de 5 a 10 minutos).
- Acesse a URL pública fornecida pelo Render e verifique se a aplicação carrega corretamente.
- Adicione um novo lançamento e verifique se ele é persistido após atualizar (refresh) a página.
Fase 6: Validação de Dados (Bean Validation)
Para evitar que dados inválidos ou vazios sejam salvos no banco, vamos usar o Bean Validation.
6.1 Adicionar Dependência
No arquivo pom.xml, adicione a dependência de validação:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
6.2 Anotar o Model
Abra Lancamento.java e adicione as anotações de validação nos campos:
@NotBlank(message = "A descrição é obrigatória")
private String descricao;
@NotNull(message = "O valor é obrigatório")
@Positive(message = "O valor deve ser positivo")
private BigDecimal valor;
6.3 Validar no Controller
No LancamentoController.java, use @Valid no método addLancamento e trate os erros:
Nota: Neste momento
carregarDadosainda tem a assinatura sem o parametro de pagina (isso muda na Fase 7). UsecarregarDados(model)aqui — na Fase 7 voce vai atualizar a assinatura e substituir porcarregarDados(model, 0).
@PostMapping("/lancamentos")
public String addLancamento(@Valid @ModelAttribute("novoLancamento") Lancamento novoLancamento, BindingResult result, Model model) {
if (result.hasErrors()) {
carregarDados(model);
model.addAttribute("tipos", TipoLancamento.values());
model.addAttribute("lancamentoParaEditar", new Lancamento());
return "index"; // Retorna a pagina inteira com os erros
}
lancamentoRepository.save(novoLancamento);
carregarDados(model);
return "index :: lista-lancamentos";
}
6.4 Exibir Erros na View
No index.html, atualize o formulário para exibir as mensagens de erro:
<div style="flex-grow: 2; display: flex; flex-direction: column;">
<input type="text" th:field="*{descricao}" placeholder="Descrição" required>
<span th:if="${#fields.hasErrors('descricao')}" th:errors="*{descricao}" style="color: var(--red-color); font-size: 12px;"></span>
</div>
Fase 7: Implementando Paginação
Para evitar que a lista fique infinitamente grande, vamos limitar a exibição a 5 itens por página.
7.1 Atualizar o Controller
No LancamentoController.java, altere o método carregarDados para aceitar o número da página:
private void carregarDados(Model model, int page) {
Pageable pageable = PageRequest.of(page, 5, Sort.by("data").descending());
Page<Lancamento> lancamentosPage = lancamentoRepository.findAll(pageable);
// Cálculo do saldo (usando todos os lançamentos para o saldo total)
List<Lancamento> todosLancamentos = lancamentoRepository.findAll();
BigDecimal saldo = todosLancamentos.stream()
.map(l -> l.getTipo() == TipoLancamento.RECEITA ? l.getValor() : l.getValor().negate())
.reduce(BigDecimal.ZERO, BigDecimal::add);
model.addAttribute("lancamentos", lancamentosPage.getContent());
model.addAttribute("saldo", saldo);
model.addAttribute("currentPage", page);
model.addAttribute("totalPages", lancamentosPage.getTotalPages());
}
7.2 Evitar Nesting no HTMX
Como a paginação faz uma requisição para / buscando a próxima página, se retornarmos o template index inteiro, o HTMX vai colocar a página inteira dentro da tabela (gerando um efeito de “página dentro de página”). Para evitar isso, altere o método index para retornar apenas o fragmento quando a requisição for do HTMX:
@GetMapping("/")
public String index(@RequestParam(defaultValue = "0") int page,
@RequestHeader(value = "HX-Request", required = false) boolean isHtmx,
Model model) {
carregarDados(model, page);
model.addAttribute("novoLancamento", new Lancamento());
model.addAttribute("tipos", TipoLancamento.values());
model.addAttribute("lancamentoParaEditar", new Lancamento());
if (isHtmx) {
return "index :: lista-lancamentos";
}
return "index";
}
7.3 Adicionar Controles na View
No index.html, adicione o bloco de paginação no final do fragmento lista-lancamentos:
<div class="pagination" th:if="${totalPages > 1}" style="display: flex; justify-content: center; gap: 8px; margin-top: 20px;">
<button th:each="pageNumber : ${#numbers.sequence(0, totalPages - 1)}"
th:text="${pageNumber + 1}"
th:classappend="${pageNumber == currentPage} ? 'active' : ''"
th:hx-get="@{/(page=${pageNumber})}"
hx-target="#lista-lancamentos"
hx-swap="innerHTML">
</button>
</div>
Fase 8: API REST com Swagger (Atendendo a US04)
Nesta fase, a aplicacao passa a expor dois tipos de interface ao mesmo tempo:
graph LR
subgraph Browser
U["Usuário\nInterface Web"]
S["Swagger UI\n/swagger-ui/index.html"]
end
subgraph "Spring Boot"
TC["LancamentoController\nGET / POST / PUT / DELETE\nRetorna HTML (Thymeleaf)"]
RC["LancamentoRestController\n/api/v1/lancamentos\nRetorna JSON"]
end
subgraph "Banco"
DB[("PostgreSQL\nLancamentoRepository")]
end
U -->|HTMX requests| TC
S -->|HTTP requests| RC
TC --> DB
RC --> DB
8.1 Adicionar Dependencia no pom.xml
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
springdoc-openapile as anotacoes@RestController,@Operatione@Tage gera a documentacao automaticamente em/swagger-ui/index.html.
8.2 Criar LancamentoRestController.java
package br.com.controledegastos.controller;
import br.com.controledegastos.model.Lancamento;
import br.com.controledegastos.repository.LancamentoRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/api/v1/lancamentos")
@Tag(name = "Lancamentos", description = "CRUD de lancamentos financeiros")
public class LancamentoRestController {
@Autowired
private LancamentoRepository lancamentoRepository;
@GetMapping
@Operation(summary = "Listar todos os lancamentos")
public ResponseEntity<List<Lancamento>> listar() {
List<Lancamento> lancamentos = lancamentoRepository.findAll();
lancamentos.sort(Comparator.comparing(Lancamento::getData).reversed());
return ResponseEntity.ok(lancamentos);
}
@GetMapping("/{id}")
@Operation(summary = "Buscar lancamento por ID")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Lancamento encontrado"),
@ApiResponse(responseCode = "404", description = "Nao encontrado")
})
public ResponseEntity<Lancamento> buscarPorId(
@Parameter(description = "ID do lancamento") @PathVariable Long id) {
return lancamentoRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
@Operation(summary = "Criar novo lancamento")
@ApiResponse(responseCode = "201", description = "Criado com sucesso")
public ResponseEntity<Lancamento> criar(@Valid @RequestBody Lancamento lancamento) {
Lancamento salvo = lancamentoRepository.save(lancamento);
return ResponseEntity.created(URI.create("/api/v1/lancamentos/" + salvo.getId())).body(salvo);
}
@PutMapping("/{id}")
@Operation(summary = "Atualizar lancamento existente")
public ResponseEntity<Lancamento> atualizar(
@PathVariable Long id, @Valid @RequestBody Lancamento lancamentoAtualizado) {
Optional<Lancamento> lancamentoOpt = lancamentoRepository.findById(id);
if (lancamentoOpt.isPresent()) {
Lancamento existente = lancamentoOpt.get();
existente.setDescricao(lancamentoAtualizado.getDescricao());
existente.setValor(lancamentoAtualizado.getValor());
existente.setTipo(lancamentoAtualizado.getTipo());
existente.setData(lancamentoAtualizado.getData());
return ResponseEntity.ok(lancamentoRepository.save(existente));
}
return ResponseEntity.notFound().build();
}
@DeleteMapping("/{id}")
@Operation(summary = "Excluir lancamento")
@ApiResponse(responseCode = "204", description = "Excluido com sucesso")
public ResponseEntity<Void> excluir(@PathVariable Long id) {
if (lancamentoRepository.existsById(id)) {
lancamentoRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
}
}
Diferencas entre @Controller e @RestController:
@Controller (Thymeleaf) |
@RestController (REST API) |
|---|---|
Retorna nome de template ("index") |
Retorna objeto Java serializado como JSON |
Resposta: text/html |
Resposta: application/json |
Usa Model para passar dados |
Usa ResponseEntity<T> |
| Para interfaces web | Para integrações e APIs |
8.3 Criar SwaggerConfig.java
package br.com.controledegastos.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("Controle de Gastos API")
.version("3.0.0")
.description("API REST para gerenciamento de lancamentos. Interface web disponivel em /"));
}
}
8.4 Adicionar link do Swagger na interface
No index.html, atualize o footer para incluir o link:
<footer style="text-align: center; margin-top: 20px; color: var(--subtext-color); font-size: 14px;">
<p>© 2026 - Controle de Gastos v3</p>
<p>
<a th:href="@{/swagger-ui/index.html}" target="_blank"
style="color: var(--primary-color); text-decoration: none; font-size: 12px;">
📖 API REST — Swagger UI
</a>
</p>
</footer>
8.5 Testar o Swagger
Suba a aplicacao e acesse: http://localhost:8080/swagger-ui/index.html
Voce vera todos os endpoints documentados e pode executar chamadas diretamente pelo browser (sem Postman ou curl).
Conceito REST — Verbos HTTP e Recursos:
Verbo Endpoint Acao HTTP Status GET/api/v1/lancamentosListar todos 200 OK GET/api/v1/lancamentos/{id}Buscar por ID 200 / 404 POST/api/v1/lancamentosCriar novo 201 Created PUT/api/v1/lancamentos/{id}Atualizar 200 OK DELETE/api/v1/lancamentos/{id}Excluir 204 No Content
🏆 Gabarito Completo (Código Final)
Aqui estão os códigos completos de todos os arquivos do projeto. Use para conferir ou corrigir erros!
1. pom.xml
<?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>3.5.5</version>
<relativePath/>
</parent>
<groupId>br.com.controledegastos</groupId>
<artifactId>controle-de-gastos</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>controle-de-gastos</name>
<description>Aplicação para controle de gastos pessoais</description>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
2. Dockerfile
# Estagio 1: Build
FROM eclipse-temurin:17-jdk-jammy as builder
WORKDIR /app
COPY .mvn/ .mvn
COPY mvnw .
COPY pom.xml .
RUN chmod +x mvnw
RUN ./mvnw dependency:go-offline
COPY src ./src
RUN ./mvnw clean package -DskipTests
# Estagio 2: Imagem final (mais leve)
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
3. src/main/resources/application.properties
# Configuracoes do H2 Database (perfil 'default' - desenvolvimento local)
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=true
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update
4. src/main/resources/application-prod.properties
# Configuracoes para producao (Render + Neon PostgreSQL)
# Ative este perfil no Render com: SPRING_PROFILES_ACTIVE=prod
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=update
spring.h2.console.enabled=false
5. TipoLancamento.java
package br.com.controledegastos.model;
public enum TipoLancamento {
RECEITA,
DESPESA
}
6. Lancamento.java
package br.com.controledegastos.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
public class Lancamento {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "A descricao e obrigatoria")
private String descricao;
@NotNull(message = "O valor e obrigatorio")
@Positive(message = "O valor deve ser positivo")
private BigDecimal valor;
private LocalDate data = LocalDate.now();
@Enumerated(EnumType.STRING)
private TipoLancamento tipo;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getDescricao() { return descricao; }
public void setDescricao(String descricao) { this.descricao = descricao; }
public BigDecimal getValor() { return valor; }
public void setValor(BigDecimal valor) { this.valor = valor; }
public LocalDate getData() { return data; }
public void setData(LocalDate data) { this.data = data; }
public TipoLancamento getTipo() { return tipo; }
public void setTipo(TipoLancamento tipo) { this.tipo = tipo; }
}
7. LancamentoRepository.java
package br.com.controledegastos.repository;
import br.com.controledegastos.model.Lancamento;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface LancamentoRepository extends JpaRepository<Lancamento, Long> {
}
8. ControleDeGastosApplication.java
package br.com.controledegastos;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ControleDeGastosApplication {
public static void main(String[] args) {
SpringApplication.run(ControleDeGastosApplication.class, args);
}
}
9. LancamentoController.java
package br.com.controledegastos.controller;
import br.com.controledegastos.model.Lancamento;
import br.com.controledegastos.model.TipoLancamento;
import br.com.controledegastos.repository.LancamentoRepository;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
@Controller
public class LancamentoController {
@Autowired
private LancamentoRepository lancamentoRepository;
private void carregarDados(Model model, int page) {
Pageable pageable = PageRequest.of(page, 5, Sort.by("data").descending());
Page<Lancamento> lancamentosPage = lancamentoRepository.findAll(pageable);
List<Lancamento> todosLancamentos = lancamentoRepository.findAll();
BigDecimal saldo = todosLancamentos.stream()
.map(l -> l.getTipo() == TipoLancamento.RECEITA ? l.getValor() : l.getValor().negate())
.reduce(BigDecimal.ZERO, BigDecimal::add);
model.addAttribute("lancamentos", lancamentosPage.getContent());
model.addAttribute("saldo", saldo);
model.addAttribute("currentPage", page);
model.addAttribute("totalPages", lancamentosPage.getTotalPages());
}
@GetMapping("/")
public String index(@RequestParam(defaultValue = "0") int page,
@RequestHeader(value = "HX-Request", required = false) boolean isHtmx,
Model model) {
carregarDados(model, page);
model.addAttribute("novoLancamento", new Lancamento());
model.addAttribute("tipos", TipoLancamento.values());
model.addAttribute("lancamentoParaEditar", new Lancamento());
if (isHtmx) {
return "index :: lista-lancamentos";
}
return "index";
}
@PostMapping("/lancamentos")
public String addLancamento(@Valid @ModelAttribute("novoLancamento") Lancamento novoLancamento, BindingResult result, Model model) {
if (result.hasErrors()) {
carregarDados(model, 0);
model.addAttribute("tipos", TipoLancamento.values());
model.addAttribute("lancamentoParaEditar", new Lancamento());
return "index";
}
lancamentoRepository.save(novoLancamento);
carregarDados(model, 0);
return "index :: lista-lancamentos";
}
@DeleteMapping("/lancamentos/{id}")
public String deleteLancamento(@PathVariable Long id, @RequestParam(defaultValue = "0") int page, Model model) {
lancamentoRepository.deleteById(id);
carregarDados(model, page);
return "index :: lista-lancamentos";
}
@GetMapping("/lancamentos/editar/{id}")
public String editLancamento(@PathVariable Long id, Model model) {
Optional<Lancamento> lancamentoOpt = lancamentoRepository.findById(id);
if (lancamentoOpt.isPresent()) {
model.addAttribute("lancamentoParaEditar", lancamentoOpt.get());
model.addAttribute("tipos", TipoLancamento.values());
return "index :: form-edicao";
}
carregarDados(model, 0);
return "index :: lista-lancamentos";
}
@PutMapping("/lancamentos/{id}")
public String updateLancamento(@PathVariable Long id, @Valid @ModelAttribute("lancamentoParaEditar") Lancamento lancamentoAtualizado, BindingResult result, Model model) {
if (result.hasErrors()) {
carregarDados(model, 0);
model.addAttribute("tipos", TipoLancamento.values());
return "index";
}
Optional<Lancamento> lancamentoOpt = lancamentoRepository.findById(id);
if (lancamentoOpt.isPresent()) {
Lancamento lancamentoExistente = lancamentoOpt.get();
lancamentoExistente.setDescricao(lancamentoAtualizado.getDescricao());
lancamentoExistente.setValor(lancamentoAtualizado.getValor());
lancamentoExistente.setTipo(lancamentoAtualizado.getTipo());
lancamentoExistente.setData(lancamentoAtualizado.getData());
lancamentoRepository.save(lancamentoExistente);
}
carregarDados(model, 0);
return "index :: lista-lancamentos";
}
}
10. LancamentoRestController.java
package br.com.controledegastos.controller;
import br.com.controledegastos.model.Lancamento;
import br.com.controledegastos.repository.LancamentoRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/api/v1/lancamentos")
@Tag(name = "Lancamentos", description = "CRUD de lancamentos financeiros")
public class LancamentoRestController {
@Autowired
private LancamentoRepository lancamentoRepository;
@GetMapping
@Operation(summary = "Listar todos os lancamentos", description = "Retorna a lista completa ordenada por data decrescente")
public ResponseEntity<List<Lancamento>> listar() {
List<Lancamento> lancamentos = lancamentoRepository.findAll();
lancamentos.sort(Comparator.comparing(Lancamento::getData).reversed());
return ResponseEntity.ok(lancamentos);
}
@GetMapping("/{id}")
@Operation(summary = "Buscar lancamento por ID")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Lancamento encontrado"),
@ApiResponse(responseCode = "404", description = "Lancamento nao encontrado")
})
public ResponseEntity<Lancamento> buscarPorId(
@Parameter(description = "ID do lancamento") @PathVariable Long id) {
return lancamentoRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
@Operation(summary = "Criar novo lancamento")
@ApiResponse(responseCode = "201", description = "Lancamento criado com sucesso")
public ResponseEntity<Lancamento> criar(@Valid @RequestBody Lancamento lancamento) {
Lancamento salvo = lancamentoRepository.save(lancamento);
return ResponseEntity.created(URI.create("/api/v1/lancamentos/" + salvo.getId())).body(salvo);
}
@PutMapping("/{id}")
@Operation(summary = "Atualizar lancamento existente")
public ResponseEntity<Lancamento> atualizar(
@PathVariable Long id,
@Valid @RequestBody Lancamento lancamentoAtualizado) {
Optional<Lancamento> lancamentoOpt = lancamentoRepository.findById(id);
if (lancamentoOpt.isPresent()) {
Lancamento existente = lancamentoOpt.get();
existente.setDescricao(lancamentoAtualizado.getDescricao());
existente.setValor(lancamentoAtualizado.getValor());
existente.setTipo(lancamentoAtualizado.getTipo());
existente.setData(lancamentoAtualizado.getData());
return ResponseEntity.ok(lancamentoRepository.save(existente));
}
return ResponseEntity.notFound().build();
}
@DeleteMapping("/{id}")
@Operation(summary = "Excluir lancamento")
@ApiResponse(responseCode = "204", description = "Lancamento excluido com sucesso")
public ResponseEntity<Void> excluir(@PathVariable Long id) {
if (lancamentoRepository.existsById(id)) {
lancamentoRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
}
}
11. SwaggerConfig.java
package br.com.controledegastos.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("Controle de Gastos API")
.version("3.0.0")
.description("API REST para gerenciamento de lancamentos financeiros. Interface web disponivel em /"));
}
}
12. src/main/resources/templates/index.html
<!DOCTYPE html>
<html lang="pt-br" xmlns:th="http://www.thymeleaf.org" class="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Controle de Gastos</title>
<link rel="stylesheet" th:href="@{/css/style.css}">
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>
<button id="theme-toggle">🌙</button>
<div class="container">
<h1>Meu Controle de Gastos 2.3 💰</h1>
<!-- Formulário de Cadastro -->
<form th:object="${novoLancamento}" hx-post="/lancamentos"
hx-target="#lista-lancamentos"
hx-swap="innerHTML"
hx-on::after-request="this.reset()">
<div style="display: flex; gap: 12px; flex-wrap: wrap; width: 100%;">
<div style="flex-grow: 2; display: flex; flex-direction: column;">
<input type="text" th:field="*{descricao}" placeholder="Descrição" required>
<span th:if="${#fields.hasErrors('descricao')}" th:errors="*{descricao}" style="color: var(--red-color); font-size: 12px;"></span>
</div>
<div style="flex-grow: 1; display: flex; flex-direction: column;">
<input type="number" step="0.01" th:field="*{valor}" placeholder="Valor" required>
<span th:if="${#fields.hasErrors('valor')}" th:errors="*{valor}" style="color: var(--red-color); font-size: 12px;"></span>
</div>
<div style="flex-grow: 1;">
<select th:field="*{tipo}" required style="width: 100%; height: 38px;">
<option th:each="tipoOpt : ${tipos}" th:value="${tipoOpt}" th:text="${tipoOpt}"></option>
</select>
</div>
<button type="submit" class="add-btn" style="height: 38px;">Adicionar</button>
</div>
</form>
<!-- Container que o HTMX vai atualizar -->
<div id="lista-lancamentos">
<div th:fragment="lista-lancamentos">
<!-- Card de Saldo -->
<div class="saldo-container">
<div class="saldo-card" th:classappend="${saldo >= 0 ? 'positivo' : 'negativo'}">
<h3>Saldo Atual</h3>
<p th:text="|R$ ${#numbers.formatDecimal(saldo, 1, 'POINT', 2, 'COMMA')}|"></p>
</div>
</div>
<!-- Tabela de Lançamentos -->
<div class="list-header">
<div class="col-desc">Descrição</div>
<div class="col-valor">Valor</div>
<div class="col-data">Data</div>
<div class="col-tipo">Tipo</div>
<div class="col-acoes">Ações</div>
</div>
<div th:each="lancamento : ${lancamentos}" th:id="'lancamento-' + ${lancamento.id}" class="list-item">
<div class="col-desc" th:text="${lancamento.descricao}"></div>
<div class="col-valor valor" th:text="|R$ ${#numbers.formatDecimal(lancamento.valor, 1, 'POINT', 2, 'COMMA')}|"
th:classappend="${lancamento.tipo.name() == 'DESPESA' ? 'despesa' : 'receita'}">
</div>
<div class="col-data" th:text="${#temporals.format(lancamento.data, 'dd/MM/yyyy')}"></div>
<div class="col-tipo" th:text="${lancamento.tipo.name()}"></div>
<div class="col-acoes">
<div class="actions-wrapper">
<button class="action-btn edit-btn"
th:hx-get="@{/lancamentos/editar/{id}(id=${lancamento.id})}"
th:hx-target="'#lancamento-' + ${lancamento.id}"
hx-swap="outerHTML">
Alterar
</button>
<button class="action-btn delete-btn"
th:hx-delete="@{/lancamentos/{id}(id=${lancamento.id}, page=${currentPage})}"
hx-target="#lista-lancamentos"
hx-swap="innerHTML"
hx-confirm="Tem certeza que deseja excluir?">
Excluir
</button>
</div>
</div>
</div>
<!-- Paginação -->
<div class="pagination" th:if="${totalPages > 1}" style="display: flex; justify-content: center; gap: 8px; margin-top: 20px;">
<button th:each="pageNumber : ${#numbers.sequence(0, totalPages - 1)}"
th:text="${pageNumber + 1}"
th:classappend="${pageNumber == currentPage} ? 'active' : ''"
th:hx-get="@{/(page=${pageNumber})}"
hx-target="#lista-lancamentos"
hx-swap="innerHTML"
style="padding: 5px 10px; border: 1px solid var(--border-color); border-radius: 3px; cursor: pointer; background-color: var(--card-bg-color); color: var(--text-color);">
</button>
</div>
</div>
</div>
<!-- Footer -->
<footer style="text-align: center; margin-top: 20px; color: var(--subtext-color); font-size: 14px;">
<p>© 2026 - Controle de Gastos v3</p>
<p>
<a th:href="@{/swagger-ui/index.html}" target="_blank"
style="color: var(--primary-color); text-decoration: none; font-size: 12px;">
📖 API REST — Swagger UI
</a>
</p>
</footer>
</div>
<!-- Fragmento Oculto para Edição -->
<div style="display: none;">
<th:block th:fragment="form-edicao">
<div th:id="'lancamento-' + ${lancamentoParaEditar.id}" class="edit-form-row">
<div class="col-desc">
<input type="text" name="descricao" th:value="${lancamentoParaEditar.descricao}">
</div>
<div class="col-valor">
<input type="number" step="0.01" name="valor" th:value="${lancamentoParaEditar.valor}">
</div>
<div class="col-data">
<input type="date" name="data" th:value="${#temporals.format(lancamentoParaEditar.data, 'yyyy-MM-dd')}">
</div>
<div class="col-tipo">
<select name="tipo">
<option th:each="tipoOpt : ${tipos}"
th:value="${tipoOpt}"
th:text="${tipoOpt}"
th:selected="${tipoOpt == lancamentoParaEditar.tipo}"></option>
</select>
</div>
<div class="col-acoes">
<div class="actions-wrapper">
<button class="action-btn save-btn"
th:hx-put="@{/lancamentos/{id}(id=${lancamentoParaEditar.id})}"
hx-target="#lista-lancamentos"
hx-swap="innerHTML"
hx-include="closest .edit-form-row">
Salvar
</button>
<button class="action-btn cancel-btn"
th:hx-get="@{/}"
hx-target="#lista-lancamentos"
hx-swap="innerHTML">
Cancelar
</button>
</div>
</div>
</div>
</th:block>
</div>
<script>
const themeToggle = document.getElementById('theme-toggle');
const html = document.documentElement;
themeToggle.addEventListener('click', () => {
if (html.classList.contains('dark')) {
html.classList.remove('dark');
html.classList.add('light');
themeToggle.textContent = '🌙';
} else {
html.classList.remove('light');
html.classList.add('dark');
themeToggle.textContent = '☀️';
}
});
</script>
</body>
</html>
13. src/main/resources/static/css/style.css
:root {
--bg-color: #f4f5f7;
--card-bg-color: #ffffff;
--text-color: #172b4d;
--subtext-color: #6b778c;
--border-color: #dfe1e6;
--primary-color: #0052cc;
--primary-hover-color: #0065ff;
--green-color: #36b37e;
--red-color: #de350b;
--yellow-color: #ffab00;
--grey-color: #6b778c;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
html.dark {
--bg-color: #172b4d;
--card-bg-color: #283e5d;
--text-color: #b0c0db;
--subtext-color: #8c9cb5;
--border-color: #42526e;
--primary-color: #4c9aff;
--primary-hover-color: #69b4ff;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 40px;
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s, color 0.3s;
}
.container {
max-width: 800px;
margin: auto;
background: var(--card-bg-color);
padding: 24px;
border-radius: 8px;
box-shadow: var(--shadow);
}
/* Card de Saldo */
.saldo-container {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.saldo-card {
background: var(--bg-color);
padding: 15px 30px;
border-radius: 8px;
text-align: center;
border: 2px solid var(--border-color);
min-width: 200px;
}
.saldo-card h3 {
margin: 0;
font-size: 14px;
text-transform: uppercase;
color: var(--subtext-color);
}
.saldo-card p {
margin: 5px 0 0 0;
font-size: 24px;
font-weight: bold;
}
.saldo-card.positivo p { color: var(--green-color); }
.saldo-card.negativo p { color: var(--red-color); }
/* Formulários */
form {
display: flex;
gap: 12px;
margin-bottom: 24px;
flex-wrap: wrap;
}
input, select {
padding: 10px;
border-radius: 5px;
border: 1px solid var(--border-color);
font-size: 14px;
background-color: var(--bg-color);
color: var(--text-color);
}
button {
padding: 10px 15px;
border-radius: 5px;
border: none;
font-size: 14px;
font-weight: 600;
color: white;
cursor: pointer;
transition: background-color 0.2s;
}
.add-btn { background-color: var(--primary-color); }
.add-btn:hover { background-color: var(--primary-hover-color); }
/* Estrutura da Lista */
.list-header, .list-item, .edit-form-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--border-color);
}
.list-header {
font-weight: 600;
color: var(--subtext-color);
font-size: 12px;
text-transform: uppercase;
}
.col-desc { flex: 4; }
.col-valor { flex: 2; }
.col-data { flex: 2; }
.col-tipo { flex: 2; }
.col-acoes { flex: 3; justify-content: flex-end; display: flex;}
.valor { text-align: right; }
.valor.despesa { color: var(--red-color); }
.valor.receita { color: var(--green-color); }
.actions-wrapper { display: flex; gap: 8px; }
.action-btn { font-size: 12px; padding: 6px 12px; }
.edit-btn { background-color: var(--yellow-color); }
.delete-btn { background-color: var(--red-color); }
.save-btn { background-color: var(--green-color); }
.cancel-btn { background-color: var(--grey-color); }
#theme-toggle {
position: fixed;
top: 20px;
right: 20px;
background-color: var(--card-bg-color);
color: var(--text-color);
border: 1px solid var(--border-color);
width: 40px;
height: 40px;
border-radius: 50%;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
⚠️ Erros Comuns e Como Resolver
Erro 1 — App não sobe no Render: Typo na DB_URL
Sintoma no log:
Driver org.postgresql.Driver claims to not accept jdbcUrl,
jdcb:postgresql://...
Causa: A variável DB_URL foi digitada com jdcb: em vez de jdbc:.
Solução: Verifique no painel do Render → Environment Variables:
❌ ERRADO: jdcb:postgresql://host/db?sslmode=require
✅ CORRETO: jdbc:postgresql://host/db?sslmode=require
O Neon fornece a Connection String no formato libpq (
postgresql://...). Para Java, adicione o prefixojdbc:manualmente →jdbc:postgresql://....
Erro 2 — App não sobe: credenciais embutidas na DB_URL
Sintoma no log:
JDBC URL invalid port number: <senha>@<host>
Driver org.postgresql.Driver claims to not accept jdbcUrl,
jdbc:postgresql://neondb_owner:<senha>@ep-xxx.neon.tech/neondb?sslmode=require
Causa: O Neon fornece a Connection String no formato libpq, com usuário e senha embutidos na URL:
postgresql://neondb_owner:<senha>@ep-xxx.neon.tech/neondb?sslmode=require
O driver JDBC do PostgreSQL não suporta o padrão user:password@host — ele tenta interpretar <senha>@<host> como número de porta e falha.
Solução: Separe as credenciais da URL nas variáveis de ambiente do Render:
| Variável | Valor correto |
|---|---|
DB_URL |
jdbc:postgresql://ep-xxx.neon.tech/neondb?sslmode=require |
DB_USERNAME |
neondb_owner |
DB_PASSWORD |
<sua senha do Neon> |
Dica: o Neon também oferece o parâmetro
&channel_binding=requirena Connection String. Remova-o daDB_URL— ele não é necessário para Java/JDBC.
Erro 3 — Warning HHH90000025 nos logs
Sintoma:
HHH90000025: PostgreSQLDialect does not need to be specified explicitly
Causa: O arquivo application-prod.properties contém a linha:
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
Solução: Remova essa linha. O Hibernate 6 (usado pelo Spring Boot 3.x) detecta o dialeto automaticamente.
Conceito — Por que o botão “Cancelar” usa @{/} e funciona?
Em versões anteriores, chamar @{/} no botão cancelar era um bug: o HTMX recebia a página HTML inteira e a injetava dentro do <div> da lista.
Nesta versão, o problema foi resolvido de forma elegante no próprio Controller. O método index() detecta o cabeçalho HX-Request que o HTMX sempre envia automaticamente em todas as suas requisições:
@GetMapping("/")
public String index(...,
@RequestHeader(value = "HX-Request", required = false) boolean isHtmx,
...) {
...
if (isHtmx) {
return "index :: lista-lancamentos"; // Retorna só o fragmento
}
return "index"; // Retorna a página inteira (acesso normal do browser)
}
Isso também resolve a paginação: os botões de página chamam GET /?page=N via HTMX — como incluem o cabeçalho HX-Request, o servidor responde com o fragmento correto.
✅ Resumo das Correções da v3
| Problema | Causa | Solução |
|---|---|---|
Permission denied no build |
mvnw sem flag de execução |
git update-index --chmod=+x mvnw + RUN chmod +x mvnw no Dockerfile |
MalformedInputException |
Acentos em arquivos .properties |
Comentários sem acentos nos arquivos de configuração |
Warning HHH90000025 |
Dialeto declarado explicitamente | Remover hibernate.dialect do application-prod.properties |
App não sobe: typo na DB_URL |
jdcb: em vez de jdbc: |
Corrigir a variável no painel do Render |
JDBC URL invalid port number |
Credenciais embutidas na URL (user:pass@host) |
Separar em DB_URL (sem credenciais), DB_USERNAME e DB_PASSWORD |
editLancamento quebra ao não encontrar ID |
Model vazio ao renderizar fragmento | Chamar carregarDados(model, 0) antes do retorno alternativo |
✅ Conclusao da Trilha
Parabens! Voce concluiu os tres projetos da serie Spring Boot + HTMX.
O que voce construiu e aprendeu em cada versao:
| Versao | Features | Conceitos novos |
|---|---|---|
| v1 | Criar, Listar, Excluir | Spring MVC, Thymeleaf, HTMX (outerHTML), H2, Docker, Render + Neon |
| v2 | + Editar | Fragment innerHTML, HTTP PUT, CSS variables, tema claro/escuro |
| v3 | + Paginacao, Saldo, Validacao | Pageable, Bean Validation (@Valid), header HX-Request, CSS externo |
Tecnologias dominadas:
- Spring Boot 3.x + Java 17 — Spring MVC, Spring Data JPA, Bean Validation
- Thymeleaf — server-side rendering com fragmentos parciais
- HTMX — interatividade sem JavaScript complexo
- Docker (Eclipse Temurin 17) + Render — deploy containerizado com CI/CD automatico
- Neon (PostgreSQL serverless) — banco de dados em producao