🚀 Controle de Gastos com Spring Boot e HTMX

v1.2

Trilha de Aprendizado — Projeto 1 de 3

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:

Tecnologias:

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):

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 mvnw como 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> {
}

JpaRepository já 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:

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:


✅ Checkpoint — Antes de avançar para o deploy

Verifique que tudo funciona localmente antes de containerizar:

  1. Acesse http://localhost:8080 e adicione um lancamento — ele deve aparecer sem reload de pagina
  2. Clique em Excluir — o item deve desaparecer sem reload de pagina
  3. No H2 Console, execute SELECT * FROM LANCAMENTO — os dados devem estar la

Tudo 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 openjdk no 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

  1. Acesse neon.tech e faça login (pode usar GitHub).
  2. Crie um novo projeto (ex: controle-de-gastos-db).
  3. 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:

7.2. Serviço no Render

  1. Acesse render.comNew Web Service.
  2. Conecte seu repositório GitHub.
  3. Configure:
    • Runtime: Docker
    • Branch: main
    • Region: Ohio (US East) — mais próximo do Brasil
    • Instance Type: Free
  4. 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
  1. 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_URLjdcb: 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:


Proximo projeto da trilha → Versao 2

No Projeto 2 voce vai evoluir esta aplicacao com: