007 — Persistir o orçamento (fechar e reabrir o app sem perder nada)
O casal registrou os custos da casa (cenário 000), fechou o app, foi dormir. No dia seguinte abriu o app de novo — e quer continuar de onde parou: a Internet Vivo lá, o Aluguel lá, o reajuste que fez ontem preservado, o total mensal igualzinho. A persistência é a infra que segura essa promessa.
A história aqui é do casal: “fecho e abro e tudo continua”. O como (SQLite via drizzle) é decisão técnica (ADR 001) e a mecânica de mapeamento row ↔ aggregate fica na spec colocated em infrastructure/ (ADR 003). Este cenário cuida só do contrato comportamental do repository: salva um Budget, carrega de volta, o aggregate recarregado se comporta exatamente igual ao que foi salvo.
A spec roda contra InMemoryBudgetRepository (adapter fake), não contra SQLite. Isso mantém o tier domain rápido (ms, no watch) e prova que o contrato do BudgetRepository não depende do driver (ADR 002). O SqliteBudgetRepository real é validado em spec colocated separada, contra :memory:.
- BudgetRepository = port que diz “eu sei
save(budget)eload(id)elist()”. Mora emapplication/porque é uma necessidade infra-facing — não regra de negócio. - InMemoryBudgetRepository = adapter de teste (test fake legítimo, não mock). Mora em
infrastructure/junto com o SQLite, porque ambos são adapters do mesmo port. - SqliteBudgetRepository = adapter de produção. Mora em
infrastructure/. Coberto por spec colocated (ADR 003).
Scenario: Salvar e recarregar o orçamento preserva tudo
Section titled “Scenario: Salvar e recarregar o orçamento preserva tudo”- Given um orçamento da casa em BRL com Internet Vivo (R$ 120, internet, dia 10), Aluguel (R$ 2.500, moradia, dia 5) e Energia CPFL (R$ 200, energia, dia 15) registrados
- And um
InMemoryBudgetRepository - When a gente chama
repo.save(orcamento) - And depois
repo.load(orcamento.id)(simulando fechar e reabrir o app) - Then o orçamento recarregado tem as três contas, na mesma ordem
- And
totalMonthly()continua R$ 2.820 - And
search("vivo")acha a Internet Vivo - And
byCategory("energia")retorna a Energia CPFL - And
dueOn(5)retorna o Aluguel - And os
ids das três expenses são idênticos aos originais
Scenario: Reajuste persistido sobrevive ao reload
Section titled “Scenario: Reajuste persistido sobrevive ao reload”- Given o orçamento da casa salvo no repository com Internet Vivo em R$ 120
- When a gente reajusta a Internet Vivo pra R$ 150 via
orcamento.adjustAmount(internet.id, ...) - And chama
repo.save(orcamento)de novo - And depois
repo.load(orcamento.id)em outro momento - Then a Internet Vivo recarregada tem
valor= R$ 150 - And o
idda Internet Vivo continua o mesmo de antes do reajuste - And
totalMonthly()é R$ 2.850
Scenario: Múltiplos orçamentos persistidos lado a lado
Section titled “Scenario: Múltiplos orçamentos persistidos lado a lado”- Given um repository vazio
- When a gente cria e salva o orçamento “Casa” em BRL (com Aluguel R$ 2.500)
- And cria e salva o orçamento “Viagem” em EUR (com Hotel EUR 300)
- Then
repo.list()retorna os dois orçamentos - And
repo.load(casa.id)retorna a “Casa” comtotalMonthly()= R$ 2.500 - And
repo.load(viagem.id)retorna a “Viagem” comtotalMonthly()= EUR 300 - And carregar um não vaza expenses do outro
Modelo
Section titled “Modelo”- Port —
BudgetRepositoryemsrc/contexts/budget/application/. Interface enxuta:Sync ou async fica a critério do adapter (better-sqlite3 é sync; futuros adapters HTTP seriam async). A spec aguarda o retorno (interface BudgetRepository {save(budget: Budget): Promise<void> | void;load(id: string): Promise<Budget | undefined> | (Budget | undefined);list(): Promise<Budget[]> | Budget[];}await) de qualquer forma — funciona pros dois. - Identidade do
Budget— precisa teridestável praload(id)funcionar. Hoje o aggregate não expõeid; cenário 007 força a promoção (extension natural, análoga aoRecurringExpense.iddo cenário 000).Budget.create({...})gera UUID por default;Budget.create({ id, ... })aceita id externo pra reconstrução pelo adapter. - Adapter test fake —
InMemoryBudgetRepositoryguarda os aggregates numMap<id, Budget>. Importante: precisa retornar cópias (re-hidratar viaBudget.create+register) ou os instances guardados — implementação fica a critério do adapter, desde que o contrato comportamental passe. - Adapter de produção —
SqliteBudgetRepositorymapeiaBudget→ tabelabudgetse cadaRecurringExpense→ tabelaexpenses(FK prabudget_id). Mecânica vive emsrc/contexts/budget/infrastructure/SqliteBudgetRepository.spec.ts(sem doc Gherkin, ADR 003).
Decisões de design
Section titled “Decisões de design”- Port em
application/, não emdomain/. Análogo à decisão doExpenseReconcilerque ficou emdomain/services/por ser regra pura (Budget + Invoice → resultado, sem I/O).BudgetRepositoryé o oposto: infra-facing, existe pra abstrair persistência. Mora emapplication/pra deixar a fronteira óbvia — domain não importa nada de application. - Test fake é adapter legítimo, não mock ad-hoc.
InMemoryBudgetRepositorymora eminfrastructure/junto com o SQLite porque os dois implementam o mesmo port. Mock inline na spec seria mais barato hoje, mas o fake vira reuso quando outros cenários (planning, household-com-budget) precisarem de um repo na mão. - Contrato testado contra fake; driver testado contra real. Domain spec (este) prova que o contrato (round-trip preserva comportamento) não depende do driver. Adapter spec colocada prova que o SQLite implementa o contrato. Sem duplicar assertion de comportamento — cada tier valida o que é seu (ADR 002).
- Sem assertion de formato de armazenamento aqui. Nada de “currency virou TEXT”, “amount virou INTEGER em centavos”. Isso é mecânica do adapter SQLite — vive na spec colocada. Aqui só o que o casal observa: “fechei, abri, continua igual”.
- Budget ganha
idpor extension natural, não por flag de persistência. Promoção é simétrica aoRecurringExpense.iddo cenário 000 — quando aparece a primeira necessidade de identificar a partir de fora (load por id), o aggregate ganha identidade estável. Sem flag de “modo persistido”.
Impacto em outros cenários
Section titled “Impacto em outros cenários”- Cenário 000 —
Budget.create({ nome, currency })continua funcionando. Oidé gerado automaticamente; specs antigas não precisam de touchup. A overloadBudget.create({ id, nome, currency })é nova mas opcional. - Cenários 003/004 — actuals por período (
recordSpend,addToActual) precisam round-trip. Coberto pela spec colocada do SqliteBudgetRepository, não aqui. O contrato genérico do port só promete preservação de estado — a granularidade fica no adapter.
Fora de escopo
Section titled “Fora de escopo”- Migrations / schema versioning. Schema é criado on-demand (
drizzle migrateou DDL inline na bring-up do adapter). - Transactions cross-aggregate (ex: salvar
Budget+Accountatomicamente). Cada repo é independente. - Soft-delete, audit trail, histórico de versões do aggregate.
- Concorrência / locking — local-first, single-user.
- Persistir
Household,Goal,Account— cada um tem seu próprio cenário de persistência quando aparecer. - Cache em camada de aplicação — adapter é fonte da verdade.
- Performance / index — vem quando aparecer hot path real.
Próximo passo
Section titled “Próximo passo”Implementar BudgetRepository port + InMemoryBudgetRepository até este spec passar. Em paralelo (ou em seguida), implementar SqliteBudgetRepository + schema.ts (drizzle) até a spec colocada em src/contexts/budget/infrastructure/ passar contra :memory:.