insert/update explícitosA semântica de escrita dos repositórios estava inconsistente entre agregados:
onConflictDoUpdate (user, profile);revoke/markUsed) (refresh-token);.returning() (oauth-account, oauth-state);password-credential).O ponto comum problemático era o save genérico (muitas vezes implementado
como upsert): um único método que servia tanto pra criar quanto pra atualizar.
Isso esconde a intenção no ponto de chamada e mistura dois significados de
negócio bem diferentes — criar algo novo vs. mutar algo que já existe têm regras,
erros e efeitos distintos.
O bug do User.save (CAI-50, em que adicionar/remover role não atualizava) foi
sintoma direto disso: o caller "salvava" sem que o tipo ou o nome deixassem claro
se aquilo era criação ou atualização, e o upsert por baixo mascarou o
comportamento errado.
Vale para toda escrita de repositório (a camada de persistência por trás dos
ports da aplicação). Não trata de onde o id/timestamps são gerados (ver D1 /
CAI-62) nem de centralizar a escrita via mapper (ver R3 / CAI-66) — essas são
decisões irmãs e complementares.
1. Métodos explícitos, nomeados por intenção. Sem save genérico, sem upsert
como padrão.
insert(entity) — criação. Falha se o registro já existe.update(entity) — mutação de registro existente. Falha (...NotFoundError)
se o registro não existe.Transições de estado específicas continuam como métodos nomeados quando o
domínio assim pede (ex.: refresh-token: revoke, revokeAllForUser,
markUsed; oauth-state: findAndDelete — consumo de uso único).
2. O contrato é dirigido pela demanda — cada repositório declara só os métodos
que seu uso real exige. Não há um CRUD especulativo. Na prática, "insert e
update" é a exceção (agregados com mutação real: user, profile,
streamer), não a regra — a maioria é insert + alguma operação nomeada de
leitura/remoção/transição (oauth-account, oauth-state, refresh-token,
password-credential).
3. Sem superclasse base genérica compartilhada. "Interface base/abstrata"
cumpre-se como convenção (todo repo usa insert/update nomeados, nunca
save), e não como uma classe-mãe única com insert+update obrigatórios. Uma
base assim reintroduziria o CRUD especulativo que esta decisão combate — 4 dos 7
repositórios não têm update, e forçá-lo seria exatamente o smell de
Speculative Generality.
4. Upsert só pontual e nomeado por intenção. Quando um upsert for legítimo —
tipicamente sincronização de fonte externa idempotente (ex.: webhook do
Pagar.me) — ele entra como um método explícito que revela o motivo (ex.:
upsertFromExternalSync), nunca como o save default. Hoje não existe nenhum
caso desses no código.
5. Test doubles fiéis ao contrato. Os repositórios in-memory espelham a
semântica do adapter real: insert adiciona (não sobrescreve silenciosamente);
update lança ...NotFoundError quando o registro não existe. Um fake que faz
upsert nos dois esconde justamente a classe de bug do CAI-50 e é proibido.
insert vs update) — some a
ambiguidade que originou o CAI-50.update do
agregado inteiro (não há update por-campo). Se concorrência vier a ser um
problema, a ferramenta é optimistic locking (coluna version) no agregado, não
métodos por campo — revisitar nesse caso.insert (ex.: password-credential) vão ganhar
update/transições quando o uso exigir (ex.: troca/revogação de senha) — e isso
é esperado, não uma lacuna desta decisão (YAGNI: difere implementação, não
previsão).upsertFromExternalSync (ou
nome equivalente) no repo afetado, mantendo insert/update separados.Supersedes este.save/upsert ambíguo) que motivou a decisão.id e
remoção de timestamps de auditoria do domínio. Decisões irmãs desta.