Skip to content

010 — Persistir o resto (Goal, Household, Account/Invoice sobrevivem ao reload)

O cenário 007 cobriu Budget. Agora o resto precisa do mesmo tratamento: a meta de Amsterdam, a renda do household, as faturas importadas — tudo tem que sobreviver quando o casal fecha e reabre o app.

A história é a mesma do 007, replicada pra três aggregates: salvo, fecho o app, abro de novo, tudo continua. O como (SQLite via drizzle, schemas próprios por context) é decisão técnica (ADR 001) e a mecânica de mapeamento row ↔ aggregate vive em specs colocadas em cada infrastructure/ (ADR 003). Este cenário cuida do contrato comportamental dos três repositories — testado contra fakes InMemory no tier domain (ADR 002).

O pattern é simétrico ao 007:

  • <Aggregate>Repository = port em <ctx>/application/. Interface enxuta: save(aggregate), load(id), list().
  • InMemory<Aggregate>Repository = adapter test fake em <ctx>/infrastructure/. Map por id.
  • Sqlite<Aggregate>Repository = adapter de produção em <ctx>/infrastructure/. Spec colocada cobre mapeamento real contra :memory:.
  • <Aggregate>.id = promovido a campo estável (UUID default, overload externo pra reconstrução pelo adapter). Análogo ao Budget.id do 007 — extension natural, sem flag de “modo persistido”.

Member já tem id estável desde o 005, então Household e Goal herdam identidade dos members reusando as mesmas instâncias (mesma referência) ou via reconstrução por id na hidratação do adapter — fica a critério da implementação, desde que equals continue passando após reload.

Scenario: Salvar e recarregar a meta de Amsterdam preserva contribuições e ritmo

Section titled “Scenario: Salvar e recarregar a meta de Amsterdam preserva contribuições e ritmo”
  • Given a meta “Amsterdam Setembro/2026” criada em 01/06/2026 com target €5.000, deadline 01/09/2026, members Gabriel e esposa
  • And Gabriel aportou R$ 2.000 em 15/06, esposa aportou R$ 3.000 em 10/07 e Gabriel aportou €500 em 30/07
  • And um InMemoryGoalRepository
  • When a gente chama repo.save(meta)
  • And depois repo.load(meta.id) (simulando fechar e reabrir o app)
  • Then a meta recarregada tem o mesmo nome, target, startedOn, deadline
  • And savedByCurrency() mostra BRL R$ 5.000 + EUR €500
  • And savedBy(gabriel) mostra BRL R$ 2.000 + EUR €500 (Gabriel reusado por instância/id)
  • And savedBy(esposa) mostra BRL R$ 3.000
  • And savedInTarget() continua €500 e remaining() €4.500
  • And requiredMonthly(01/08/2026) continua €4.500 (consistente com 002)
  • And pace(01/08/2026) continua €250/mês e onTrack(01/08/2026) continua false

Scenario: Salvar e recarregar o household preserva renda e members

Section titled “Scenario: Salvar e recarregar o household preserva renda e members”
  • Given o household “Casa” em BRL com Gabriel (R$ 8.000) e esposa (R$ 6.000) atribuídos
  • And um InMemoryHouseholdRepository
  • When a gente chama repo.save(casa)
  • And depois repo.load(casa.id)
  • Then o household recarregado tem nome “Casa” e currency BRL
  • And members().length é 2 (Gabriel e esposa, ids estáveis preservados via shared Member)
  • And monthlyIncome() continua R$ 14.000
  • And incomeBy(gabriel) é R$ 8.000 e incomeBy(esposa) é R$ 6.000
  • And depois de assignIncome(gabriel, R$ 9.000) + novo save + load, monthlyIncome() vira R$ 15.000 e o reload não duplica linha de income

Scenario: Salvar e recarregar o cartão Nubank preserva faturas e status paid

Section titled “Scenario: Salvar e recarregar o cartão Nubank preserva faturas e status paid”
  • Given o cartão Nubank (BRL, fechamento dia 25, vencimento dia 10) com a fatura de maio/2026 (3 transações somando R$ 4.500) importada
  • And a fatura de junho/2026 (2 transações somando R$ 1.000) também importada
  • And a fatura de maio marcada como paga em 10/06/2026
  • And um InMemoryAccountRepository
  • When a gente chama repo.save(cartao)
  • And depois repo.load(cartao.id)
  • Then o cartão recarregado tem o mesmo nome, cycle, currency
  • And invoices() retorna duas faturas, ordenadas (maio antes de junho)
  • And cada fatura preserva period, closedAt, dueDate, transactions() (com description e amount) e total()
  • And a fatura de maio continua isPaid() === true e isOverdue(11/06/2026) continua false
  • And a fatura de junho continua isPaid() === false

Três ports espelhando o BudgetRepository do 007:

src/contexts/goals/application/GoalRepository.ts
interface GoalRepository {
save(goal: Goal): Promise<void> | void;
load(id: string): Promise<Goal | undefined> | Goal | undefined;
list(): Promise<Goal[]> | Goal[];
}
// src/contexts/household/application/HouseholdRepository.ts
interface HouseholdRepository {
save(household: Household): Promise<void> | void;
load(id: string): Promise<Household | undefined> | Household | undefined;
list(): Promise<Household[]> | Household[];
}
// src/contexts/accounts/application/AccountRepository.ts
interface AccountRepository {
save(account: Account): Promise<void> | void;
load(id: string): Promise<Account | undefined> | Account | undefined;
list(): Promise<Account[]> | Account[];
}
  • Identidade dos aggregatesGoal, Household e Account ganham id estável (UUID default via crypto.randomUUID(), overload externo pra reconstrução). Análogo ao Budget.id do 007.
  • Member compartilhado entre contextsMember.id (estável desde 005) é a chave que Goal e Household persistem; a hidratação reconstrói Member com o mesmo id ou aceita uma lookup table externa. Como o spec aqui é tier domain (fake), basta reusar a instância — a mecânica real cabe no adapter SQLite.
  • Adapter test fakeMap<id, Aggregate>. Implementação espelha InMemoryBudgetRepository (cenário 007).
  • Adapter de produção — schema próprio por context (goals/contributions, households/incomes, accounts/invoices/transactions). Mecânica em specs colocadas (ADR 003).
  • Port em application/, fake e sqlite em infrastructure/. Mesmo lugar do 007. Domain não importa de application nem infrastructure.
  • Aggregates ganham id por extension natural. Goal.create({...}), Household.create({...}), Account.openCreditCard({...}) continuam funcionando — id vem por UUID default, specs antigos (001, 002, 005) não precisam de touchup. Overload { id, ... } é nova, opcional, usada só pelo adapter na reconstrução.
  • Contrato testado contra fake; driver testado contra real. Mesma divisão do 007. Tier domain prova que o contrato independe do driver. Tier infra (specs colocadas) prova que o SQLite implementa o contrato.
  • Sem persistência cross-aggregate atômica. Cada repo é independente — não tem UnitOfWork ou transaction wrapper. Se aparecer caso (“salvar Goal e Household juntos atomicamente”), entra cenário separado.
  • Member reusado por referência no spec domain. Mesma gotcha do 002/005: o spec cria const gabriel = Member.create({...}) uma vez e reusa a instância no addMember, assignIncome, contribute({paidBy: gabriel}). equals por id continua valendo após reload desde que o adapter rehidrate com o mesmo id (fake guarda a instância; SQLite reconstrói).
  • Cenários 001, 002, 005Account.openCreditCard({...}), Goal.create({...}), Household.create({...}) continuam funcionando. O id é gerado automaticamente; specs antigos não precisam de touchup. As overloads { id, ... } são novas e opcionais.
  • Cenário 003 (Goal.contribute com data variada) — round-trip de contribuições com diferentes moedas e datas é exercitado neste cenário (Amsterdam) e também na spec colocada do SqliteGoalRepository.
  • Cenário 001 (Invoice) — round-trip de markPaid é exercitado aqui (fatura de maio paga sobrevive); detalhamento de transações fica na spec colocada do SqliteAccountRepository.
  • Migrations / schema versioning.
  • Transactions cross-aggregate atômicas.
  • Soft-delete, audit trail, histórico de versões.
  • Concorrência / locking — local-first, single-user.
  • Cache em camada de aplicação.
  • Performance / index.
  • Repository pra RecurringExpense standalone (parte do Budget, não tem identidade fora dele) — coberto pelo 007.
  • Repository pra Contribution, Invoice, Transaction standalone (parte do aggregate respectivo).
  • Repository pra Member standalone — Member mora em shared-kernel/; persistência hoje é via aggregate dono (Household, Goal). Quando aparecer “diretório de members” entra cenário próprio.

Implementar três ports + três fakes + três adapters SQLite (+ schemas drizzle) até este spec passar (tier domain) e as três specs colocadas em src/contexts/{goals,household,accounts}/infrastructure/ passarem contra :memory: (tier infra).