Pular para conteúdo

0022 — Persistência por Micronaut Data: o critério lote × CRUD

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15 · Decidido em: 2026-09-11

Contexto

O int-pied nasceu com a persistência inteira em SQL cru: repositórios @Singleton abrindo dataSource.getConnection() e montando o PreparedStatement à mão — 58 chamadas em 16 classes. A decisão 0012 trouxe o micronaut-data-jdbc à força (o outbox do xadm-mensageria é @JdbcRepository) e registrou o app como "raw-SQL por design": o Data entrou para o @Transactional funcionar, e o SQL continuou à mão.

A norma da casa fechou essa porta depois (engenharia/java-micronaut §Banco): micronaut-data-jdbc é obrigatório para server que persiste, e SQL cru via DataSource.getConnection() como camada de persistência é anti-padrão. O que segue legítimo à mão é o trabalho de lote num @Singleton — o veto é à persistência inteira em SQL cru.

Decisão

Toda persistência é Micronaut Data; SQL à mão só onde é trabalho de conexão de verdade, numa allowlist guardada por teste. O critério por repositório:

Tipo de acesso Forma Onde
CRUD puro @JdbcRepository sobre CrudRepository, entidade @MappedEntity, métodos derivados + @Query pontual DestinatarioAlertaRepository, WatchdogEstadoRepository
Comando, upsert, projeção @Query nativo com o SQL de antes, numa interface @JdbcRepository aninhada (Sql) PiedPedidoRepository, CursorRepository, NormalizacaoEstadoRepository, PiedClienteRepository, PiedProdutoRepository, PiedWebhookRepository, PiedRestRepository, PainelRepository, DadosRepository, CapturaRepository
Poda por idade @Query nativo, um DELETE por tabela RetencaoRepository
Lote / sessão (manual) DataSource.getConnection() NormalizadorService (varredura do raw), PiedClusterLock (advisory lock)
  • A classe do repositório fica; o SQL vai para a interface aninhada. Nos repositórios de comando e projeção, a classe @Singleton mantém a API de domínio (os boolean de "fez a transição?", os defaults de "sem linha", o envelope JSON do webhook, o pretty-print da captura), a transação REQUIRED por método (@Transactional na classe, como antes) e o construtor que os fakes dos testes unitários usam (super(null)). O Sql é o @JdbcRepository. Repositório sem nada disso a preservar é a própria interface (RetencaoRepository, e os dois de CRUD).
  • Muda o transporte, não a semântica. O texto SQL é o mesmo: ? virou :nome e ?::jsonb virou CAST(:x AS jsonb). No pied_pedido isso inclui o upsert com os quatro CASE de transição e a guarda de recência, e as transições guardadas por status no WHERE. Nunca save()/update() de entidade no pied_pedido: o UPDATE gerado reescreveria as colunas da máquina de entrega e as guardas deixariam de existir.
  • A chave e os carimbos seguem com o banco. Insert por @Query onde o save() exigiria gerar o uuid no app; uuidv7(), criado_em/recebido_em/buscado_em e atualizado_em = now() como antes. O jsonb continua gravado como texto convertido pelo banco — o mesmo dado.
  • Sai o código sem chamador: IntegracaoEstadoRepository, PiedPedidoRepository.enfileirarElegiveis (o corte go-forward perdeu o papel na revisão do gate de 2026-08-07) e PiedClienteRepository.inscEstDe (lido só por teste; a leitura foi para a BancoFixture, decisão 0013). A tabela pied_integracao_estado fica: só o /dados/estado a lê.

Correção (2026-09-15): a varredura do raw do NormalizadorService sai da allowlist — o motivo "lote / sessão" não corresponde ao código. O PreparedStatement da varredura não chama setFetchSize, e sem ele o PgJDBC traz o ResultSet inteiro para a memória antes da primeira linha: não há streaming a preservar, é um SELECT comum aberto à mão. O texto SQL segue o mesmo, agora @Query nativo no RawCapturadoRepository devolvendo a projeção @Introspected RawLinha (ts, fonte, tipo, payload), com o recorte incremental num parâmetro nomeado único (CAST(:desde AS timestamptz), o mesmo valor testado contra NULL e comparado nas duas fontes). A allowlist fica só com o PiedClusterLock, cujo advisory lock de sessão precisa mesmo da conexão. O consumo de memória não muda de ordem: o recorte pelo cursor (V11) é que mantém a leitura pequena.

A trava

FronteirasTest.persistenciaSoPorMicronautData: nenhuma classe fora da allowlist chama DataSource.getConnection() (casa por nome e por dono atribuível a DataSource, então pega também a chamada por um subtipo). Provada com mutante: uma classe nova em dados chamando getConnection() deixa o teste vermelho, apontando a linha; sem ela, verde. Pôr uma classe na allowlist é decisão, com o motivo no comentário ao lado — não atalho.

O que o Micronaut Data 5.0.4 cobrou

Achados da migração, todos verificados no build:

  • Todo @JdbcRepository precisa de entidade-raiz, mesmo só com @Query: sem GenericRepository<E, ID> o processor falha com Persistent entity is required. Nos repositórios de SQL nativo a raiz é um record Raiz(@Id …) que mapeia só a chave.
  • O mapeador de projeção lê a propriedade homônima da raiz antes da do record. Por isso o DadosRepository e o CapturaRepository, cujas linhas têm id de tipos diversos (uuid como texto, int do singleton), usam uma raiz chaveada por entidade, não por id.
  • Record de projeção precisa de @Introspected e de @Nullable onde a coluna pode vir nula — sem a anotação o mapeador recusa o null ao construir o record. O mapeamento passou a ser por nome de coluna (antes era por posição): onde o rótulo diferia do componente, entrou um alias (documento_cliente AS documento).
  • @Query exige constante de compilação, e três leituras montavam o SQL em runtime. Viraram variantes finitas: no painel, um método por bucket com o predicado literal — e o StatusBucketTest trava cada literal contra o StatusBucket.sqlFiltro(), para bucket novo não divergir em silêncio —; na timeline da captura, uma por fonte; na retenção, um DELETE por tabela.
  • Parâmetro nomeado é só [a-zA-Z0-9] (sem _), e o :: do Postgres saiu do texto (CAST(… AS …)) para o SQL não depender do parser de parâmetros.
  • Falha de banco chega como DataAccessException, não mais como o IllegalStateException que os repositórios embrulhavam. Os chamadores capturam RuntimeException/Exception, então o comportamento não muda.
  • Native: o Micronaut Data gera introspecção e consultas em compilação, sem reflexão — nenhum hint a acrescentar.

Alternativas descartadas

  • Repositório como classe abstrata @JdbcRepository (API pública concreta, @Query abstrato). Funciona, mas os fakes dos testes unitários (PushEnviadorTest, PollServiceTest) herdam os repositórios e teriam de implementar cada @Query abstrato — cerca de vinte só no PiedPedidoRepository, e mais um a cada consulta nova.
  • A interface @JdbcRepository como o próprio repositório, com a lógica em métodos default. O mesmo problema dos fakes.
  • Entidade completa + save()/update(). Reescreveria o SQL que carrega regra de negócio (FSM guardada no WHERE, COALESCE/GREATEST, now() do banco): mudaria a semântica, não só o transporte.

Consequência

A decisão 0012 deixa de ser "raw-SQL por design" (ver a emenda de 2026-09-11 nela). A suíte de testes atravessou as cinco fatias da migração sem mudança, salvo a leitura do state_inscription (foi para a BancoFixture) e a API da retenção.