Skip to content

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) e load(id) e list()”. Mora em application/ 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 id da 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” com totalMonthly() = R$ 2.500
  • And repo.load(viagem.id) retorna a “Viagem” com totalMonthly() = EUR 300
  • And carregar um não vaza expenses do outro
  • PortBudgetRepository em src/contexts/budget/application/. Interface enxuta:
    interface BudgetRepository {
    save(budget: Budget): Promise<void> | void;
    load(id: string): Promise<Budget | undefined> | (Budget | undefined);
    list(): Promise<Budget[]> | Budget[];
    }
    Sync ou async fica a critério do adapter (better-sqlite3 é sync; futuros adapters HTTP seriam async). A spec aguarda o retorno (await) de qualquer forma — funciona pros dois.
  • Identidade do Budget — precisa ter id estável pra load(id) funcionar. Hoje o aggregate não expõe id; cenário 007 força a promoção (extension natural, análoga ao RecurringExpense.id do cenário 000). Budget.create({...}) gera UUID por default; Budget.create({ id, ... }) aceita id externo pra reconstrução pelo adapter.
  • Adapter test fakeInMemoryBudgetRepository guarda os aggregates num Map<id, Budget>. Importante: precisa retornar cópias (re-hidratar via Budget.create + register) ou os instances guardados — implementação fica a critério do adapter, desde que o contrato comportamental passe.
  • Adapter de produçãoSqliteBudgetRepository mapeia Budget → tabela budgets e cada RecurringExpense → tabela expenses (FK pra budget_id). Mecânica vive em src/contexts/budget/infrastructure/SqliteBudgetRepository.spec.ts (sem doc Gherkin, ADR 003).
  • Port em application/, não em domain/. Análogo à decisão do ExpenseReconciler que ficou em domain/services/ por ser regra pura (Budget + Invoice → resultado, sem I/O). BudgetRepository é o oposto: infra-facing, existe pra abstrair persistência. Mora em application/ pra deixar a fronteira óbvia — domain não importa nada de application.
  • Test fake é adapter legítimo, não mock ad-hoc. InMemoryBudgetRepository mora em infrastructure/ 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 id por extension natural, não por flag de persistência. Promoção é simétrica ao RecurringExpense.id do 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”.
  • Cenário 000Budget.create({ nome, currency }) continua funcionando. O id é gerado automaticamente; specs antigas não precisam de touchup. A overload Budget.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.
  • Migrations / schema versioning. Schema é criado on-demand (drizzle migrate ou DDL inline na bring-up do adapter).
  • Transactions cross-aggregate (ex: salvar Budget + Account atomicamente). 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.

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