🚀 Construindo e Implantando o “Controle de Gastos” com Spring Boot e htmx
v1.2
Vamos construir juntos uma aplicação web moderna e robusta chamada Controle de Gastos. O objetivo é criar um gerenciador financeiro simples, mas com uma experiência de usuário fluida, similar a uma Single Page Application (SPA), sem a complexidade de frameworks JavaScript.
Ao final, você terá uma aplicação funcional implantada na nuvem, pronta para ser usada e compartilhada.
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 diferentes ambientes (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.
Vamos começar!
🗂️ Fase 1: Preparação do Ambiente e Controle de Versão
Todo projeto profissional começa com um bom controle de versão. Vamos usar Git e hospedá-lo no GitHub.
-
Crie a Pasta do Projeto: Abra seu terminal e crie uma pasta para o projeto.
mkdir controle-de-gastos cd controle-de-gastos -
Inicialize o Repositório Git:
git init -
Crie o Repositório no GitHub:
- Vá para o GitHub e crie um novo repositório (pode ser público ou privado).
- NÃO inicialize com
README,.gitignoreou licença. Queremos um repositório limpo. - Após criar, o GitHub lhe dará os comandos para conectar seu repositório local. Copie-os e execute no seu terminal. Serão parecidos com isto:
git remote add origin https://github.com/seu-usuario/controle-de-gastos.git git branch -M main # Faremos o primeiro push após criar o projeto na próxima fase.
🏗️ Fase 2: Criação do Esqueleto do Projeto
Usaremos o Spring Initializr para gerar a base da nossa aplicação com todas as dependências necessárias.
-
Acesse o Spring Initializr.
-
Preencha os campos da seguinte forma:
- Project: Maven
- Language: Java
- Spring Boot: A versão mais recente (ex: 3.x.x)
- Project Metadata:
- Group:
br.com.controledegastos - Artifact:
controle-de-gastos - Name:
controle-de-gastos - Description:
Aplicação para controle de gastos pessoais - Package name:
br.com.controledegastos
- Group:
- Packaging: Jar
- Java: 17
-
No campo Dependencies, clique em “ADD DEPENDENCIES” e adicione as seguintes:
Spring Web: Para criar controllers e APIs REST.Spring Data JPA: Para persistência de dados de forma simples.Thymeleaf: Nosso motor de templates para renderizar o HTML.H2 Database: Banco de dados em memória para o ambiente de desenvolvimento.PostgreSQL Driver: Para conectar com nosso banco de produção.Spring Boot DevTools: Para live reload durante o desenvolvimento.
-
Clique em GENERATE. Um arquivo
.zipserá baixado. -
Descompacte o conteúdo do
.zipdentro da pastacontrole-de-gastosque criamos na Fase 1. A estrutura de arquivos deve ficar assim:controle-de-gastos/ ├── .git/ ├── .mvn/ ├── src/ ├── mvnw ├── mvnw.cmd └── pom.xml -
Faça o primeiro commit:
git add . git commit -m "🚀 Feat: Initial project structure from Spring Initializr" git push -u origin main
💾 Fase 3: Modelagem de Dados
Vamos definir a estrutura do nosso “Lançamento” financeiro e o repositório para acessá-lo no banco de dados.
-
Crie o Enum
TipoLancamento: Dentro desrc/main/java/br/com/controledegastos/, crie um novo pacote chamadomodel. Dentro dele, crie um EnumTipoLancamento.java.// src/main/java/br/com/controledegastos/model/TipoLancamento.java package br.com.controledegastos.model; public enum TipoLancamento { RECEITA, DESPESA } -
Crie a Entidade
Lancamento: No mesmo pacotemodel, crie a classeLancamento.java.// 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; // Getters e Setters 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; } } -
Crie o Repositório
LancamentoRepository: Crie um novo pacote chamadorepository. Dentro dele, crie a interfaceLancamentoRepository.java.// 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> { }O
JpaRepositoryjá nos fornece todos os métodos de CRUD (save,findById,findAll,deleteById, etc.) magicamente!
⚙️ Fase 4: Lógica de Backend (Controller)
O Controller irá receber as requisições do navegador, processá-las e devolver o HTML.
-
Crie o
LancamentoController: Crie um novo pacote chamadocontroller. Dentro dele, crie a classeLancamentoController.java.// 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; // Método para carregar a página principal @GetMapping("/") public String index(Model model) { List<Lancamento> lancamentos = lancamentoRepository.findAll(); lancamentos.sort(Comparator.comparing(Lancamento::getData).reversed()); model.addAttribute("lancamentos", lancamentos); model.addAttribute("novoLancamento", new Lancamento()); model.addAttribute("tipos", TipoLancamento.values()); return "index"; // Retorna o arquivo templates/index.html } // Método para adicionar um novo lançamento (via htmx) @PostMapping("/lancamentos") public String addLancamento(@ModelAttribute Lancamento novoLancamento, Model model) { lancamentoRepository.save(novoLancamento); // Após salvar, recarregamos a lista e a retornamos como um fragmento List<Lancamento> lancamentos = lancamentoRepository.findAll(); lancamentos.sort(Comparator.comparing(Lancamento::getData).reversed()); model.addAttribute("lancamentos", lancamentos); // Retorna apenas o fragmento da tabela, não a página inteira return "index :: lista-lancamentos"; } // Método para excluir um lançamento (via htmx) @DeleteMapping("/lancamentos/{id}") public String deleteLancamento(@PathVariable Long id, Model model) { lancamentoRepository.deleteById(id); // Após deletar, recarregamos a lista e a retornamos como um fragmento List<Lancamento> lancamentos = lancamentoRepository.findAll(); lancamentos.sort(Comparator.comparing(Lancamento::getData).reversed()); model.addAttribute("lancamentos", lancamentos); // Retorna apenas o fragmento da tabela return "index :: lista-lancamentos"; } }Ponto Chave: Note que os métodos
addLancamentoedeleteLancamentoretornam"index :: lista-lancamentos". Isso é uma instrução para o Thymeleaf renderizar apenas o pedaço (fragmento) do HTML chamadolista-lancamentosdentro do arquivoindex.html. É essa a mágica que o htmx usará para atualizar a página dinamicamente.
🎨 Fase 5: Construção da Interface com Thymeleaf e htmx
Agora vamos criar a interface que o usuário verá. Ela será um único arquivo HTML que será atualizado dinamicamente pelo htmx.
-
Crie o arquivo
index.html: Dentro desrc/main/resources/templates/, crie o arquivoindex.html. -
Adicione o conteúdo HTML: Este código inclui um formulário para adicionar lançamentos e uma tabela para listá-los. Observe os atributos
hx-*, que são os comandos do htmx.<!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="Descrição" 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>Descrição</th> <th>Valor</th> <th>Data</th> <th>Tipo</th> <th>Ação</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> <button class="delete-btn" 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>temhx-post="/lancamentos". Ao submeter, ele envia um POST para essa URL.hx-target="#lista-lancamentos"ehx-swap="outerHTML"dizem para pegar a resposta (que é o fragmento da tabela atualizada) e substituir todo o elemento<div id="lista-lancamentos">. - Excluir: O
<button>de exclusão temhx-delete="/lancamentos/...". Ao clicar, ele envia uma requisição DELETE. A resposta (a tabela atualizada) também substitui o<div id="lista-lancamentos">.
- Adicionar: O
🌍 Fase 6: Configuração de Ambientes
Vamos configurar a aplicação para usar o H2 em desenvolvimento e o PostgreSQL em produção.
-
Arquivo
application.properties(Desenvolvimento): Este arquivo, localizado emsrc/main/resources/, já é o perfil padrão. Vamos configurá-lo para o H2.# Configurações do H2 Database (perfil 'default') spring.datasource.url=jdbcmem:testdb spring.datasource.driverClassName=org.h2.Driver spring.datasource.username=sa spring.datasource.password= spring.h2.console.enabled=true # JPA spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=updateAgora você pode rodar sua aplicação localmente. Abra a classe
ControleDeGastosApplication.javana sua IDE e execute-a. Acessehttp://localhost:8080ehttp://localhost:8080/h2-console(URL do JDBC:jdbcmem:testdb) para testar. -
Arquivo
application-prod.properties(Produção): Na mesma pasta, crie um novo arquivo chamadoapplication-prod.properties. Este arquivo será ativado quando o perfilprodestiver ativo.# Configurações do PostgreSQL (perfil 'prod') # Usaremos variáveis de ambiente no Render para preencher estes valores spring.datasource.url=${DB_URL} spring.datasource.username=${DB_USERNAME} spring.datasource.password=${DB_PASSWORD} # JPA spring.jpa.hibernate.ddl-auto=update spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialectOs valores
${...}serão substituídos por variáveis de ambiente que configuraremos na plataforma de deploy. -
Commit das mudanças:
git add . git commit -m "✨ Feat: Implement CRUD and SPA interface with htmx" git push origin main
1. O Acesso Padrão do Nosso Guia
No guia que construímos, o acesso à página principal foi configurado para a raiz do servidor. Isso foi feito no arquivo LancamentoController.java com a seguinte anotação:
@GetMapping("/") // <-- Esta linha define o endereço
public String index(Model model) {
// ...
}Isso significa que, ao rodar o projeto localmente, a forma correta de acessar a página do Thymeleaf é:
URL (pelo guia): http://localhost:8080/
Como mudar a rota padrão1 mais informações.
☁️ Fase 7: Deploy em Produção
É hora de colocar nossa aplicação no ar!
7.1. Criando o Banco de Dados no Neon
- Vá para Neon e faça login (pode usar sua conta do GitHub).
- Crie um novo projeto. Dê um nome, como
controle-de-gastos-db. - Após a criação, você será levado a um painel. Na seção Connection Details, você encontrará as informações que precisamos.
- O Neon fornece uma URL de conexão completa. Guarde as seguintes partes:
- Host: (ex:
ep-plain-snow-123456.us-east-2.aws.neon.tech) - Database: (o nome do seu banco)
- User: (o nome do seu usuário)
- Password: (a senha que foi gerada)
- Host: (ex:
7.2. Fazendo o Deploy da Aplicação no Render
-
Vá para Render e faça login (também pode usar sua conta do GitHub).
-
No painel, clique em New + > Web Service.
-
Conecte seu repositório do GitHub e selecione o repositório
controle-de-gastos. -
Na tela de configuração, preencha:
- Name:
controle-de-gastos(ou o que preferir). - Region: Escolha a mais próxima de você.
- Branch:
main. - Runtime:
Java. O Render geralmente detecta isso. - Build Command:
./mvnw clean install - Start Command:
java -jar target/controle-de-gastos-0.0.1-SNAPSHOT.jar - Instance Type: Free.
- Name:
-
Agora, a parte mais importante: as Variáveis de Ambiente. Clique em Advanced.
-
Clique em Add Environment Variable e adicione as seguintes chaves e valores:
Chave Valor SPRING_PROFILES_ACTIVEprodDB_USERNAMEO usuário do seu banco de dados Neon. DB_PASSWORDA senha do seu banco de dados Neon. DB_URLjdbc//<HOST_DO_NEON>/<DB_NAME_DO_NEON>?sslmode=requireAtenção: Substitua
<HOST_DO_NEON>e<DB_NAME_DO_NEON>pelos valores que você pegou do painel do Neon. A parte?sslmode=requireé essencial para a conexão funcionar.
-
-
Role para baixo e clique em Create Web Service.
O Render irá buscar seu código do GitHub, construir a aplicação e iniciá-la. O primeiro deploy pode levar alguns minutos. Você pode acompanhar o progresso nos logs.
Quando terminar, o Render fornecerá uma URL pública (ex: https://controle-de-gastos.onrender.com), e sua aplicação estará ao vivo! 🎉
✅ Conclusão
Parabéns! Você construiu do zero e implantou uma aplicação web completa com uma stack moderna. Você aprendeu a combinar o poder do Spring Boot no backend com a simplicidade e reatividade do htmx no frontend, criando uma experiência de usuário ágil sem escrever JavaScript complexo.
Próximos Passos Sugeridos:
- Adicionar validação nos campos do formulário.
- Implementar a funcionalidade de edição de um lançamento.
- Melhorar o CSS e a aparência geral da aplicação.
- Adicionar um sistema de usuários e autenticação com Spring Security.
Espero que este guia tenha sido útil e didático. Agora você tem uma base sólida para criar muitas outras aplicações incríveis!
Apêndice
2. Como Alterar o Acesso para localhost:8080/controle-de-gastos
Se você deseja que sua aplicação responda no caminho /controle-de-gastos, você precisa fazer um pequeno ajuste no seu Controller. A maneira mais organizada de fazer isso é adicionar um mapeamento base para toda a classe.
Passo 1: Modificar o LancamentoController.java
Adicione a anotação @RequestMapping("/controle-de-gastos") no topo da sua classe LancamentoController. Isso fará com que todos os caminhos (endpoints) dentro desta classe sejam prefixados com /controle-de-gastos.
O GetMapping para o método index agora pode ser apenas /, pois ele será combinado com o prefixo da classe.
Veja como o arquivo deve ficar:
// src/main/java/br/com/controledegastos/controller/LancamentoController.java
package br.com.controledegastos.controller;
import br.com.controledegastos.model.Lancamento;
// ... outras importações
import org.springframework.web.bind.annotation.RequestMapping; // <-- IMPORTE ESTA CLASSE
@Controller
@RequestMapping("/controle-de-gastos") // <-- ADICIONE ESTA LINHA
public class LancamentoController {
@Autowired
private LancamentoRepository lancamentoRepository;
// O GetMapping agora responde em "/controle-de-gastos/"
@GetMapping
public String index(Model model) {
// ... o código do método continua o mesmo
List<Lancamento> lancamentos = lancamentoRepository.findAll();
lancamentos.sort(Comparator.comparing(Lancamento::getData).reversed());
model.addAttribute("lancamentos", lancamentos);
model.addAttribute("novoLancamento", new Lancamento());
model.addAttribute("tipos", TipoLancamento.values());
return "index";
}
// Este endpoint agora será "/controle-de-gastos/lancamentos"
@PostMapping("/lancamentos")
public String addLancamento(@ModelAttribute Lancamento novoLancamento, Model model) {
// ... o código do método continua o mesmo
}
// Este endpoint agora será "/controle-de-gastos/lancamentos/{id}"
@DeleteMapping("/lancamentos/{id}")
public String deleteLancamento(@PathVariable Long id, Model model) {
// ... o código do método continua o mesmo
}
}Passo 2: Atualizar o index.html
Como mudamos os endereços no backend, precisamos atualizar os endereços que o htmx usa no frontend para que as requisições de adicionar e excluir continuem funcionando.
Abra o arquivo src/main/resources/templates/index.html e ajuste os atributos hx-post e hx-delete para incluir o novo caminho base. A melhor forma de fazer isso é usando a sintaxe @{...} do Thymeleaf, que constrói a URL corretamente.
Altere estas linhas:
<form hx-post="/lancamentos" ...>
<button ...
hx-delete="/lancamentos/[[${lancamento.id}]]"
...>Para isto:
<form th:action="@{/controle-de-gastos/lancamentos}"
hx-post="@{/controle-de-gastos/lancamentos}"
...>
<button ...
hx-delete="@{/controle-de-gastos/lancamentos/{id}(id=${lancamento.id})}"
...>- Observação: Usar
th:actionjunto comhx-posté uma boa prática para garantir que o formulário funcione mesmo se o JavaScript (ou htmx) falhar. O atributohx-postirá prevalecer.
Resumo da Operação
- Pare a aplicação se ela estiver rodando.
- Adicione
@RequestMapping("/controle-de-gastos")no topo da classeLancamentoController. - Altere
@GetMapping("/")para@GetMappingno métodoindex. - Atualize os caminhos
hx-postehx-deleteno arquivoindex.htmlpara incluir o prefixo/controle-de-gastos. - Rode a aplicação novamente.
Agora, você poderá acessar sua página Thymeleaf localmente pela URL que desejava:
➡️ http://localhost:8080/controle-de-gastos
Footnotes
-
Mudando a Rota ↩