📚 Pré-requisitos Teóricos: este projeto aplica conceitos ensinados em Especialização em Backend com Java e Spring Boot. Recomendado revisar antes de começar.
🚀 Controle de Gastos — Trabalho em Equipe Ágil
v4.0
Trilha de Aprendizado — Projeto 4 de 4
- v1: CRUD básico (Criar, Listar, Excluir) · H2 local · Deploy Docker + Render
- v2: + Editar · Tema claro/escuro · padrão
innerHTML- v3: + Paginação · Saldo em tempo real · Bean Validation · API REST/Swagger
- ➡️ v4 (este): Levantamento de Requisitos · Backlog & Sprints (Scrum) · Git Flow (branch/PR/merge) · 3 novas features
🎓 Nível profissional simulado: Sênior. A diferença de sênior para pleno raramente é “sabe mais sintaxe” — é saber lidar com ambiguidade. Aqui você não recebe uma lista de features prontas: recebe um cliente confuso, extrai requisitos você mesmo, decide o que fica de fora do escopo (e por quê), prioriza um backlog, e entrega através de um processo que outras pessoas conseguem revisar e confiar (branches, PRs, code review). Sênior pensa em produto e em time, não só em código.
Pré-requisito: Ter concluído o Projeto 3 (v3) — este projeto não reensina Spring Boot/HTMX do zero. O foco aqui é como um time de verdade decide o que construir e entrega isso com processo, não só “mais uma feature”.`
Nos projetos 1 a 3 você sempre recebeu a lista de features prontas (“implemente X”). Na vida real, ninguém te entrega isso pronto — alguém (um cliente, um product owner) descreve um problema de forma confusa, em linguagem natural, e é trabalho do time extrair os requisitos daí, organizar em um backlog, dividir em sprints e entregar via um fluxo de Git colaborativo (branches, Pull Requests, code review).
Este projeto simula esse ciclo completo, do zero ao deploy, evoluindo o código do Projeto 3 com 3 novas funcionalidades reais.
—`
Dona Marta é dona de uma papelaria de bairro e usa o Controle de Gastos (v3) há um mês. Ela te manda a seguinte mensagem, sem nenhuma organização técnica — é assim que requisitos chegam no mundo real:
“Oi! Então, tá funcionando bem o sisteminha, mas preciso de umas coisas. Primeiro: eu lanço tudo junto, contas de luz, compra de mercadoria, salário dos funcionários… fica uma bagunça só, queria separar por tipo de gasto sabe, tipo ‘Fornecedores’, ‘Contas Fixas’, ‘Salários’, essas coisas, e poder ver só de um tipo quando eu quiser. Segundo, meu contador vive pedindo uma planilha com tudo pra ele conferir no Excel dele, hoje eu tenho que copiar linha por linha, um Saco. Terceiro — e essa é importante — mês passado eu estourei o orçamento sem perceber, queria que o sistema me avisasse quando eu tô gastando demais no mês, tipo um alerta vermelho. Ah, e também queria trocar a cor do sistema pra roxo porque é a cor da minha loja, mas isso é só um desejo, não é urgente. Precisa ser rápido viu, meu computador da loja é meio lento.”
Um briefing como esse nunca vira código diretamente. O primeiro passo de qualquer levantamento de requisitos é separar o que a Dona Marta disse em Requisitos Funcionais (o que o sistema deve fazer) e Requisitos Não-Funcionais (que qualidades o sistema deve ter).
Requisitos Funcionais (RF):
| ID | Requisito | Origem no briefing |
|---|---|---|
| RF01 | O sistema deve permitir classificar cada lançamento em uma categoria (ex.: Fornecedores, Contas Fixas, Salários) | “queria separar por tipo de gasto” |
| RF02 | O sistema deve permitir filtrar a lista de lançamentos por categoria | “poder ver só de um tipo quando eu quiser” |
| RF03 | O sistema deve exportar os lançamentos em um formato que abra no Excel | “uma planilha com tudo pra ele conferir no Excel” |
| RF04 | O sistema deve alertar visualmente quando os gastos do mês ultrapassarem um limite definido | “me avisasse quando eu tô gastando demais” |
| RF05 (fora do escopo desta sprint) | Permitir personalizar a cor do tema | “trocar a cor do sistema pra roxo” |
Requisitos Não-Funcionais (RNF):
| ID | Requisito | Origem no briefing |
|---|---|---|
| RNF01 | O sistema deve responder rapidamente mesmo em hardware modesto | “meu computador da loja é meio lento” |
| RNF02 | A exportação deve usar um formato universalmente compatível com Excel (CSV) | “conferir no Excel dele” |
💡 Por que separar RF de RNF? Um Requisito Funcional vira uma feature (algo que aparece no backlog como User Story). Um Requisito Não-Funcional geralmente não vira uma US isolada — ele vira uma restrição de qualidade que se aplica a todas as USs (ex.: RNF01 significa “toda query nova precisa ter índice/paginação”, não é uma tarefa separada).
RF05 foi explicitamente marcado como fora do escopo desta sprint — Dona Marta mesma disse “isso é só um desejo, não é urgente”. Saber dizer não agora (sem descartar para sempre) é parte do levantamento de requisitos: RF05 vai para o backlog do produto, mas não para a sprint atual.
RF01-RF04 viram as User Stories que alimentam o backlog da Fase 2. Note que um requisito às vezes vira mais de uma US (RF01 e RF02 estão relacionados, mas RF01 é “guardar a categoria” e RF02 é “filtrar por ela” — são entregas incrementais separadas):
| Requisito | User Story |
|---|---|
| RF01 | US02a — Categorizar lançamentos |
| RF02 | US02b — Filtrar por categoria |
| RF03 + RNF02 | US03 — Exportar lançamentos em CSV |
| RF04 | US04 — Alerta de orçamento mensal |
—`
| ID | User Story | Prioridade |
|---|---|---|
| US01 | (já entregue no Projeto 3) Como usuário, quero ver meu saldo atualizado em tempo real | ✅ Feito |
| US02a | Como Dona Marta, quero classificar cada lançamento em uma categoria, para organizar meus gastos por tipo | Alta |
| US02b | Como Dona Marta, quero filtrar a lista por categoria, para ver só os gastos de um tipo específico | Alta |
| US03 | Como Dona Marta, quero exportar os lançamentos em CSV, para conferir com meu contador no Excel | Média |
| US04 | Como Dona Marta, quero um alerta visual quando ultrapassar meu orçamento mensal, para não estourar o limite sem perceber | Alta |
| RF05 | (backlog futuro, fora desta sprint) Personalizar cor do tema | Baixa |
Duas sprints de uma “semana” cada (na prática, o tempo que você levar para implementar cada bloco):
Copie esta tabela para acompanhar seu progresso — mova cada linha de coluna conforme avança (na prática, edite o Markdown ou mantenha um arquivo SPRINT_BOARD.md no seu repositório):
| User Story | To Do | Doing | Done |
|---|---|---|---|
| US02a — Categorizar | |||
| US02b — Filtrar por categoria | |||
| US03 — Exportar CSV | |||
| US04 — Alerta de orçamento |
💡 Por que Markdown e não um Trello de verdade? Um quadro real (Trello, Jira, GitHub Projects) é essencial em equipe, mas para um projeto autoguiado individual, manter o board dentro do próprio repositório (versionado junto com o código) evita depender de conta externa e mantém tudo no mesmo lugar. Em um time real, use a ferramenta que a empresa usar — o conceito (colunas de status, uma US por vez em “Doing”) é o mesmo.
—`
Em uma equipe real, ninguém commita direto na main. Cada User Story vira uma branch, passa por um Pull Request revisado por outra pessoa, e só então é mesclada. Como você está sozinho neste projeto, vai simular os dois papéis: quem abre a PR (autor) e quem revisa (revisor) — na prática, você mesmo vai preencher o checklist de revisão antes de mesclar.
main — sempre estável, é o que está (ou vai) em produção.feature/US0Xx-nome-curto — uma branch por User Story.# Sempre parta de uma main atualizada
git checkout main
git pull origin main
# Crie a branch da US que vai trabalhar
git checkout -b feature/US02a-categoria
git checkout -b feature/US0Xx-nome).feat:, fix:, docs: etc., descrevendo o porquê, não só o o quê:
git add .
git commit -m "feat: adiciona campo categoria ao lancamento (US02a)"
git push -u origin feature/US02a-categoria
git checkout main
git pull origin main
git branch -d feature/US02a-categoria
Repita esse ciclo para cada User Story da sprint.
Use este modelo na descrição de cada PR que você abrir (cole no corpo do PR no GitHub):
## O que mudou
<!-- Resumo de 1-2 frases da mudança -->`
## User Story
US0Xx — <descrição da US>`
## Como testar
1.
2.`
## Checklist
- [ ] Código compila sem warnings novos
- [ ] `./mvnw test` passa (adicionei teste novo se introduzi regra de negócio)
- [ ] Testei manualmente o fluxo feliz
- [ ] Testei um caso de erro/borda
- [ ] Não deixei código comentado/de debug
- [ ] Se tomei uma decisão técnica relevante, registrei um ADR
Antes de aprovar (mesmo revisando seu próprio código), pergunte-se:
—`
1. Nova categoria no modelo — crie src/main/java/br/com/controledegastos/model/Categoria.java:
package br.com.controledegastos.model;
public enum Categoria {
FORNECEDORES,
CONTAS_FIXAS,
SALARIOS,
OUTROS
}
2. Adicione o campo em Lancamento.java:
@Enumerated(EnumType.STRING)
@NotNull(message = "A categoria é obrigatória")
private Categoria categoria;
// Getter e Setter
public Categoria getCategoria() { return categoria; }
public void setCategoria(Categoria categoria) { this.categoria = categoria; }
Nota: como
spring.jpa.hibernate.ddl-auto=update, a nova coluna é criada automaticamente no H2/PostgreSQL na próxima subida da aplicação — não precisa de migração manual neste projeto didático (em um projeto real com Flyway/Liquibase, isso viraria um script de migração versionado).
3. Filtro no repositório — em LancamentoRepository.java:
Page<Lancamento> findByCategoria(Categoria categoria, Pageable pageable);
4. Controller (LancamentoController.java) — adicione o parâmetro opcional de categoria:
private void carregarDados(Model model, int page, Categoria filtroCategoria) {
Pageable pageable = PageRequest.of(page, 5, Sort.by("data").descending());
Page<Lancamento> lancamentosPage = filtroCategoria != null
? lancamentoRepository.findByCategoria(filtroCategoria, pageable)
: lancamentoRepository.findAll(pageable);
// ... resto do método igual, mais:
model.addAttribute("filtroCategoria", filtroCategoria);
model.addAttribute("categorias", Categoria.values());
}
Atualize a assinatura de index() para receber @RequestParam(required = false) Categoria categoria e repassar para carregarDados(model, page, categoria). Não esqueça de propagar o mesmo parâmetro nos outros métodos que chamam carregarDados (addLancamento, deleteLancamento, updateLancamento), senão o filtro “reseta” sozinho depois de qualquer ação.
5. Formulário e filtro na view (index.html) — adicione um <select> de categoria no formulário de novo lançamento (igual ao <select> de tipo que já existe) e um filtro no topo da lista:
<form hx-get="/" hx-target="body">
<select name="categoria">
<option value="">Todas as categorias</option>
<option th:each="cat : ${categorias}" th:value="${cat}" th:text="${cat}" th:selected="${cat == filtroCategoria}"></option>
</select>
<button type="submit">Filtrar</button>
</form>
Novo endpoint que gera um arquivo CSV para download, atendendo RF03 + RNF02 (formato universal, compatível com Excel):
Repare que a lógica de gerar CSV é regra de negócio pura (não depende de HTTP nem de banco) — se ela ficar dentro do @Controller, fica difícil testar sem subir o contexto Spring inteiro. Um dev pleno faria funcionar; um dev sênior já extrai isso para uma classe testável antes de terminar a US (ver seção “Refatorando para Testabilidade” logo abaixo). O controller fica assim, já chamando o service:
@Autowired
private RelatorioService relatorioService;
@GetMapping("/lancamentos/exportar")
public ResponseEntity<String> exportarCsv() {
String csv = relatorioService.gerarCsv(lancamentoRepository.findAll());
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=lancamentos.csv")
.header("Content-Type", "text/csv; charset=UTF-8")
.body(csv);
}
💡 Por que
;e não,como separador? O Excel em configuração de idioma Português (Brasil) usa,como separador decimal (19,90), então interpreta,como separador de coluna e;como delimitador de campo — usar,faria valores decimais quebrarem em duas colunas.
Adicione um botão <a href="/lancamentos/exportar">📥 Exportar CSV</a> na tela principal.
1. Limite configurável — em application.properties:
app.orcamento-mensal=2000.00
2. Cálculo do gasto do mês atual e comparação com o limite — também vai para o RelatorioService (ver próxima seção), chamado a partir do carregarDados:
@Value("${app.orcamento-mensal}")
private BigDecimal orcamentoMensal;
// dentro de carregarDados(...):
BigDecimal gastoMesAtual = relatorioService.calcularGastoDoMes(lancamentoRepository.findAll(), YearMonth.now());
model.addAttribute("gastoMesAtual", gastoMesAtual);
model.addAttribute("orcamentoEstourado", relatorioService.orcamentoEstourado(gastoMesAtual, orcamentoMensal));
3. Alerta visual no index.html:
<div th:if="${orcamentoEstourado}" class="alerta-orcamento"> ⚠️ Atenção! Você já gastou <span th:text="${gastoMesAtual}"></span> este mês,
acima do limite de <span th:text="${orcamentoMensal}"></span>.
</div>
Adicione uma classe .alerta-orcamento no CSS (fundo vermelho claro, texto vermelho escuro) para o alerta chamar atenção de verdade.
—`
Antes de fechar US03/US04, extraia a lógica de CSV e de cálculo de orçamento do controller para uma classe própria — RelatorioService. Isso é Single Responsibility na prática: o controller cuida de HTTP (rotas, parâmetros, status code), o service cuida de regra de negócio (matemática, formatação). Como bônus direto, o service vira testável sem precisar subir o Spring inteiro.
package br.com.controledegastos.service;
import br.com.controledegastos.model.Lancamento;
import br.com.controledegastos.model.TipoLancamento;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.YearMonth;
import java.util.Comparator;
import java.util.List;
@Service
public class RelatorioService {
public String gerarCsv(List<Lancamento> lancamentos) {
List<Lancamento> ordenados = lancamentos.stream()
.sorted(Comparator.comparing(Lancamento::getData))
.toList();
StringBuilder csv = new StringBuilder("Data;Descricao;Categoria;Tipo;Valor\n");
for (Lancamento l : ordenados) {
csv.append(l.getData()).append(";")
.append(l.getDescricao()).append(";")
.append(l.getCategoria()).append(";")
.append(l.getTipo()).append(";")
.append(l.getValor()).append("\n");
}
return csv.toString();
}
public BigDecimal calcularGastoDoMes(List<Lancamento> lancamentos, YearMonth mes) {
return lancamentos.stream()
.filter(l -> l.getTipo() == TipoLancamento.DESPESA)
.filter(l -> YearMonth.from(l.getData()).equals(mes))
.map(Lancamento::getValor)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
public boolean orcamentoEstourado(BigDecimal gastoDoMes, BigDecimal limite) {
return gastoDoMes.compareTo(limite) > 0;
}
}
💡 Como saber o que extrair? Regra prática: se um método não usa nada de
HttpServletRequest/Model/anotações do Spring MVC e só transforma dados de entrada em dados de saída, ele não deveria estar no controller.calcularGastoDoMesegerarCsvnão sabem (nem precisam saber) que existe uma URL/lancamentos/exportar— isso é o sinal de que pertencem a outra camada.
—`
Um ADR (Architecture Decision Record) é um registro curto de uma decisão técnica: o contexto, o que foi decidido, e as consequências (inclusive as negativas). Sênior não é quem nunca aceita dívida técnica — é quem documenta a dívida em vez de escondê-la. Guarde estes registros em docs/adr/ no seu repositório (0001-categoria-como-enum.md, etc.):
ADR 001 — Categoria como Enum fixo, não uma entidade própria
enum fixo no código.enum fixo com 4 valores (FORNECEDORES, CONTAS_FIXAS, SALARIOS, OUTROS).ADR 002 — Squash merge como estratégia de integração
main fica limpo — um commit por User Story, fácil de reverter uma feature inteira com git revert. ❌ Perde-se o histórico granular de commits intermediários (ex.: “corrige typo”, “WIP”) — aceitável, pois esses commits não têm valor histórico por si só.ADR 003 — ddl-auto=update mantido como dívida técnica aceita
categoria precisa existir no banco. Em produção real, isso pede uma ferramenta de migração versionada (Flyway ou Liquibase).spring.jpa.hibernate.ddl-auto=update (já usado desde o v1), sem introduzir Flyway/Liquibase nesta sprint.ddl-auto=update é prática recomendada em produção — é um atalho didático consciente.—`
Com a lógica extraída para RelatorioService, testá-la é rápido e não depende de banco de dados nem de contexto Spring — são testes de unidade puros. Crie src/test/java/br/com/controledegastos/service/RelatorioServiceTest.java:
package br.com.controledegastos.service;
import br.com.controledegastos.model.Categoria;
import br.com.controledegastos.model.Lancamento;
import br.com.controledegastos.model.TipoLancamento;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class RelatorioServiceTest {
private final RelatorioService service = new RelatorioService();
private Lancamento criarLancamento(TipoLancamento tipo, BigDecimal valor, LocalDate data) {
Lancamento l = new Lancamento();
l.setDescricao("Teste");
l.setTipo(tipo);
l.setValor(valor);
l.setData(data);
l.setCategoria(Categoria.OUTROS);
return l;
}
@Test
void calcularGastoDoMes_deveSomarSoDespesasDoMesInformado() {
List<Lancamento> lancamentos = List.of(
criarLancamento(TipoLancamento.DESPESA, new BigDecimal("100.00"), LocalDate.of(2026, 3, 5)),
criarLancamento(TipoLancamento.DESPESA, new BigDecimal("50.00"), LocalDate.of(2026, 3, 20)),
criarLancamento(TipoLancamento.RECEITA, new BigDecimal("999.00"), LocalDate.of(2026, 3, 10)), // não deve contar
criarLancamento(TipoLancamento.DESPESA, new BigDecimal("30.00"), LocalDate.of(2026, 2, 15)) // mês diferente
);
BigDecimal gasto = service.calcularGastoDoMes(lancamentos, YearMonth.of(2026, 3));
assertEquals(new BigDecimal("150.00"), gasto);
}
@Test
void orcamentoEstourado_deveRetornarTrueQuandoGastoUltrapassaLimite() {
assertTrue(service.orcamentoEstourado(new BigDecimal("2500.00"), new BigDecimal("2000.00")));
}
@Test
void orcamentoEstourado_deveRetornarFalseQuandoGastoIgualAoLimite() {
// Regra de negócio: só estoura quando ULTRAPASSA, não quando iguala
assertFalse(service.orcamentoEstourado(new BigDecimal("2000.00"), new BigDecimal("2000.00")));
}
@Test
void gerarCsv_devePreservarOsDadosDoLancamentoNaLinha() {
Lancamento l = criarLancamento(TipoLancamento.DESPESA, new BigDecimal("42.50"), LocalDate.of(2026, 1, 1));
l.setDescricao("Papel A4");
String csv = service.gerarCsv(List.of(l));
assertTrue(csv.contains("Papel A4"));
assertTrue(csv.contains("42.50"));
assertTrue(csv.startsWith("Data;Descricao;Categoria;Tipo;Valor"));
}
}
💡 Por que testar
orcamentoEstourado(2000.00, 2000.00) == false? Esse é um caso de borda (edge case) — “estourar” o orçamento deveria significar ultrapassar, não igualar. Um teste assim documenta a regra de negócio de forma inequívoca: se alguém “corrigir” o>para>=no futuro achando que é a mesma coisa, o teste quebra e avisa que essa não era a intenção original. É clean code funcionando como documentação viva.
Rode ./mvnw test antes de cada git push — nenhuma PR deveria ser aberta com testes quebrando.
—`
No seu editor/IDE, abra a pasta deste projeto (File > Open Folder) ou navegue via terminal:
cd javaweb_gastos_04_htmx
./mvnw spring-boot:run
./mvnw test
[!TIP] Dica para execução a partir da raiz do repositório: Se você abriu o repositório completo no VS Code, basta navegar até a pasta antes de executar: cd proj_aplicacoes_full_stack/projetos/javaweb_gastos_04_htmx`
Antes de considerar a sprint concluída:
- As 4 User Stories (US02a, US02b, US03, US04) estão em branches próprias, cada uma com PR mesclada via squash.
- O quadro Sprint está com todas as linhas em “Done”.
./mvnw testpassa sem falhas — os testes deRelatorioServiceTestcobrem a regra de negócio nova.- Testou manualmente: categorizar um lançamento, filtrar por categoria, exportar CSV e abrir no Excel/LibreOffice, e forçar o orçamento a estourar para ver o alerta aparecer.
- As 3 decisões técnicas (ADR 001-003) estão registradas em
docs/adr/no repositório.git log --oneline mainmostra o histórico de merges — cada commit conta a história de uma decisão de produto, não só uma mudança de código.
—`
Parabéns! Você completou os quatro projetos da série Spring Boot + HTMX — do CRUD mais simples até um ciclo completo de descoberta de produto e entrega em equipe:
| Versão | Nível simulado | Foco | O que ainda falta (chega no próximo nível) |
|---|---|---|---|
| v1 | Estagiário | Fundamentos guiados: CRUD, H2, Docker, Render + Neon | Padrões de design, testes, decisões de arquitetura |
| v2 | Júnior | Qualidade de código: SOLID, DRY, fragmentos HTMX | Contrato de API, validação de entrada, escala |
| v3 | Pleno | Produto: Paginação, Validation, API REST/Swagger, primeira dose de Agile | Extrair requisitos sozinho, testes automatizados, ADRs |
| v4 | Sênior | Processo: levantamento de requisitos, backlog, sprints, Git Flow (branch/PR/review/merge), testes, ADRs | — |
O que diferencia um desenvolvedor júnior de um pleno raramente é saber mais sintaxe — é saber transformar um pedido confuso em requisitos claros, priorizar o que entregar primeiro, e trabalhar em um fluxo que outras pessoas conseguem revisar e confiar.
—`
# Fluxo de branch por User Story
git checkout main
git pull origin main
git checkout -b feature/US0Xx-nome-curto
# Commit semântico
git add .
git commit -m "feat: descricao da mudanca (US0Xx)"
# Publicar a branch e abrir PR no GitHub
git push -u origin feature/US0Xx-nome-curto
# Após a PR ser mesclada (squash) pela interface do GitHub
git checkout main
git pull origin main
git branch -d feature/US0Xx-nome-curto
# Rodar os testes automatizados antes de cada push
./mvnw test
# Rodar localmente antes de abrir a PR
./mvnw spring-boot:run
# Testar a exportacao CSV
curl -o lancamentos.csv http://localhost:8080/lancamentos/exportar