🚀 Controle de Gastos com Spring Boot e HTMX

v2.2

Trilha de Aprendizado — Projeto 2 de 3

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:

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:

  1. O que aciona? (hx-trigger): Um clique, o envio de um formulário, etc.
  2. Que requisição fazer? (hx-get, hx-post, hx-put, hx-delete): Os verbos HTTP.
  3. Qual o alvo da atualização? (hx-target): Um seletor CSS que aponta para onde a resposta deve ser colocada.
  4. 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 um hx-target de 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:

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, recebe Permission 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:

  1. 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.
  2. O método editLancamento sempre chama carregarLancamentos(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 /lancamentos retorna 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:

  1. Adicionar: insira um lancamento → lista atualiza sem reload de pagina
  2. Alterar: clique em Alterar → a linha vira formulario de edicao inline
  3. Cancelar: clique em Cancelar → lista volta ao estado normal sem reload
  4. Salvar: edite e clique em Salvar → dado atualizado aparece na lista
  5. 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: 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ório openjdk no 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

2. Configure o Serviço no Render

Variável Valor
SPRING_PROFILES_ACTIVE prod
DB_URL jdbc:postgresql://... (ver abaixo)
DB_USERNAME usuário do Neon
DB_PASSWORD senha do Neon

⚠️ 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 prefixo jdbc: manualmente, resultando em jdbc: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: