🚀 Controle de Gastos com Spring Boot e HTMX
v2.2
Trilha de Aprendizado — Projeto 2 de 3
- v1: CRUD basico (Criar, Listar, Excluir) · H2 local · Deploy Docker + Render
- ➡️ v2 (este): + Editar · Tema claro/escuro · padrao
innerHTML- v3: + Paginacao · Saldo em tempo real · Bean Validation
Pre-requisito: Ter concluido o Projeto 1 ou conhecer os conceitos de Spring Boot, Thymeleaf e HTMX.
Bem-vindo ao guia para criar a aplicação Controle de Gastos. Vamos construir uma aplicação Spring Boot com interface dinâmica usando HTMX e implantá-la na nuvem 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
O que ha de novo em relacao ao Projeto 1:
| Recurso | v1 | v2 |
|---|---|---|
| Operacoes CRUD | Criar, Listar, Excluir | + Editar (metodo PUT) |
| CSS | Inline simples | CSS variables + tema claro/escuro |
| Fragmento HTMX | outerHTML (fragment e o <div> alvo) |
innerHTML (fragment e filho do <div>) |
| Cancelar edicao | Nao havia | GET /lancamentos retorna so o fragmento |
1. A Arquitetura e os Conceitos
1.1. Arquitetura do Projeto (MVC)
Usaremos o padrão Model-View-Controller para organizar nosso código:
graph TD
subgraph Browser
A["👨💻 Usuário"]
end
subgraph "Aplicação Spring Boot (Container Docker)"
C[LancamentoController] -- Usa --> D{LancamentoRepository}
D -- Gerencia --> E["Lancamento - Entidade"]
C -- Renderiza --> B["View: Thymeleaf + htmx"]
end
subgraph "Banco de Dados (Neon)"
F[("PostgreSQL")]
end
A <-->|Requisições HTTP| B
B -- Aciona via htmx --> C
E -- Mapeada para --> F
1.2. Como Funciona o HTMX
HTMX potencializa o HTML para que ele possa fazer requisições ao servidor e atualizar partes da página sem recarregar tudo, sem escrever JavaScript complexo. Ele funciona respondendo a 4 perguntas através de atributos HTML:
- O que aciona? (
hx-trigger): Um clique, o envio de um formulário, etc. - Que requisição fazer? (
hx-get,hx-post,hx-put,hx-delete): Os verbos HTTP. - Qual o alvo da atualização? (
hx-target): Um seletor CSS que aponta para onde a resposta deve ser colocada. - Como atualizar? (
hx-swap): A estratégia para inserir a resposta (innerHTML,outerHTML, etc.).
O servidor responde com pequenos fragmentos de HTML, não com JSON.
1.3. O Padrão Fragment com Thymeleaf + HTMX
Este projeto usa um padrão essencial: o Controller pode retornar a página inteira ou apenas um fragmento dela.
// Retorna a página completa (primeiro acesso do navegador)
return "index";
// Retorna apenas um pedaço da página (chamada HTMX)
return "index :: lista-lancamentos";
Regra importante: Cada endpoint HTMX deve retornar o fragmento certo para o alvo certo (
hx-target). Nunca aponte umhx-targetde um fragmento para um endpoint que retorna a página inteira — isso injeta todo o HTML da página dentro de um<div>.
1.4. Principios SOLID e DRY na pratica
Esta versao demonstra dois principios de design aplicados no codigo.
DRY — Don’t Repeat Yourself
Sem DRY (o mesmo bloco repetido em 3 endpoints):
// Em addLancamento, deleteLancamento E getLancamentos:
List<Lancamento> lancamentos = lancamentoRepository.findAll();
lancamentos.sort(Comparator.comparing(Lancamento::getData).reversed());
model.addAttribute("lancamentos", lancamentos);
Com DRY — helper method carregarLancamentos(model):
private void carregarLancamentos(Model model) {
List<Lancamento> lancamentos = lancamentoRepository.findAll();
lancamentos.sort(Comparator.comparing(Lancamento::getData).reversed());
model.addAttribute("lancamentos", lancamentos);
}
// Se a logica mudar (ex: filtro por usuario), muda em UM lugar so.
SRP — Single Responsibility Principle
Cada classe tem UMA responsabilidade:
graph LR
A["Lancamento.java\nModel — representa dados"]
B["LancamentoRepository\nRepository — acessa o banco"]
C["LancamentoController\nController — trata requests HTTP"]
D["index.html\nView — apresenta a interface"]
C -->|chama| B
C -->|popula| D
B -->|gerencia| A
Regra pratica: Se voce precisar descrever uma classe com “faz isso e tambem aquilo”, ela provavelmente viola o SRP.
Estrutura Final do Projeto
controle-de-gastos/
├── .mvn/
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── br/com/controledegastos/
│ │ │ ├── ControleDeGastosApplication.java
│ │ │ ├── controller/
│ │ │ │ └── LancamentoController.java
│ │ │ ├── model/
│ │ │ │ ├── Lancamento.java
│ │ │ │ └── TipoLancamento.java
│ │ │ └── repository/
│ │ │ └── LancamentoRepository.java
│ │ └── resources/
│ │ ├── templates/
│ │ │ └── index.html
│ │ ├── application.properties
│ │ └── application-prod.properties
│ └── test/
├── .gitignore
├── Dockerfile
├── mvnw
├── mvnw.cmd
└── pom.xml
Fase 1: Configuração Inicial do Projeto
Nesta fase preparamos o ambiente e o esqueleto do projeto, já aplicando todas as correções preventivas para evitar erros comuns de build e deploy.
Passo 1 — Inicie o Git em uma nova pasta e conecte-a a um repositório no GitHub.
Passo 2 — Gere o projeto no Spring Initializr (start.spring.io) com:
- Project: Maven
- Language: Java
- Java: 17
- Dependencies:
Spring Web,Spring Data JPA,Thymeleaf,H2 Database,PostgreSQL Driver,Spring Boot DevTools
Passo 3 — Descompacte e abra o projeto na sua IDE.
⚠️ Ação Preventiva 1 — Permissão de Execução do mvnw
O arquivo mvnw precisa ter permissão de execução no Git. Se isso não for feito, o build Docker no Render falhará com o erro Permission denied.
Execute no terminal dentro da pasta do projeto:
git update-index --chmod=+x mvnw
Por que isso acontece? O Git armazena as permissões dos arquivos. Em Windows, ao gerar o projeto, o arquivo
mvnwé salvo sem a flag de execução. Quando o Docker (Linux) tenta rodá-lo, recebePermission denied. Este comando corrige a permissão direto no Git.
⚠️ Ação Preventiva 2 — Configure o pom.xml (Java 17 + UTF-8)
Substitua o conteúdo do pom.xml pelo código abaixo. Ele define Java 17 e força a codificação UTF-8, evitando erros de MalformedInputException durante o build.
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 — Configure o application.properties (sem acentos)
O Maven, durante o build Docker, lê os arquivos .properties como UTF-8. Caracteres acentuados nos comentários causam MalformedInputException e o build falha. Use apenas texto sem acentos nos comentários.
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 — Configure o application-prod.properties
Este arquivo é usado somente em produção (quando SPRING_PROFILES_ACTIVE=prod). Ele lê as credenciais do banco a partir de variáveis de ambiente configuradas 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: O Spring Boot 3.x detecta o dialeto do PostgreSQL automaticamente. Não adicione a propriedade
spring.jpa.properties.hibernate.dialect— ela é desnecessária e gera warnings.
Passo 4 — Faça o primeiro commit com a estrutura corrigida:
git add .
git commit -m "feat: Estrutura inicial do projeto"
git push origin main
Fase 2: A Camada de Dados (Model e Repository)
Definimos a estrutura dos dados e como vamos acessá-los.
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> {
}
Fase 3: A Camada de Lógica (Controller)
O Controller orquestra todas as ações CRUD da aplicação. Preste atenção em dois pontos importantes:
- O método
getLancamentos(GET /lancamentos) existe para que o botão “Cancelar” da edição retorne apenas o fragmento da lista — se ele chamasse/, receberia a página inteira e injetaria o HTML completo dentro do<div>da lista. - O método
editLancamentosempre chamacarregarLancamentos(model)no caminho alternativo, para que o Thymeleaf tenha os dados necessários ao renderizar o fragmento.
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;
import java.util.Optional;
@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());
// Garante que o objeto exista para o parser do Thymeleaf na carga inicial da pagina.
model.addAttribute("lancamentoParaEditar", new Lancamento());
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";
}
// Endpoint dedicado para retornar apenas o fragmento da lista.
// Usado pelo botão "Cancelar" da edição para não injetar a página inteira.
@GetMapping("/lancamentos")
public String getLancamentos(Model model) {
carregarLancamentos(model);
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";
}
carregarLancamentos(model);
return "index :: lista-lancamentos";
}
@PutMapping("/lancamentos/{id}")
public String updateLancamento(@PathVariable Long id, @ModelAttribute Lancamento lancamentoAtualizado, Model model) {
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);
}
carregarLancamentos(model);
return "index :: lista-lancamentos";
}
}
Fase 4: A Camada de Apresentação (View)
A interface usa Thymeleaf para renderização server-side e HTMX para interatividade sem reload da página.
Ponto de atenção no botão Cancelar: ele usa
th:hx-get="@{/lancamentos}"(e não@{/}). O endpoint/lancamentosretorna apenas o fragmento da lista. Se usássemos/, o HTMX receberia a página HTML completa e a injetaria dentro do<div id="lista-lancamentos">, quebrando o layout.
Arquivo: 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>
<style>
/* CSS Variables para Temas Light/Dark */
: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, Helvetica, Arial, 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);
transition: background-color 0.3s;
}
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); }
.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; min-width: 0; }
.col-valor { flex: 2; min-width: 0; }
.col-data { flex: 2; min-width: 0; }
.col-tipo { flex: 2; min-width: 0; }
.col-acoes { flex: 3; min-width: 0; 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); }
.footer {
text-align: center;
margin-top: 24px;
font-size: 12px;
color: var(--subtext-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;
}
</style>
<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 💰</h1>
<form
hx-post="/lancamentos"
hx-target="#lista-lancamentos"
hx-swap="innerHTML"
hx-on::after-request="this.reset()">
<input type="text" name="descricao" placeholder="Descricao" required style="flex-grow: 2;">
<input type="number" step="0.01" name="valor" placeholder="Valor" required style="flex-grow: 1;">
<select name="tipo" required style="flex-grow: 1;">
<option th:each="tipoOpt : ${tipos}" th:value="${tipoOpt}" th:text="${tipoOpt}"></option>
</select>
<button type="submit" class="add-btn" style="flex-grow: 1;">Adicionar</button>
</form>
<div id="lista-lancamentos">
<div th:fragment="lista-lancamentos">
<div class="list-header">
<div class="col-desc">Descricao</div>
<div class="col-valor">Valor</div>
<div class="col-data">Data</div>
<div class="col-tipo">Tipo</div>
<div class="col-acoes">Acoes</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})}"
hx-target="#lista-lancamentos"
hx-swap="innerHTML"
hx-confirm="Tem certeza que deseja excluir?">
Excluir
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="footer">
Softhouse(c)2025
</div>
<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>
<!--
IMPORTANTE: usa /lancamentos (não /) para retornar apenas o fragmento.
Chamar / retornaria a página inteira e o HTMX injetaria
todo o HTML dentro do div da lista, quebrando o layout.
-->
<button class="action-btn cancel-btn"
th:hx-get="@{/lancamentos}"
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>
✅ Checkpoint — Teste local antes do deploy
Execute a aplicacao e verifique cada operacao:
- Adicionar: insira um lancamento → lista atualiza sem reload de pagina
- Alterar: clique em Alterar → a linha vira formulario de edicao inline
- Cancelar: clique em Cancelar → lista volta ao estado normal sem reload
- Salvar: edite e clique em Salvar → dado atualizado aparece na lista
- Excluir: clique em Excluir → item desaparece sem reload
Tudo ok? Avance para o deploy.
Fase 5: Containerização e Deploy
Arquivo: Dockerfile
O Dockerfile usa duas etapas para gerar uma imagem final leve e segura.
- Etapa 1 (
builder): Usa um JDK 17 completo para compilar o projeto com Maven. ORUN chmod +x mvnwgarante a permissão de execução dentro do container (complementar à Ação Preventiva 1). - Etapa 2 (final): Usa apenas um JRE 17, que é menor que o JDK. Copia somente o
.jarcompilado.
# 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 e não
openjdk? O repositórioopenjdkno Docker Hub está depreciado e não oferece imagens JRE para Java 17+. O Eclipse Temurin é o substituto oficial recomendado pela comunidade Java e mantém as variantes JDK e JRE.
Deploy no Render
1. Crie o Banco de Dados no Neon
- Acesse neon.tech, crie um projeto e copie a Connection String no formato JDBC (começa com
jdbc:postgresql://).
2. Configure o Serviço no Render
- Crie um New Web Service e conecte seu repositório GitHub.
- Selecione Docker como Runtime.
- Deixe
Build CommandeStart Commandem branco. - Adicione as Environment Variables:
| Variável | Valor |
|---|---|
SPRING_PROFILES_ACTIVE |
prod |
DB_URL |
jdbc:postgresql://... (ver abaixo) |
DB_USERNAME |
usuário do Neon |
DB_PASSWORD |
senha do Neon |
- Clique em Create Web Service.
⚠️ Erro Comum no Deploy: Typo na DB_URL
Sintoma no log do Render:
Driver org.postgresql.Driver claims to not accept jdbcUrl,
jdcb:postgresql://...
Causa: A URL foi digitada com jdcb: em vez de jdbc:.
Solução: Verifique a variável DB_URL no Render e corrija o prefixo:
❌ 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 usar no Java, você deve adicionar o prefixojdbc:manualmente, resultando emjdbc:postgresql://....
⚠️ Aviso HHH90000025 (PostgreSQLDialect)
Se aparecer este aviso nos logs:
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 a partir da conexão com o banco.
✅ Conclusão
Parabéns! Você construiu uma aplicação completa com Spring Boot, HTMX e deploy profissional com Docker.
Resumo dos pontos críticos para não esquecer:
| Problema | Causa | Solução |
|---|---|---|
Permission denied no build |
mvnw sem flag de execução |
git update-index --chmod=+x mvnw |
MalformedInputException |
Acentos em arquivos .properties |
Escrever comentários sem acentos |
| Botão “Cancelar” quebra layout | HTMX recebendo página inteira | Endpoint GET /lancamentos retorna só o fragmento |
| App não sobe no Render | Typo jdcb: na DB_URL |
Verificar e corrigir o prefixo para jdbc: |
Warning HHH90000025 |
Dialeto PostgreSQL declarado explicitamente | Remover hibernate.dialect do application-prod.properties |
Proximo projeto da trilha → Versao 3
No Projeto 3 voce vai evoluir a aplicacao com:
- Paginacao (5 itens por pagina com Spring Data
Pageable) - Saldo em tempo real (calculado no servidor a cada atualizacao)
- Bean Validation (
@Valid,@NotBlank,@Positive) para bloquear dados invalidos - CSS externo em arquivo separado (
static/css/style.css) - Tecnica avancada: detectar o header
HX-Requestno controller para o mesmo endpointGET /servir fragmento (HTMX) ou pagina completa (navegador)