🚀 Controle de Gastos com Spring Boot e HTMX
v1.2
Trilha de Aprendizado — Projeto 1 de 3
- ➡️ v1 (este): CRUD basico (Criar, Listar, Excluir) · H2 local · Deploy Docker + Render + Neon
- v2: + Editar · Tema claro/escuro · padrao
innerHTML- v3: + Paginacao · Saldo em tempo real · Bean Validation
Neste guia, vamos construir uma aplicação web moderna chamada Controle de Gastos. O objetivo é criar um gerenciador financeiro simples com uma experiência de usuário fluida — similar a uma Single Page Application (SPA) — sem a complexidade de frameworks JavaScript.
O que você vai aprender:
- Estruturar um projeto Spring Boot do zero.
- Modelar dados e usar o Spring Data JPA.
- Criar uma interface reativa com Thymeleaf e HTMX.
- Gerenciar ambientes de desenvolvimento e produção com Spring Profiles.
- Fazer o deploy de um banco de dados PostgreSQL no Neon.
- Fazer o deploy de uma aplicação Java no Render com Docker.
Tecnologias:
- Backend: Java 17, Spring Boot 3.x, Spring Data JPA
- Frontend: Thymeleaf + HTMX
- Banco de Dados: H2 (Desenvolvimento), PostgreSQL/Neon (Produção)
- Deploy: Docker, Render
Escopo desta versão: Criar, listar e excluir lançamentos. A funcionalidade de edição será implementada na versão 2.
1. Arquitetura do Projeto (MVC)
Usaremos o padrão Model-View-Controller para organizar o código:
graph TD
subgraph Browser
A["👨💻 Usuário"]
end
subgraph "Aplicação Spring Boot (Docker)"
C[LancamentoController] --> D{LancamentoRepository}
D --> E[Lancamento - Entidade]
C --> B["View: Thymeleaf + HTMX"]
end
subgraph "Banco (Neon)"
F[("PostgreSQL")]
end
A <-->|HTTP| B
B -->|HTMX| C
E --> F
Como o HTMX funciona: Em vez de recarregar a página inteira, o HTMX envia requisições HTTP ao servidor e substitui apenas um trecho do HTML. O servidor responde com um fragmento de HTML, não com JSON.
2. Conceitos Fundamentais
Antes de codificar, entenda as tecnologias que sustentam a aplicação.
2.1. ORM e JPA — Mapeamento Objeto-Relacional
ORM traduz objetos Java em registros de banco de dados automaticamente. O JPA é a especificação; o Hibernate (embutido no Spring Boot) é a implementação.
classDiagram
class Lancamento {
+Long id
+String descricao
+BigDecimal valor
+LocalDate data
+TipoLancamento tipo
}
class TipoLancamento {
<<enumeration>>
RECEITA
DESPESA
}
class lancamento_table {
<<tabela PostgreSQL>>
id BIGSERIAL PK
descricao VARCHAR
valor NUMERIC
data DATE
tipo VARCHAR
}
Lancamento --> TipoLancamento : tem
Lancamento ..> lancamento_table : mapeado por JPA
Anotacoes JPA neste projeto:
| Anotacao | Efeito no banco de dados |
|---|---|
@Entity |
Esta classe se torna uma tabela |
@Id |
Este campo e a chave primaria |
@GeneratedValue(IDENTITY) |
O banco gera o ID (auto-increment) |
@Enumerated(EnumType.STRING) |
Enum salvo como texto (“RECEITA” / “DESPESA”) |
2.2. Repository Pattern — Zero SQL manual
LancamentoRepository extends JpaRepository<Lancamento, Long> herda todos os metodos CRUD automaticamente:
| Metodo herdado | SQL equivalente |
|---|---|
findAll() |
SELECT * FROM lancamento |
findById(id) |
SELECT * FROM lancamento WHERE id = ? |
save(entity) |
INSERT ou UPDATE (detecta qual usar) |
deleteById(id) |
DELETE FROM lancamento WHERE id = ? |
existsById(id) |
SELECT COUNT(*) > 0 WHERE id = ? |
JpaRepository<Lancamento, Long>— primeiro tipo = entidade gerenciada; segundo tipo = tipo da chave primaria.
Estrutura Final do Projeto
controle-de-gastos/
├── .mvn/
├── src/
│ ├── main/
│ │ ├── java/br/com/controledegastos/
│ │ │ ├── ControleDeGastosApplication.java
│ │ │ ├── controller/LancamentoController.java
│ │ │ ├── model/Lancamento.java
│ │ │ ├── model/TipoLancamento.java
│ │ │ └── repository/LancamentoRepository.java
│ │ └── resources/
│ │ ├── templates/index.html
│ │ ├── application.properties
│ │ └── application-prod.properties
│ └── test/
├── Dockerfile <-- arquivo de deploy
├── mvnw
├── mvnw.cmd
└── pom.xml
Fase 1: Configuração Inicial
Passo 1 — Crie uma pasta para o projeto, inicialize o Git e conecte ao GitHub.
Se não estiver habituado ao terminal, use o GitHub Desktop: desktop.github.com/download
mkdir controle-de-gastos
cd controle-de-gastos
git init
git remote add origin https://github.com/seu-usuario/controle-de-gastos.git
git branch -M main
Passo 2 — Gere o projeto no Spring Initializr (start.spring.io):
-
Project: Maven Language: Java Java: 17 -
Group: br.com.controledegastosArtifact: controle-de-gastos - Dependencies:
Spring Web,Spring Data JPA,Thymeleaf,H2 Database,PostgreSQL Driver,Spring Boot DevTools
Passo 3 — Descompacte dentro da pasta criada e abra na IDE.
⚠️ Ação Preventiva 1 — Permissão do mvnw
O mvnw precisa de permissão de execução no Git. Sem isso, o build Docker no Render falha com Permission denied.
git update-index --chmod=+x mvnw
Por que: O Git armazena permissões de arquivos. Windows não marca
mvnwcomo executável ao gerar o projeto. O Docker (Linux) herda essa restrição.
⚠️ Ação Preventiva 2 — pom.xml com Java 17 e UTF-8
Substitua o pom.xml gerado pelo conteúdo abaixo. Isso garante Java 17 e força codificação UTF-8 (evita MalformedInputException no build Docker).
Arquivo: 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>Aplicacao 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-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>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
⚠️ Ação Preventiva 3 — application.properties sem acentos
Arquivos .properties com caracteres acentuados causam MalformedInputException durante o build Docker. Escreva sempre os comentários sem acentos.
Arquivo: src/main/resources/application.properties
# Configuracoes do H2 Database (perfil 'default')
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=true
spring.jpa.hibernate.ddl-auto=update
⚠️ Ação Preventiva 4 — application-prod.properties
Crie este arquivo para o perfil de produção (PostgreSQL). Os valores ${...} serão configurados como variáveis de ambiente no Render.
Arquivo: src/main/resources/application-prod.properties
# Configuracoes do PostgreSQL (perfil 'prod')
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=update
Atenção: Não adicione
spring.jpa.properties.hibernate.dialect. O Spring Boot 3.x detecta o dialeto automaticamente.
Passo 4 — Primeiro commit:
git add .
git commit -m "feat: Estrutura inicial do projeto"
git push -u origin main
Fase 2: A Camada de Dados (Model e Repository)
Arquivo: src/main/java/br/com/controledegastos/model/TipoLancamento.java
package br.com.controledegastos.model;
public enum TipoLancamento {
RECEITA,
DESPESA
}
Arquivo: src/main/java/br/com/controledegastos/model/Lancamento.java
package br.com.controledegastos.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
public class Lancamento {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String descricao;
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; }
}
Arquivo: src/main/java/br/com/controledegastos/repository/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> {
}
JpaRepositoryjá fornece todos os métodos de CRUD (save,findById,findAll,deleteById, etc.) automaticamente.
Fase 3: A Camada de Lógica (Controller)
O Controller recebe as requisições, processa e devolve o HTML.
Ponto Chave: addLancamento e deleteLancamento retornam "index :: lista-lancamentos" — instrução para o Thymeleaf renderizar apenas o fragmento lista-lancamentos. É isso que o HTMX usa para atualizar só aquele trecho da página.
Arquivo: src/main/java/br/com/controledegastos/controller/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 org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.Comparator;
import java.util.List;
@Controller
public class LancamentoController {
@Autowired
private LancamentoRepository lancamentoRepository;
private void carregarLancamentos(Model model) {
List<Lancamento> lancamentos = lancamentoRepository.findAll();
lancamentos.sort(Comparator.comparing(Lancamento::getData).reversed());
model.addAttribute("lancamentos", lancamentos);
}
@GetMapping("/")
public String index(Model model) {
carregarLancamentos(model);
model.addAttribute("novoLancamento", new Lancamento());
model.addAttribute("tipos", TipoLancamento.values());
return "index";
}
@PostMapping("/lancamentos")
public String addLancamento(@ModelAttribute Lancamento novoLancamento, Model model) {
lancamentoRepository.save(novoLancamento);
carregarLancamentos(model);
return "index :: lista-lancamentos";
}
@DeleteMapping("/lancamentos/{id}")
public String deleteLancamento(@PathVariable Long id, Model model) {
lancamentoRepository.deleteById(id);
carregarLancamentos(model);
return "index :: lista-lancamentos";
}
}
Fase 4: A Camada de Apresentação (View)
Atenção: O atributo do botão Excluir usa th:hx-delete (com o prefixo th:). Sem o th:, o Thymeleaf não processa a expressão @{...} e a URL fica literal — o botão não funciona.
Arquivo: src/main/resources/templates/index.html
<!DOCTYPE html>
<html lang="pt-br" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Controle de Gastos</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 40px; background-color: #f4f4f9; color: #333; }
.container { max-width: 800px; margin: auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
form { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
input, select, button { padding: 10px; border-radius: 5px; border: 1px solid #ddd; }
button { background-color: #007bff; color: white; border: none; cursor: pointer; }
button:hover { background-color: #0056b3; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px; border-bottom: 1px solid #ddd; text-align: left; }
.despesa { color: #d9534f; }
.receita { color: #5cb85c; }
.delete-btn { background-color: #d9534f; }
.delete-btn:hover { background-color: #c9302c; }
</style>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>
<div class="container">
<h1>Meu Controle de Gastos 💰</h1>
<form hx-post="/lancamentos"
hx-target="#lista-lancamentos"
hx-swap="outerHTML"
hx-on::after-request="this.reset()">
<input type="text" name="descricao" placeholder="Descricao" required>
<input type="number" step="0.01" name="valor" placeholder="Valor" required>
<select name="tipo" required>
<option th:each="tipoOpt : ${tipos}" th:value="${tipoOpt}" th:text="${tipoOpt}"></option>
</select>
<button type="submit">Adicionar</button>
</form>
<div id="lista-lancamentos" th:fragment="lista-lancamentos">
<table>
<thead>
<tr>
<th>Descricao</th>
<th>Valor</th>
<th>Data</th>
<th>Tipo</th>
<th>Acao</th>
</tr>
</thead>
<tbody>
<tr th:each="lancamento : ${lancamentos}">
<td th:text="${lancamento.descricao}"></td>
<td th:text="|R$ ${#numbers.formatDecimal(lancamento.valor, 1, 'POINT', 2, 'COMMA')}|"
th:class="${lancamento.tipo.name() == 'DESPESA' ? 'despesa' : 'receita'}">
</td>
<td th:text="${#temporals.format(lancamento.data, 'dd/MM/yyyy')}"></td>
<td th:text="${lancamento.tipo.name()}"></td>
<td>
<!--
IMPORTANTE: use th:hx-delete (com th:).
Sem th:, o Thymeleaf não processa @{...} e a URL fica errada.
-->
<button class="delete-btn"
th:hx-delete="@{/lancamentos/{id}(id=${lancamento.id})}"
hx-target="#lista-lancamentos"
hx-swap="outerHTML"
hx-confirm="Tem certeza que deseja excluir?">
Excluir
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>
Como o HTMX funciona aqui:
- Adicionar: O
<form>usahx-post="/lancamentos". O servidor salva e retorna o fragmentolista-lancamentos. O HTMX substitui o<div id="lista-lancamentos">inteiro (outerHTML). - Excluir: O botão usa
th:hx-delete. O servidor exclui e retorna o fragmento atualizado. O HTMX substitui o<div>.
Próxima versão: A funcionalidade de edição (PUT) será implementada na versão 2.
Fase 5: Testando Localmente
Execute a aplicação pela IDE (classe ControleDeGastosApplication.java) e acesse:
- Aplicação:
http://localhost:8080 - Console H2 (banco em memória):
http://localhost:8080/h2-console- JDBC URL:
jdbc:h2:mem:testdb -
User: saPassword: (vazio)
- JDBC URL:
✅ Checkpoint — Antes de avançar para o deploy
Verifique que tudo funciona localmente antes de containerizar:
- Acesse
http://localhost:8080e adicione um lancamento — ele deve aparecer sem reload de pagina- Clique em Excluir — o item deve desaparecer sem reload de pagina
- No H2 Console, execute
SELECT * FROM LANCAMENTO— os dados devem estar laTudo ok? Avance para a containerizacao e o deploy.
Fase 6: Containerização (Dockerfile)
Crie o Dockerfile na raiz do projeto (mesma pasta do pom.xml):
# Etapa 1: Build com Eclipse Temurin JDK 17
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
# Etapa 2: Imagem final com Eclipse Temurin JRE 17 (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"]
Por que Eclipse Temurin? O repositório
openjdkno Docker Hub está depreciado e não tem imagem JRE para Java 17+. Eclipse Temurin é o substituto oficial.
Faça commit e push:
git add .
git commit -m "feat: Implementa CRUD inicial e Dockerfile"
git push origin main
Fase 7: Deploy em Produção (Neon + Render)
7.1. Banco de Dados no Neon
- Acesse neon.tech e faça login (pode usar GitHub).
- Crie um novo projeto (ex:
controle-de-gastos-db). - Clique em Connect → selecione Connection string → copie a URL.
A URL do Neon tem o formato libpq:
postgresql://neondb_owner:SENHA@ep-xxx.neon.tech/neondb?sslmode=require
Para usar no Java, adicione o prefixo
jdbc::jdbc:postgresql://neondb_owner:SENHA@ep-xxx.neon.tech/neondb?sslmode=require
Anote separadamente:
- DB_URL →
jdbc:postgresql://...(com o prefixojdbc:!) - DB_USERNAME →
neondb_owner - DB_PASSWORD → a senha gerada
7.2. Serviço no Render
- Acesse render.com → New Web Service.
- Conecte seu repositório GitHub.
- Configure:
- Runtime: Docker
- Branch:
main - Region: Ohio (US East) — mais próximo do Brasil
- Instance Type: Free
- Em Environment Variables, adicione:
| Variável | Valor |
|---|---|
SPRING_PROFILES_ACTIVE |
prod |
DB_URL |
jdbc:postgresql://... (com o prefixo jdbc:!) |
DB_USERNAME |
neondb_owner |
DB_PASSWORD |
sua senha do Neon |
- Clique em Create Web Service.
O Render detecta o Dockerfile automaticamente e inicia o build. O primeiro deploy leva alguns minutos.
URL pública: https://controle-de-gastos-SeuNome.onrender.com
⚠️ Erros Comuns no Deploy
Erro 1 — Permission denied no build
/bin/sh: ./mvnw: Permission denied
Causa: mvnw não tem permissão de execução no Git.
Solução:
git update-index --chmod=+x mvnw
git commit -m "fix: permissao de execucao do mvnw"
git push origin main
Erro 2 — MalformedInputException
java.nio.charset.MalformedInputException: Input length = 1
Causa: Arquivo .properties com caracteres acentuados.
Solução: Remova os acentos dos comentários em application.properties e application-prod.properties. Verifique que o pom.xml tem <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>.
Erro 3 — App não sobe: typo na DB_URL
Driver org.postgresql.Driver claims to not accept jdbcUrl,
jdcb:postgresql://...
Causa: Typo na variável DB_URL — jdcb: em vez de jdbc:.
Solução: Corrija no Render → Environment:
❌ jdcb:postgresql://host/db?sslmode=require
✅ jdbc:postgresql://host/db?sslmode=require
Erro 4 — Warning HHH90000025
HHH90000025: PostgreSQLDialect does not need to be specified explicitly
Causa: application-prod.properties tem spring.jpa.properties.hibernate.dialect.
Solução: Remova essa linha — o Spring Boot 3.x detecta o dialeto automaticamente.
✅ Conclusão
Parabéns! Você construiu e implantou uma aplicação web completa combinando Spring Boot + HTMX.
O que aprendeu:
- Padrão MVC com Spring Boot
- Fragmentos Thymeleaf + atualização parcial com HTMX
- Deploy com Docker no Render + PostgreSQL no Neon
- Spring Profiles para múltiplos ambientes
Proximo projeto da trilha → Versao 2
No Projeto 2 voce vai evoluir esta aplicacao com:
- Funcionalidade de edicao (metodo HTTP PUT) — CRUD completo
- Interface com tema claro/escuro via CSS custom properties
- Padrao de fragmento diferente:
hx-swap="innerHTML"(fragment e filho do<div>alvo, nao o proprio<div>) - Endpoint dedicado
GET /lancamentospara o botao Cancelar