Pular para conteúdo

Java / Micronaut — servidor / API

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-16

O arquétipo servidor / API da casa. Compartilha o build/checkstyle/logging/tipos do Java; aqui estão a arquitetura, os erros/testes/banco e o que é específico de Micronaut. Piso de segurança: Segurança — o piso.

Arquitetura (v0.1 — destilada dos pilotos)

Destilado em 2026-07-05 do que os apps Micronaut da casa realmente praticam (auth, bi-transporte-xls; bi-comercial-xls é a geração anterior). Consenso virou norma; o que diverge está declarado.

Package-by-feature com camadas DENTRO da feature (norma): login/, processamento/, powersync/… — controller, service, repository e entidade convivem no pacote da feature; o transversal vive em comum/{config,exception,util,…}. A trava é ArchUnit no gate (sem ciclos entre features; comum não depende de feature; parsing puro não depende de controller) — ver guarda de fronteiras abaixo. Package-by-layer em inglês (api/, service/, repository/) é a geração anterior — repo novo não nasce assim; migração é oportunista.

DI — constructor injection puro (norma): @Singleton + dependências no construtor; zero @Inject em campo (os três repos: nenhuma ocorrência). Dependência opcional = @Nullable no construtor embrulhado em Optional; variação por estratégia = classes irmãs (…Coolify/…None, …Impl/…Stub), não flag booleana dentro de uma classe.

Controller fino, lógica no service (norma): o controller injeta 1–2 services e delega; OpenAPI/validação/HTTP moram nele, negócio não. (O contraexemplo existe na casa — um controller de 615 linhas com rate-limit, CORS manual e DTOs inline — e é dívida declarada, não norma.)

Tipos (norma): DTO HTTP = record @Serdeable com sufixo *Request/*Response/ *View; entidade = classe @MappedEntity com @MappedProperty snake_case explícito; view-model separado da entidade (a tela não serializa a entidade do banco).

Config (norma): bloco coeso → @ConfigurationProperties (uma classe …Settings com javadoc mapeando cada env); valor avulso → @Value("${x.y:default}") no construtor. O application.yml carrega o porquê em comentário (remoções e ajustes com data e história — o yml é doc de operação, não só config).

Nomes: domínio em pt-BR + sufixo técnico em inglês (ProcessamentoService, ArquivoXlsStorage); javadoc em pt-BR citando as decisões/specs que motivaram o código ("decisão 0017", "spec 003") — o código aponta pra doc, fechando o ciclo §1.9. As camadas de biblioteca compartilhada seguem o mesmo idioma: base de pacote br.com.xadm e sufixo pt-BR por capacidade (br.com.xadm.comum.*, br.com.xadm.seguranca.*) — não biz.xadm nem camada em inglês (decisão 0019).

Guarda de fronteiras de arquitetura

A arquitetura que o livro do app narra (camadas, dependências entre features) tem guarda no gate (./gradlew check): um teste de fronteiras de dependência que falha quando alguém cruza uma fronteira que o livro declara. Não amarrar a uma ferramenta ou biblioteca — é a ideia (as fronteiras de pacote são respeitadas; o gate acusa quem as cruza), não uma dependência. Onde um teste de fronteiras leve não couber, o piso é convenção + review; a guarda mecânica é o reforço.

A ordem fecha o ciclo intenção↔garantia:

  1. o livro (projeto/index.md, cap. de fluxos) declara as fronteiras pretendidas;
  2. a guarda enforça essas fronteiras;
  3. o livro linka a regra guardada.

Exemplos de fronteiras de alto sinal (guardar as que o livro de fato afirma, não uma arquitetura ideal importada):

  • api não depende de repository — conversa com service, não com persistência direta;
  • service/processing não dependem de api;
  • domain não depende de api/service/repository;
  • pacotes top-level (features) sem ciclos; comum não depende de feature;
  • config/infra não vaza para o domínio.

Poucas regras, alto sinal — não exaustividade. Exceção local = comentário no teste ou decisão, não silêncio. (No piloto Micronaut, 3 fronteiras estreitas pegaram 2 dependências erradas numa migração que convenção + review deixaram passar — a prova de que vale a guarda.)

Limite conhecido — a guarda é BYTECODE. Uma dependência cross-package que exista via referência a uma constante public static final (String/int/…) é invisível à guarda: o compilador inlina o valor da constante no bytecode do chamador, então não sobra referência à classe de origem (o import no fonte existe, mas a regra "comum não depende de feature" passa mesmo assim). Acoplamento real (tipo em campo/parâmetro, injeção, chamada de método) é pego normalmente — só a constante inlinada escapa. Não é violação a caçar; é saber que constantes compartilhadas não são guardadas ao desenhar as regras (se a fronteira importa ali, guarde por outro meio — ex. mover a constante ou um teste dedicado).

Erros e logging

Exceções: as de fluxo HTTP estendem HttpStatusException — o conjunto da casa é BadRequest/Conflict/Forbidden/NotFound/StorageIndisponivelException; exceção de domínio específica quando ela carrega semântica própria (ResetarPowersyncAlreadyInProgressException). Não lance Exception/RuntimeException crus na borda.

Chamada externa num fluxo com estado — catch largo o bastante pra o estado SEMPRE resolver. Quando uma operação marca um registro "em voo" (ENVIANDO, PROCESSANDO…) antes de chamar um serviço externo, o catch que reage à falha da chamada tem de pegar também RuntimeException inesperada — não só a exceção de domínio prevista. Uma falha fora do roteiro (config vazia, URI malformada, NPE) que escapa de um catch estreito deixa o registro preso no estado intermediário, sem transição de saída, e ainda vira 500 na borda em vez do erro de domínio. Regra dupla: (1) valide a montagem da chamada (URI vinda de config, corpo, headers) convertendo o erro em exceção de domínio tratável antes que ele escape; (2) todo catch em torno de chamada externa num fluxo com máquina de estados garante a transição pra um estado terminal (falha/retry) em qualquer exceção. Caso concreto: cliente HTTP + config vazia.

Corpo de erro — RFC 7807, num ponto único (norma, decisão 0017 do piloto): um ErrorResponseProcessor custom com @Replaces(HateoasErrorResponseProcessor.class) formata tudo (HttpStatusException, Bean Validation, 404, parse error) como application/problem+json — ele só formata o corpo, não decide status. Fluxo que exige resposta especial (ex. view server-side → 302 pro login) ganha um ExceptionHandler dedicado. Corpo ad-hoc (Map.of("error", …)) montado no controller é a geração anterior — não copie.

Discriminador de máquina no corpo — uniforme para a classe toda. Se o problem+json carrega um code (ou outro campo) que um consumidor NOSSO usa para ramificar (ex. a Central lê o code do broker), esse discriminador tem de ser estável e uniforme para toda a falha da mesma classe. A armadilha: para o mesmo "campo obrigatório faltando", o Bean Validation na borda (@Valid @NotBlank) e uma checagem no service produzem codes diferentes — o da borda sai derivado do status (BAD_REQUEST genérico), o do service sai com o code próprio da exceção de domínio → o consumidor vê metade das faltas com um code e metade com outro. Conserte o contrato, não o placement: mantenha o @Valid na borda (é norma — Validação de input) e faça o ErrorResponseProcessor de ponto único carimbar um code de máquina estável também nas falhas de Bean Validation (com a lista de campos), para o consumidor nunca precisar distinguir borda×service. Refina o lado provedor do contrato de entrada travado dos dois lados: não basta rejeitar faltante com 400 — o 400 consumido por máquina precisa de code uniforme.

Níveis de log — conscientes do SentryAppender (todo ERROR vai ao GlitchTip):

  • ERROR = operação falhou de verdade, sempre com a exceção anexada — e só para falha que você engoliu de propósito: exceção não-tratada já estoura e o appender captura sozinho (um LOG.error displicente é ruído direto no GlitchTip);
  • WARN = degradação/recusa esperada (token inválido, config incompleta → no-op, cache stale usado) — sem stacktrace de pânico;
  • INFO = marco de ciclo de vida / auditoria leve (acesso administrativo com path+método).

logback.xml: pattern de console comum da casa; SentryAppender com o DSN no bloco <options> do appender (${SENTRY_DSN:-}, vazio = no-op — ver Observabilidade); tuning por logger comentado (ex. com.zaxxer.hikari WARN, io.micronaut.management WARN, silenciar logger barulhento pontual) — cada linha com o porquê.

Request-id/correlação (MDC) — norma decidida, implementação na lib. Rastrear um request entre os logs de um multi-hop (integrador ↔ sascar ↔ plataforma) é norma a partir da decisão 0020: um header de correlação (request-id) atravessa o envelope M2M em cada hop e popula o MDC do log de cada app. O filtro que gera e propaga o header vive no xadm-comum-web (decisão 0019, Fase 2) — um @ServerFilter que lê o header de entrada (ou cunha um na borda), põe no MDC e re-emite na chamada de saída. Ainda não implementado em nenhum app (nasce com a lib); é norma, não pendência aberta — quando a lib existir, o app a consome, não reinventa o filtro.

Testes e fixtures (Micronaut)

  • Fixture golden-master, datada, com receita de captura: entrada real + resultado esperado em src/test/resources/fixtures/<dominio>-<AAAAMMDD>/, e o comando que gerou o esperado documentado no javadoc do teste (quem re-captura não adivinha). Teste de conformidade parametrizado varre o diretório. Anonimização é parte da captura: dado real de cliente em src/test/resources é o mesmo risco do episódio docs/anexos — fixture nova nasce anonimizada (troque nomes/valores sensíveis preservando o shape) ou com justificativa explícita no javadoc; "repo privado" não é anonimização.
  • API externa = WireMock com shape capturado da instância real (constituição §6 — a fixture inventada quebrou produção 2×): o stub documenta o comportamento descoberto empiricamente (ex. "o GlitchTip deriva o slug do nome e ignora o enviado"). Provider puro testa com WireMock sem subir o Micronaut. Costura quando o controller chama um serviço com HTTP externo: o HttpClient injetado por DI (base-url vazia/relativa em teste) aponta pro servidor embutido, não pra porta dinâmica do WireMock — subir o @MicronautTest inteiro não faz o client alcançar o stub. Teste o serviço com um client construído à mão na porta do WireMock (`HttpClient.create(new URL("http://localhost:"
  • wireMock.port()))`) e o controller só nos ramos sem rede (validação, erro, roteamento).
  • Testcontainers — Postgres pinado à versão do deploy (postgres:18-alpine = a do Coolify): a norma é o container único compartilhado entre as classes (singleton + TestPropertyProvider injetando a URL; limpeza de tabelas em @BeforeEach) — um container por classe é lento. A URL mágica jdbc:tc:postgresql:… por classe é aceita em suíte pequena. Flyway real roda no container (as migrations são parte do testado). Encanamento do runner: Forgejo e CI.
  • Teste de endpoint semeia no @BeforeEach/@Sql, nunca no corpo do @Test + HTTP. @MicronautTest default (transactional=true) embrulha cada @Test numa transação com rollback; o servidor embutido atende a requisição em OUTRA conexão e não enxerga o que o corpo do teste ainda não commitou. O default TransactionMode.SEPARATE_TRANSACTIONS commita cada @BeforeEach em transação própria — por isso seed no @BeforeEach (ou @Sql) fica visível ao controller, seed no corpo do @Test some (a contagem volta 0, o teste atesta nada). Duas saídas: semeie no @BeforeEach/@Sql (committed), ou teste no nível do repositório (sem HTTP — mesma conexão do teste). Não cure com transactional=false por reflexo: além de re-habilitar escritas persistentes indesejadas, quebra o JDBC cru do @BeforeEach que depende da conexão de transação ambiente (NoConnectionException). Ponto cego declarado: nenhum gate pega isso — "write de repo no corpo antes da chamada HTTP" é heurístico e falso-positivo fácil; a suíte fica verde com o teste medindo vazio. Quem pega é a leitura do teste (ou o comportamento errado em produção).
  • View controller = teste @Client que faz GET e renderiza de verdade. O ./gradlew check compila e roda os testes, mas não renderiza template — toda a família de armadilhas de render server-side (objeto implícito de web ausente, record/OGNL, link concatenado, th:onclick String, Map.of no modelo, view sem @Secured = 401, fragment homônimo) passa verde e só aparece no browser. O único gate que fecha a família é um teste @MicronautTest com @Client que faz GET na rota, espera 200 e assere um pedaço do HTML renderizado (assertTrue(body.contains("<table"))) — o render roda de verdade, com JVM, e qualquer erro de template estoura ali. É também o lugar onde um defeito de render do kit aparece (o central garante shape, não render — §UI): um app que adota o kit e tem esse teste é a primeira rede a pegar. Barato: um @Test por view pública, mais os ramos de erro que renderizam.
  • Unit × integração por environment do Micronaut: @MicronautTest(environments = "unit") = H2 MODE=PostgreSQL com Flyway desligado (SQL das migrations é Postgres); "test" = Testcontainers + Flyway; integração se chama *IntegrationTest. Alvo: ~metade da suíte pura e rápida, sem contexto Micronaut. O application-test.yml neutraliza envs de auth do shell/CI e usa port: -1 (evita colisão entre projetos).
  • Porta efêmera no que sobe servidor — E2E e run de debug. micronaut.server.port: -1 (ou ${random.port}) faz o servidor pegar porta livre; o run normal fica em 8080 fixo, então debug local que precisa não colidir roda com MICRONAUT_SERVER_PORT=-1 e lê a URL do log de startup (Server Running: http://localhost:<porta>). No E2E com @MicronautTest, a porta vem injetada — pegue a URL do EmbeddedServer (server.getURI()/getPort()) ou @Client("/") (resolve pro embutido sozinho); nunca cole localhost:8080 no teste (colide com processo vivo/zumbi e vira flaky). O porquê geral em agentes § Loop de verificação.
  • Cobertura (JaCoCo) — toolVersion amarrada ao class-major do Java. A política (visível não bloqueante; piso opt-in por app) está em ci-testes §Definição de pronto. Pegadinha crítica: JaCoCo <0.8.15 não instrumenta Java 25 (class major 69) — cospe "Unsupported class file major version 69" e o relatório fica válido na aparência mas vazio: falso 0% silencioso (o arqui-inimigo do §6 — e o gate nem roda o report). Amarre a jacoco.toolVersion ao Java homologado: Java 25 → ≥ 0.8.15. (O baseline da fábrica homologa a versão de Java — decisão 0003; vale um PR no ci-images amarrando a versão do JaCoCo à do Java.)
  • A task que lê o XML do JaCoCo é configuration-cache-safe: declare o Provider do arquivo como val local da configuração da task (não no topo do script — serializa referência ao objeto-script), use .get().asFile no doLast, sem fun aninhada e sem capturar layout/project no doLast (viram referência ao script e quebram o CC). Ao somar o total, pegue o ÚLTIMO <counter> de cada tipo no XML (o total do relatório vem depois dos por-pacote), não o primeiro.

Banco (Postgres compartilhado)

  • Owner-writes (um dono por tabela). No Postgres compartilhado, cada tabela tem um dono — quem roda o Flyway e escreve; os demais leem via PowerSync. Sem "mão única" num serviço central: o ponto de integração é o banco, não um dispatcher. É a base do §Flyway abaixo (histórico por app) e a norma da plataforma (constituição §8 · Plataforma de Integração). A face-cliente (write-back via uploadData, nunca escrita direta) vive em PowerSync.
  • Espelho fiel (nível de coluna). Na tabela-espelho (réplica/estado-desejado), a identidade (chave, join) vive em coluna nativa do X-Adm, conferida contra a spec de entidades do ERP; campo de origem externa entra prefixado (<origem>_, ex. pied_x_ped), só rastreabilidade — nunca chave nem join. Norma e tabela em Plataforma de Integração §Fronteiras (decisão 0009).
  • Chave-de-mudança single-writer via @DateUpdated (Micronaut Data). App dono único da tabela (sem PowerSync, apply, admin CRUD externo escrevendo): declare updated_at TIMESTAMPTZ NOT NULL DEFAULT now() e anote o campo da entidade com @DateUpdated — o Micronaut Data carimba a cada write e rejeita valor externo de graça (o setter é ignorado no UPDATE que o ORM emite). Serve de chave-versão para consumidor externo ler "o que mudou" por ORDER BY updated_at (delta-sweep); NOT NULL DEFAULT now() garante ordenação limpa desde o INSERT. Contraste com o bullet abaixo: aqui o app é o único caminho de escrita, então quem carimba é o ORM e mapear na entidade é norma; assim que aparece um segundo escritor (apply, admin, PowerSync) a garantia quebra e a cura passa a ser o trigger — entidade não mapeia.
  • Chave-de-mudança de espelho multi-writer vive no banco (trigger), não no app. Espelho com vários escritores (apply, admin CRUD, PowerSync) que um consumidor externo lê por "o que mudou": mantenha a coluna-versão (updated_at) por trigger Postgres (BEFORE INSERT OR UPDATE, bump quando coluna relevante IS DISTINCT FROM) — cobre todos os escritores num lugar, imune a quem esquecer, mais robusto que bump em Java (que cada caminho de escrita teria de lembrar). Coluna trigger-managed → não mapear na entidade: o Micronaut Data faz UPDATE só das colunas mapeadas, então mapeá-la sem carregá-la a cada update zera o valor (null-wipe) que o trigger acabou de gravar.
  • Micronaut Data JDBC (sem Hibernate): @JdbcRepository(dialect = POSTGRES) extends CrudRepository para CRUD pontual; caminho de bulk é JDBC à mão num @Singleton (COPY + TEMP TABLE, upsert diff-aware com IS DISTINCT FROM) — o javadoc do repository declara a divisão.
  • Read-model de tabela de terceiro tolera o null que o banco pode ter. Uma projeção read-only (record @MappedEntity de uma tabela que outro app/ERP escreve) marca @Nullable todo campo que a fonte pode deixar null. "Obrigatório no contrato" ≠ "nunca-null no banco": sem o @Nullable, o Micronaut Data estoura ao construir o record ("... non-null constructor argument ...") e derruba o job inteiro — um único registro anômalo (1 produto sem preço) mata o sweep de 500. Trate a ausência como anomalia (Sentry/ WARN e pula a linha), não como falha fatal do lote — mesma disciplina do processador XLS que valida por linha sem reprovar o lote (Segurança §Validação).
  • Batch upsert ordena pela chave natural — sempre (anti-deadlock). O diff-aware (ON CONFLICT DO UPDATE … WHERE … IS DISTINCT FROM …) passa a impressão de que linha inalterada é "no-op". Não é: o ON CONFLICT DO UPDATE tira row lock na arbitragem do conflito, ANTES de avaliar o WHERE — a linha trava mesmo quando nada muda. Com processamento concorrente e dimensões sobrepostas entre lotes (o caso normal), duas transações travando as mesmas linhas em ordens diferentes deadlockam (40P01 deadlock detected) e o processamento inteiro cai em erro — questão de tempo, não de azar (aconteceu em produção: dois arquivos do mesmo lote, ~250 linhas de dimensão na ordem de aparição da planilha, 4 workers). Cura: ordenar as linhas pela chave natural do upsert antes de executar — ordem de lock determinística entre transações concorrentes elimina o deadlock na mesma tabela. Nas duas implementações da casa: PreparedStatement.executeBatchsorted(ORDEM_CHAVE_NATURAL) no repository antes do map (comparator com nullsFirst quando a chave tem coluna nullable); COPY + TEMP TABLEORDER BY <chave natural> no INSERT … SELECT … FROM temp (o Sort alimenta o upsert em ordem). A ordenação imuniza a mesma tabela; upsert em várias tabelas na mesma transação ainda exige ordem consistente entre tabelas. Transação de mescla com MAIS de uma fase de escrita (ex. upsert + DELETE seletivo) exige defesa adicional: o DELETE trava linhas em ordem de scan, que ORDER BY não controla (DELETE nem aceita ORDER BY) — serialize a fase de banco com pg_advisory_xact_lock(<chave fixa do app>) na abertura da transação; o lock solta no COMMIT e só a gravação enfileira (a fase cara de parse continua paralela). Teste proporcional: o deadlock em si não é testável deterministicamente — o que se testa (e se declara no javadoc do componente) é a pré-condição estrutural: unit test que injeta um capturador no lugar do executor de batch e afirma a ordem das rows (chave simples e chave composta com null), ou que afirma o ORDER BY no SQL gerado.
  • Flyway: VN__descricao_em_snake_pt_br.sql, congeladas por checksum. Migração destrutiva é aceita com cabeçalho de justificativa (por que a tabela morreu) e idempotência (IF EXISTS). Tabela de histórico nomeada por app (flyway_history_<app> / bi_<dominio>_flyway_schema_history) — obrigatório no Postgres compartilhado, senão dois apps disputam a flyway_schema_history. App secundário (schema já povoado por outro app) exige baseline: com a história por app, o segundo app sobe com a sua tabela de histórico ainda inexistente, mas o schema não está vazio (as tabelas do app dono já estão lá) → o Flyway aborta ("Found non-empty schema(s) without schema history table"). Cura: flyway.baseline-on-migrate: true + baseline-version: 0. Os dois juntos: baseline-on-migrate deixa o Flyway marcar o schema existente como linha-base em vez de abortar; baseline-version: 0 é o que faz a própria V1 do app rodar — o default (1) marca tudo ≤ 1 como já-aplicado e pula a V1 do app secundário silenciosamente. Só no app secundário; o dono do schema não precisa (sobe do zero).
  • Pool (HikariCP) deliberadamente pequeno: maximum-pool-size 5–10, minimum-idle 2. O Postgres é compartilhado entre os apps — pool grande num app esgota as conexões dos vizinhos; subir o teto é decisão de operação (medida), não default.
  • Segredo: ${ENV_VAR:default-dev-inócuo} no application.yml; produção = env do Coolify. Detalhe e tri-estado de config em Segurança.

Espelho X-Adm — registry de handler por tabela (anti-god-node)

O espelho X-Adm (a réplica das tabelas do ERP, aplicada a partir do push) modela-se por registry de handler por tabelaA forma da casa, não uma opção entre outras.

  • Uma classe por tabela, um driver genérico. Cada tabela do espelho é um XadmTableHandler<E> que sabe parse / find / merge / order da sua entidade; um driver genérico percorre o registry e aplica o payload, sem conhecer as tabelas uma a uma. O describeXxx() repetido por tabela vira um describe() único via BeanIntrospection. O ganho concreto: adicionar uma tabela do espelho passa a exigir 1 classe nova (o handler), sem editar um payload central, o parser ou os loops de apply.
  • É o oposto do god node. O integrador-server hoje carimba a tríade à mão por tabela — um ParsedXadmPayload de ~170 arestas (god node) que cresce a cada tabela nova, o mesmo trabalho 21×. O registry dissolve isso: o crescimento é aditivo (mais um handler no registry), não editar o núcleo.
  • Refactor puro, na Fase 4. A migração da tríade para o registry é sub-lineage própria no repo do integrador (citando raiz/001): os testes de apply/ingestão passam sem mudança de comportamento (é refactor), mais um teste novo provando "tabela nova = 1 handler". O padrão é norma aqui; a execução é da fase. Amarra-se ao mecanismo de lib (0019) e ao contrato (0020): o registry é o que fica no app dono do espelho; o que é infra transversal sobe para xadm-commons.

Não confundir com owner-writes (§Banco): o registry é como o dono do espelho aplica o push nas suas tabelas — não abre um segundo dono, não vira dispatcher central. Um dono por tabela continua (Plataforma de Integração).

Específico de Micronaut

  • Auth no micronaut-security nativo (AuthenticationFetcher + SecurityRule + ExceptionHandler), não um @Filter paralelo; API por Bearer — piso completo em Segurança.
  • Callback/poke de um app a outro mora sob /api/<origem>/<recurso> (norma). Quando um app chama outro para avisar (o poke "algo mudou, vá puxar" e afins), a rota do lado do chamado leva o app chamador no prefixo — <origem> = quem chama; o caso comum é a Plataforma de Integração/api/integrador/produto. (Chamada de API normal segue /api/<recurso>; o prefixo por origem é para o callback.) Todo o /api/** é Bearer (@Secured("ROLE_API")), então o prefixo dá auth uniforme de graça e um contrato de rota previsível: um recurso novo não renegocia auth nem caminho. É também o caso em que o intercept-url-map (Bearer) é a ferramenta certa — o contraste é a view server-render, que nunca deve ser liberada por intercept-url-map greedy (§UI · piso).
  • HTTP app↔app dentro de Micronaut = @Client declarativo + @Retryable, não OkHttp. O transversal da casa diz HTTP=OkHttp (stack) — e isso vale para os arquétipos plain-Java (CLI/worker sem contexto Micronaut). Dentro de um app Micronaut, a chamada a outro app usa o @Client declarativo — gerado do OpenAPI do receptor no lado consumidor (openapi-generator), que é o dono do contrato (decisão 0020) — com @Retryable para a resiliência de rede. O cliente HTTP já vem no runtime, integra DI/filtros/observabilidade (o filtro de request-id, §Erros) e não some no fat jar; OkHttp num app Micronaut reinventa o que o framework entrega. (maxsul migra o cliente OkHttp herdado — stack.)
  • Config de cliente HTTP outbound: prop por destino + env <APP>_URL/<APP>_API_TOKEN, com fallback de migração. Para chamar outro app, a base-URL e o Bearer vêm de env nomeada pelo destino — a convenção de nome está no piso (Segurança §Segredos). Mapeie a URL numa prop por destino e injete-a no @Client@Client("${webstorm.url}") casando webstorm.url: ${WEBSTORM_URL:} (e o token idem) —, o que de quebra evita o bean eager do serviço nomeado (armadilha @Client config-gated). Renomear env sem quebrar produção NÃO usa cascata no yaml — o Micronaut não aninha placeholder (armadilha); o ${…:-${…}} do SENTRY_DSN é do logback, motor OUTRO. Dois caminhos válidos: (a) rename atômico — provisione o nome novo em todos os deploys, flipe o binding (webstorm.token: ${WEBSTORM_API_TOKEN:}), remova o antigo; natural quando o /xadm-setup/broker emite a env (muda num lugar só), custa uma janela curta com os dois nomes no ar. (b) resolve-in-code (zero-downtime, sem coordenar deploy) — duas props single-level (webstorm.token: ${WEBSTORM_API_TOKEN:} e webstorm.token-legado: ${API_BEARER_TOKEN:}) e o bean pega a primeira não-vazia; remova a legada quando todo deploy tiver o nome novo.
  • @Serdeable é allowlist de segurança — todo DTO HTTP precisa.
  • ./gradlew run como rodar-em-dev (sobe a infra mínima; MICRONAUT_ENVIRONMENTS=dev).
  • O OpenAPI é gerado, logo é um oráculo — da rota. Com micronaut-openapi no annotationProcessor, o spec sai dos @Controller/@Get ao compilar — é a verdade sobre quais rotas o app expõe, de graça. Por isso o ci.yml roda o checa-rotas.py depois do ./gradlew check: rota REST citada na prosa que o spec desmente reprova o PR. Oráculo da rota, não da forma: o schema do spec (required/enum/nullable) é frágil (não captura validação imperativa nem serialização custom), e quem atesta a forma é o teste de fio, não o gate. Detalhe, escapes e pontos cegos em CI, gates e testes.

UI (server-render)

Piso na constituição §5 (UI dos apps): todo app que renderiza HTML no servidor — telas de admin/ops/dev, inclusive internas e atrás do gate de auth do Coolify — usa o layout padrão X-Adm, não reinventa o visual. A casa server-renderiza com Micronaut Views + Thymeleaf; o esqueleto vem do template rastreado, o app só escreve o conteúdo de cada tela.

O kit (template rastreado, sincronizado pela /xadm-docs):

Template central Destino no app
templates/views/layout.html src/main/resources/views/layout.html
templates/public/css/custom-theme.css src/main/resources/public/css/custom-theme.css
templates/public/images/logo.png src/main/resources/public/images/logo.png
templates/public/images/favicon.ico src/main/resources/public/images/favicon.ico

A tabela ilustra a regra, não é uma segunda fonte a manter: a árvore do kit espelha a do app, e o mapeamento é o glob (views/**views/**, public/**public/**). Reproduza o caminho inteiro, sem achatar. O que sustenta o espelho é quem serve o quê: views/ é do Thymeleaf, public/ é do static-resources — por isso o CSS mora em public/css/, e um .css em views/ sobe a tela sem estilo. O checa-views.py cobra (views/ só tem .html), porque foi exatamente esse achatamento que a 0.23.0 publicou.

O kit é opcional no manifesto (app headless não tem view → não é defasagem); adotou views → mantém em sincronia como os demais templates. Não edite por app — identidade muda no central, re-deriva via /xadm-docs.

Dois CSS, dois donos (§1.9). O head linka os dois, nessa ordem:

custom-theme.css app.css
O que é identidade da casa: paleta, header, botões, chip de sessão componentes deste app: timeline, tiles, stepper, badges de domínio
Dono o central o app
Rastreado sim — verbatim, /xadm-docs re-deriva não: templates/app.css é scaffold, copiado uma vez e nunca mais tocado
Você edita não sim, à vontade

Todo app que usa o kit tem public/css/app.css, nem que vazio — o <link> é fixo, e sem o arquivo dá 404 em toda página. Sem CSS de componente? O scaffold (só comentário) basta. O app.css vem por último na cascata, então sobrescrever a identidade "funciona" — e é justamente o que não se faz: identidade divergente por app é o problema que o kit existe para resolver. Falta algo da casa? Traga o caso ao central (§6), não corrija local.

O scaffold mora em templates/app.css, fora de templates/public/, porque o glob public/** copia verbatim: um scaffold lá dentro seria sobrescrito a cada /xadm-docs, apagando o CSS do app. Glob não leva exceção — a origem é que se move (mesma decisão de 0.23.1).

Como a tela usa o layout — puxa os três fragments, escreve só o miolo:

<html lang="pt-BR" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{layout :: head('Requests')}"></head>
<body>
  <header th:replace="~{layout :: navbar}"></header>       <!-- ou navbarComMenu(~{::navMenu}) p/ menu -->
  <main class="container py-4">
    <table class="table table-striped"> ... </table>        <!-- idiomas da casa -->
  </main>
  <script th:replace="~{layout :: scripts}"></script>
</body>
</html>

Dois headers, um por tela: navbar (marca + chip de sessão) ou navbarComMenu(~{::navMenu}) (idem + o <ul> de menu da página). Os nomes são distintos porque o Thymeleaf resolve fragment por nome e não faz overload por aridade — dois th:fragment homônimos quebram o render (500), não escolhem por número de parâmetros.

De headum só: head(title). A 0.21.0 tentou um irmão com slot (headComExtras) para a tela que precisa de markup próprio dentro do <head> — típico <noscript><meta http-equiv="refresh"> de fallback sem JS — e isso quebrou o kit inteiro: um documento HTML tem UM <head>, o parser funde o segundo no primeiro, e o head('Título') passou a arrastar o ${extras} do irmão, com 500 em toda view. Não é colisão de nome nem posição no arquivo: dois <head> no mesmo documento bastam. Removido na 0.23.3; o checa-views.py (regra G) reprova a reincidência.

CSS do app não precisa de slot — é o app.css acima, já linkado. Foi essa a lacuna que a 0.23.3 deixou aberta e a 0.24.2 fechou: dois apps precisavam somar um <link> e, sem slot nem app.css no kit, cada um inventou um contorno diferente — um pôs o <link> no <body> via fragment local, o outro forkou o layout.html e ficou preso numa versão velha do kit (herdou de volta um fragment morto que o central já removera). Fork silencioso é o custo real de lacuna de template: some do radar do /xadm-docs.

Tela que precisa de outro markup no <head> (ex. <noscript><meta http-equiv="refresh">): escreva o <head> à mão nela e traga o caso ao central. Um slot de markup arbitrário só volta a ser desenhado com tela real precisando — na 0.21.0 não havia: 0 views usavam o slot, 126 quebraram por causa dele.

Onde a página declara o markup que passadentro do elemento que faz o th:replace, nunca solto:

<head th:replace="~{layout :: head('Requests')}"></head>
<body>
  <header th:replace="~{layout :: navbarComMenu(~{::navMenu})}">
    <ul th:fragment="navMenu" class="navbar-nav me-auto">
      <li class="nav-item"><a class="nav-link" href="/itens">Itens</a></li>
    </ul>
  </header>

O th:replace substitui o elemento host inteiro: o host e tudo aninhado nele somem do output, e o markup só reaparece onde o layout o insere. Declarar o th:fragment fora do host (solto no <body>) faz ele renderizar duas vezes — uma no header, outra onde foi declarado. É silencioso: não dá 500, só sai errado na tela. O checa-views.py reprova.

Model attrs opcionais que o layout lê: ${appNome} (nome no header), ${clienteXadm} (sufixo do cliente), ${appVersao} (versão discreta à direita), ${appModo} (pill de ambiente à direita), ${authEnabled}/${currentUserEmail}/${currentUserName} (chip de sessão). A paleta é a mesma do xadm.css do portal (royal #064490, accent #2a5b97, prata #939495) — uma fonte, dois consumidores. Use os tokens (var(--xadm-royal), --xadm-accent, --xadm-silver…), não re-digite os hex no app.css: o custom-theme.css é o dono da paleta.

Ambiente e versão no header — ${appModo} / ${appVersao} via ViewModelProcessor, nunca hand-roll. O layout renderiza o pill de ambiente e a versão quando o attr está presente${appModo} com th:if, sem comparar string no template (Thymeleaf standalone é chato com expressão — ver armadilhas). Quem decide se mostra é o ViewModelProcessor, que injeta os dois em toda view sem o controller repetir: seta appModo só quando o ambiente ≠ produção (em prod o pill some) e appVersao sempre. Antes disto cada app reinventava — um forkou o layout.html (e ficou preso numa versão velha do kit), outro pôs no conteúdo/navMenu; o slot no kit + este processor de referência matam o drift. Popula modelo mutável (LinkedHashMap), nunca Map.of — o app já devolve modelo mutável pela regra do record/OGNL (armadilhas); um Map.of no ramo "não encontrado" dá 500 só naquelas rotas quando o processor faz put.

import io.micronaut.context.env.Environment;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.http.HttpRequest;
import io.micronaut.views.ModelAndView;
import io.micronaut.views.ViewModelProcessor;
import jakarta.inject.Singleton;
import java.util.LinkedHashMap;
import java.util.Map;

/** Injeta ambiente + versão no layout X-Adm de toda view. */
@Singleton
public class XadmLayoutModel implements ViewModelProcessor<Map<String, Object>> {

    private final String appVersao;   // ex. "v1.4.5"
    private final String appModo;     // rótulo do ambiente, ou null em produção

    public XadmLayoutModel(Environment env,
                           @io.micronaut.context.annotation.Value("${app.versao:}") String versao) {
        this.appVersao = versao.isBlank() ? null : "v" + versao;
        // Produção não mostra pill; qualquer outro ambiente mostra o nome em caixa-alta.
        boolean prod = env.getActiveNames().contains(Environment.PRODUCTION);
        this.appModo = prod ? null
                : env.getActiveNames().stream()
                     .filter(n -> !Environment.CLOUD.equals(n))   // ajuste os ambientes técnicos do seu app
                     .findFirst().map(String::toUpperCase).orElse(null);
    }

    @Override
    public void process(@NonNull HttpRequest<?> request,
                        @NonNull ModelAndView<Map<String, Object>> mv) {
        Map<String, Object> model = mv.getModel().orElseGet(() -> {
            Map<String, Object> m = new LinkedHashMap<>();  // mutável — nunca Map.of
            mv.setModel(m);
            return m;
        });
        if (appVersao != null) model.putIfAbsent("appVersao", appVersao);
        if (appModo != null)   model.putIfAbsent("appModo", appModo);
    }
}

A app.versao vem da config (ex. SOURCE_COMMIT/tag no build) e a detecção de produção sai do MICRONAUT_ENVIRONMENTS — os dois são app-specific: o kit garante que o header mostra igual, o processor decide o quê. Um app pode não adotar o processor (sem os attrs, o header simplesmente não mostra modo/versão — os th:if caem).

O gate — checa-views.py (no ci.yml, junto do checa-nav.py): o manifesto garante que a cópia do kit é idêntica à do central, não que ela funciona — a 0.19.0 publicou um layout.html com dois th:fragment="navbar", sha íntegro e 500 em toda view. O check é stdlib e offline (sem JVM): reprova fragment homônimo, chamada a fragment que o layout não declara, aridade divergente, chamada posicional contra fragment sem signature, markup passado declarado fora do host, ativo estático em views/ e <html>/<head>/<body> declarado duas vezes no mesmo arquivo (o bug da 0.21.0). Tolerante: sem src/main/resources/views/, sai 0.

O que ele não pega, e vale saber antes de confiar nele: erro semântico de render (expressão inválida, model attr ausente) e markup errado que ainda assim parseia — trocar o <head> do fragment por um <div> satisfaz o check e emite <div> onde devia ir <head>. Só render com JVM pega essas duas, e o central não renderiza (não há projeto Java aqui — decisão do Gustavo na 0.23.3). Quem fecha essa fresta é o CI do app: o primeiro app que adotar o kit e tiver teste de render é o primeiro lugar onde um defeito de render do kit aparece. Até lá, o kit chega ao app com o shape conferido e o render não.

application.yml — servir os estáticos (Micronaut não serve classpath:public por padrão; sem isto o logo.png/custom-theme.css/favicon.ico dão 404):

micronaut:
  router:
    static-resources:
      css:    { enabled: true, mapping: "/css/**",    paths: "classpath:public/css" }
      images: { enabled: true, mapping: "/images/**", paths: "classpath:public/images" }

Dependências: micronaut-views-thymeleaf (build) + o Bootstrap 5 vem por CDN no layout.html (telas internas com rede; app offline vendoriza o Bootstrap e ajusta os <link>/<script>).

Favicon: o favicon.ico do kit é gerado do wordmark canônico como default substituível — a variação quadrada dedicada do logo é design pendente (backlog); quando existir, gera-se um favicon melhor dela.

View anônima em prod — marque @Secured(SecurityRule.IS_ANONYMOUS) na rota; nunca abra por intercept-url-map greedy. Com o micronaut-security ligado, o default é fail-closed: uma rota de view sem decisão explícita (nem @Secured, nem allowlist) cai na regra que nega e responde 401 — o SecuredAnnotationRule devolve UNKNOWN e a SecurityRule de piso rejeita. Tela que roda sem login (landing, health público, callback sem Bearer) precisa de uma liberação explícita, e o idioma padrão é @Secured(SecurityRule.IS_ANONYMOUS) no próprio handler: é rota-exata por construção (a anotação vale só aquele método, não vaza pra irmã), fica colada à rota (não há allowlist paralela pra driftar) e sai ALLOWED no SecuredAnnotationRule (-100), antes da regra de piso.

Quando o conjunto de rotas públicas é programático/dinâmico (não um punhado de telas fixas), use uma isPublicView() na SecurityRule custom casando a rota exata — mesma garantia, allowlist em código. O que nunca serve pra view é um pattern abrangente (/*, /**) no intercept-url-map: é questão de ordem — o ConfigurationInterceptUrlMapRule roda em -100, antes da SecurityRule custom (0), e a primeira regra que devolve ALLOWED/REJECTED vence, então um intercept-url-map greedy com ALLOWED ganha da regra que protegia a irmã e deixa /admin público sem 500 e sem sinal nenhum. Reserve o intercept-url-map para o que é uniforme por prefixo (o /api/** Bearer, infra — §Específico). Piso e checklist do revisor em Segurança.

Deploy — o Dockerfile

App que deploya no Coolify tem Dockerfile + .dockerignore no repo, desde o primeiro commit de código (constituição §3). Não derive do repo irmão: derive dos templates dockerfile-java e dockerignore-java, que são rastreados no manifesto — o /xadm-docs avisa quando defasam, e o xerox manual não. Junto vai o .gitattributes (template gitattributes-java, também rastreado): checkout em Windows com core.autocrlf=true deixa o gradlew com CRLF na árvore de trabalho, o docker build COPYa a árvore como está e o /bin/sh do Alpine não resolve o shebang #!/bin/sh\r — o build morre com ./gradlew: not found. O --chmod=0755 do Dockerfile cura o bit de execução, não o fim de linha; gradlew text eol=lf cura na raiz, em qualquer máquina.

O Coolify builda o Dockerfile do repo (Build Pack: Dockerfile, sem buildpack) e o ci.yml não builda imagem — logo nada exercita este arquivo antes do deploy. É por isso que o /xadm-release roda docker build no pré-flight: sem esse gate, o primeiro a descobrir um Dockerfile ausente ou quebrado é a produção.

O molde é multi-stage: temurin:<v>-jdk-alpine builda o shadowJar, temurin:<v>-jre-jammy roda. Pontos que não são gosto:

  • Build-only. O gate de testes é o ci.yml (./gradlew check com Testcontainers, no runner com dind). O build do Coolify não tem daemon Docker — teste de integração não sobe aqui, e tentar rodá-lo no Dockerfile só produz vermelho confuso.
  • Nome do jar fixo. O COPY --from=builder /app/build/libs/app.jar exige archiveFileName.set("app.jar") no shadowJar; sem isso o artefato sai versionado (app-1.2.3-all.jar) e o COPY quebra a cada bump de versão. Só no shadowJar, nunca no jar base: com o plugin application, dar app.jar aos dois faz o startScripts/build do Gradle 9.5 falhar por overlap de output (§Armadilhas).
  • Runtime Jammy, não Alpine. É o default oficial do plugin Gradle do Micronaut; variantes Alpine do Temurin têm suporte irregular e a economia de ~100 MB não paga o risco com libs JNI.
  • O app não escreve no próprio WORKDIR. O WORKDIR /app cria a pasta como root (o --chown do COPY é do jar, não dela), então depois do USER app qualquer escrita sob /app — log em caminho relativo, cache, upload — falha com AccessDeniedException em runtime, na produção: o build passa, o teste passa, e o docker build do pré-flight também. Tudo que o app criar sob /app precisa existir com dono app antes do USER app (RUN mkdir -p /app/logs && chown app:app /app/logs). Não troque isso por chown app:app /app inteiro: o app passaria a poder sobrescrever o próprio app.jar, que é metade do motivo de rodar não-root. E antes de criar logs/, confira o arquétipo: servidor loga em stdout (java), quem loga em arquivo é CLI/worker — logs/ num servidor costuma ser logback.xml copiado do arquétipo errado, e aí o mkdir cura o sintoma. O par disso no Coolify é o Storages: volume nomeado herda o dono do diretório da imagem (não existindo o diretório, nasce root e quebra igual); bind mount não herda — o dono no host é que precisa ser 1001:1001.
  • HEALTHCHECK + curl — o health check é obrigatório (versionamento); o curl no estágio de runtime existe para ele.
  • -XX:MaxRAMPercentage=75 (o default ~25% desperdiça heap) e -XX:+ExitOnOutOfMemoryError (container morto o Coolify reinicia; travado em GC death-spiral, não).
  • JDK bate com o docs/app.json (toolchain.java) e com a baseline da fábrica — mesma fonte única do ARG FLUTTER_VERSION × .fvmrc no Flutter.
  • Cache mount do Gradle: id por app + sharing=locked. Troque o <slug> do template. O id de um cache mount default para o próprio target, então --mount=type=cache,target=/root/.gradle bare faz todos os apps Java da casa dividirem um único cache no servidor do Coolify; e o sharing default é shared ("can be used concurrently by multiple writers"), que o Gradle não tolera. Dois deploys concorrentes — ou um build anterior morto que deixou lock stale — falham com Timeout waiting to lock journal cache (/root/.gradle/caches/journal-1). It is currently in use by another process. Parece bug do app; é do Dockerfile. É a mesma razão pela qual a doc do Docker põe sharing=locked no exemplo do apt, e a mesma família das armadilhas de estado stale que sobrevive ao processo.

O .dockerignore não é .gitignore. O Docker casa cada padrão contra o caminho relativo à raiz do contexto com o filepath.Match do Go, onde * não cruza / — um build bare exclui só a build/ da raiz, e sub/build/ viaja pro daemon (inócuo em single-module, mina armada no dia que alguém modularizar). Daí o **/build do template. A exceção que não leva **: *.jar é raiz-only de propósito**/*.jar excluiria o gradle/wrapper/gradle-wrapper.jar, o COPY gradle gradle viria sem o wrapper e o gradlew morreria com "Could not find or load main class org.gradle.wrapper.GradleWrapperMain". Os artefatos de build reais já saem pelo **/build. Se o Dockerfile lê docs/app.json, a exceção !docs/app.json vem depois de docs (vence a última linha que casa, não a mais específica) — as duas metades no mesmo PR (Coolify).

Observabilidade — GlitchTip/Sentry

O env do DSN é SENTRY_DSN — em toda stack, apesar de o backend ser GlitchTip. É o nome canônico que o SDK do Sentry lê do ambiente por convenção (Java e Dart), e GlitchTip é Sentry-compatível: com o nome canônico o app não precisa de código só pra traduzir env. Vale igual no Flutter (--dart-define=SENTRY_DSN, ponte app.json) e é o nome que o /xadm-setup injeta no Coolify (exceção glitchtip.dsn → SENTRY_DSN na tabela ENVS_CANONICOS do broker, que fora dela deriva <PROVIDER>_<KEY>). GLITCHTIP_DSN é o nome antigo — se aparecer, é resíduo de antes da padronização.

Rename é silencioso, não barulhento. Trocar o nome no Coolify sem o código já lendo o novo não dá erro: o ${…:-} resolve pra vazio, o appender vira no-op e o app sobe normal — todo erro de produção para de ser reportado sem sinal nenhum. Ordem segura: código primeiro (com fallback ${SENTRY_DSN:-${GLITCHTIP_DSN:-}}), deploy, aí o rename no Coolify, aí remove o fallback. E a injeção do broker é upsert: reprovisionar cria SENTRY_DSN e deixa o GLITCHTIP_DSN órfão no recurso — apagar na mão.

Recipe (log → captura, zero código no negócio): io.sentry:sentry + io.sentry:sentry-logback (pin, ex. 8.42.0). No logback.xml, um SentryAppender com o DSN dentro de <options> (<options><dsn>${SENTRY_DSN:-}</dsn></options>), <minimumEventLevel>ERROR</minimumEventLevel> e appender-ref no <root> → captura todo log ERROR (inclui exceção não-tratada do Micronaut), sem tocar o negócio. DSN vazio → no-op (dev/local não spamma). <dsn> é propriedade do SentryOptions, não do appender — em sentry-logback 8.x o SentryAppender só expõe setOptions(...), então <dsn> flat (fora de <options>) não casa com setter nenhum e o logback ignora em silêncio (só um WARN no status interno; build verde) → DSN nunca setado → no-op invisível, e o erro real some em produção sem ninguém notar. É de propósito que a rota /test/glitchtip abaixo existe: ela pega justo esse buraco. <minimumEventLevel> é setter do appender (fica flat, fora de <options>). Não há módulo micronaut-sentry oficial (o comunitário não confirma compat com MN5) — por isso a via logback. O DSN vem da feature GlitchTip do app.json e vira env no Coolify (server) — ver Central de Apps. Espelha o bloco Sentry do Flutter.

Rota de diagnóstico /test/glitchtip (smoke-test pós-deploy, opt-in): endpoint autenticado (@Secured(SecurityRule.IS_AUTHENTICATED)) que dispara uma exceção de teste — ex. LOG.error("teste GlitchTip <app_id>", new RuntimeException("smoke-test")) (capturada pelo SentryAppender) — e responde 200 com "enviado". Confirma em produção que o DSN chega no GlitchTip sem esperar um erro real. Autenticado (não deixa qualquer um disparar). O /xadm-setup oferece scaffoldar (com o OK do dev).

Armadilhas

  • application.yaml do Micronaut NÃO aninha placeholder — ${A:${B}} sai lixo, sem erro de build. O DefaultPropertyPlaceholderResolver casa o primeiro } (indexOf), não o balanceado: ${NOVO:${ANTIGO:}} vira a expressão NOVO:${ANTIGO: (default literal ${ANTIGO:) e sobra um } órfão → o valor resolve pra lixo (token/URL inválido) e o app sobe quebrado sem o build reclamar. O operador de default é : (um dois-pontos), não :-. Não confunda com o logback.xml: lá o motor é OUTRO — operador :- e aninhamento suportado (${SENTRY_DSN:-${GLITCHTIP_DSN:-}} é válido no appender — é logback, não Micronaut). Copiar o fallback do DSN pro yaml foi o bug de produção que originou esta regra. Para renomear env sem quebrar (o que o aninhado tentava), use rename atômico ou resolve-in-code (§Específico). O CI do app pega reincidência (grep de ${…${ no application.yaml/.properties, só em app Micronaut — Spring aninha certo).
  • @Client config-gated: declarar micronaut.http.services.<id>.url cria bean eager que quebra o startup INTEIRO. Um micronaut.http.services.<id>.url no application.yml instancia um ServiceHttpClientConfiguration eager (@EachProperty); se a URL estiver vazia (o deploy onde esse cliente não existe), o contexto inteiro não sobe — Failed to inject value for parameter [url]. Sintoma enganoso: N @MicronautTest falhando em massa no NettyHttpServer.start/beforeAll (parece bug de teste, é config que não resolve). Para um cliente HTTP que só existe em alguns deploys (ligado por config), não declare o serviço nomeado: ponha @Client("${prop.url}") direto na interface e injete por BeanProvider<Client> (lazy) no consumidor — desligado, o bean nem é criado e a URL vazia nunca é resolvida. Mesmo balde do "cliente HTTP + config vazia" abaixo, mas na fase de startup, não de request.
  • Endpoint que serve <form> HTML precisa de @Consumes explícito. O @Post do Micronaut assume @Consumes(application/json); um <form method="post"> do navegador manda application/x-www-form-urlencoded415 no submit, mesmo que o handler nem leia o corpo. Anote @Consumes({MediaType.APPLICATION_FORM_URLENCODED, MediaType.APPLICATION_JSON}) (ou MediaType.ALL) no método. Armadilha de teste que esconde isso: o HttpClient do Micronaut, num POST(uri, corpo), manda application/json por default — o teste só pega o 415 se enviar .contentType(APPLICATION_FORM_URLENCODED) (igual ao navegador). Vale pra qualquer view server-side (raw HTML ou Thymeleaf).
  • record no modelo do Thymeleaf → Property … not found. Micronaut Views usa o Thymeleaf standalone, cuja linguagem de expressão é o OGNL, que resolve propriedade por getter JavaBean (getFoo()). Um record gera acessor foo(), não getFoo()${obj.foo} estoura no render ("Property 'foo' not found on type …", não é null: é erro de template). Saídas: passe um Map no modelo (o OGNL tem accessor de Map) ou invoque o método${obj.foo()}. Mesma classe do bullet acima: view server-side tem armadilhas que o @Controller "de JSON" não tem.
  • Thymeleaf: th:replace/th:insert têm precedência MAIOR que th:if — no mesmo tag o th:if não segura. O processamento é por precedência (menor roda antes): inclusão de fragmento (th:replace/th:insert, 100) roda antes de th:if/th:unless (300). Então <div th:replace="~{frag}" th:if="${cond}"> sempre inclui o fragmento — quando o th:replace executa, o elemento já foi substituído e o th:if nem é avaliado (rende com a condição falsa → quebra). Cura: ponha o th:if num elemento pai (ou <th:block th:if="…">) que envolva o th:replace. Mesma família do record/OGNL acima: pegadinha de view server-side. Corolário de organização: com duas superfícies de UI distintas (ex. chrome público limpo × navbar de operador), prefira dois arquivos de fragmento de layout separados a um único layout com th:if no menu — além de fugir dessa armadilha de precedência, deixa a fronteira das superfícies explícita no arquivo, não escondida numa condição.
  • Thymeleaf: expressão String em atributo de evento (th:onclick/th:onload/th:on*) é bloqueada. Por segurança (defesa contra injeção de JS via dado de usuário), o Thymeleaf 3.x só aceita nesses atributos expressão que retorna número ou boolean — qualquer String lança TemplateProcessingException ("Only variable expressions returning numbers or booleans are allowed…"), no render, com o build verde. Padrão da casa para linha/card clicável: renderize o dado num data-* (th:attr="data-href=@{/rota/{id}(id=${x})}") e ligue o handler em JS no rodapé — nunca monte th:onclick com a URL. Mesma família das pegadinhas de view server-side acima.
  • Thymeleaf standalone NÃO tem os objetos implícitos de web (param, session, request). Esses são da integração Thymeleaf-Spring (o WebContext do Spring MVC); o Micronaut Views renderiza com Thymeleaf standalone (Context puro, sem contexto web — a mesma razão do OGNL no bullet do record), então ${param.q}/${session.user}/${#request…} não resolvem — saem vazios ou estouram no render, com o build verde. Query string, header, cookie e afins: o controller lê (@QueryValue, HttpRequest) e passa ao modelo; a view só consome model attr. Regra geral: no Thymeleaf da casa, tudo que a tela mostra vem do modelo — não há atalho pra pegar do request no template. Mesma família do record/OGNL: view server-side não é o Thymeleaf do Spring.
  • Link com variável de path é @{/x/{id}(id=${..})}, não concatenação @{'/x/' + ${..}}. A sintaxe de URL do Thymeleaf tem forma própria pra parâmetro: {id} no template do path + (id=${x}) liga o valor, e o Thymeleaf URL-encoda e monta a query certa (@{/busca(q=${termo})} vira ?q=… encodado). Concatenar String dentro do @{…} (@{'/x/' + ${id}}) não encoda, quebra com caractere especial e é frágil por parse — a forma paramétrica é a única correta (o exemplo do data-* acima já a usa). Mesma família das pegadinhas de view server-side acima.
  • ViewModelProcessor + Map.of() do controller = 500 só nas rotas que devolvem imutável. Um ViewModelProcessor injeta dado transversal no modelo de toda view renderizada (usuário/tenant/ versão no layout — padrão layout.html + GlobalViewModel do molde do integrador) fazendo model.put(...). Se o controller devolveu um Map.of(...) (imutável — comum no ramo "não encontrado"), o put estoura UnsupportedOperationExceptionHTTP 500, e só naquelas rotas (as que devolvem Map mutável passam). Regra: view controller sempre devolve modelo mutável (LinkedHashMap), nunca Map.of/Collections.emptyMap. Casa com o bullet do record/OGNL acima (que já empurra passar Map no modelo — só garanta que seja mutável). Guarda barata: um teste que faz GET numa rota de not-found e espera 200, não 500. Vale pra qualquer app que adote o ViewModelProcessor transversal do molde — o de referência que popula ${appModo}/ ${appVersao} no layout está em §UI.
  • Cliente REST de API externa — PUT substitui a coleção. Um PUT num recurso/coleção troca o valor inteiro, não faz append. Para adicionar sem apagar o resto, faça read-modify-write (GET o atual → altere em memória → PUT o conjunto completo). Pega quem provisiona/atualiza config em provider externo (broker da Central de Apps).
  • Cliente HTTP + config vazia = 500 + registro preso. URI montada de config (baseUrl + rota) com base vazia vira URI relativaHttpRequest.newBuilder(uri) lança IllegalArgumentException (a URI tem de ser absoluta com host) — não a exceção de domínio que o catch espera → escapa → 500. Pior: se a máquina de estados já marcou o registro como "em voo" (ENVIANDO) antes da chamada, ele fica preso sem transição de saída. Cura nas duas pontas: valide a URI (converta em exceção de domínio tratável antes do newBuilder) e alargue o catch pra RuntimeException inesperada — a norma chamada externa: catch largo pra o estado sempre resolver.
  • Nomeie o recurso por escopo quando o container é compartilhado. Num provider onde vários apps dividem o mesmo espaço (org/projeto/bucket), nomeie o recurso pelo escopo estável (ex. projeto por app_id) — evita colisão de nome e cruzar dados entre apps.
  • Filtro HTTP: @ServerFilter (honra @Order), nunca o @Filter/HttpServerFilter legado dependendo de @Order. O @Filter legado ignora @Order → a ordem dos filtros vira indefinida no fat jar: a descoberta diverge entre o classpath de teste e o empacotado, então o teste passa e a ordem sai errada em produção. É um caso concreto do piso §6 "gate fiel ao artefato que embarca" — o @ServerFilter respeita @Order de forma determinística nos dois.
  • ERROR Failed to inject … jsonMapper (JsonMessageHandler) no fim de uma run multi-classe @MicronautTest = artefato de teardown, não bug. Ao derrubar o contexto compartilhado entre classes, o Micronaut pode logar uma falha de injeção de um bean interno (o JsonMessageHandler pedindo o jsonMapper de um contexto já desmontado) — é ruído de shutdown, não do teste. Se a suíte passa (verde), não caçar. Mesmo balde do falso-0% do JaCoCo e do Test Resources órfão abaixo: sintoma barulhento de run multi-classe que não é o código.
  • Test Resources órfão por SIGTERM → initializationError em MASSA na run seguinte (dev local). Uma run de ./gradlew test/check interrompida (SIGTERM/timeout/Ctrl-C) mata o serviço do Micronaut Test Resources, mas o estado do Gradle (.micronaut/test-resources) ainda aponta a porta antiga — e a task internalStartTestResourcesService fica UP-TO-DATE (o Gradle acha que o serviço está no ar), então os @MicronautTest tentam conectar na porta morta e falham em massa (TestResourcesException: Test resource service is not available). ./gradlew --stop sozinho NÃO resolve (a task segue UP-TO-DATE); o remédio universal é apagar o estado stale: ./gradlew --stop && rm -rf .micronaut/test-resources .gradle/configuration-cache && ./gradlew check (pkill -f test-resources só onde o sandbox permite — e pode casar o próprio Gradle). Confirmar que é isso, não o código: rodar uma classe que falhou isolada (./gradlew test --tests "…") passa → prova que é o serviço compartilhado. Outro jeito de provocar o mesmo órfão (não só SIGTERM): rodar o gate com --no-daemon e apagar build/ entre execuções — sem daemon o serviço de TR não sobrevive à JVM efêmera e o rm -rf build/ remove os arquivos de descoberta da porta, então a run seguinte reencontra service not available. Prevenção: rodar o gate com daemon (default) e não rm -rf build/ no meio de uma sessão de testes; se travar, o sintoma é service not available, não o código. (A casa prefere o container singleton via TestPropertyProvider, §Testes, que o Ryuk do Testcontainers reapa no fim — o Test Resources não tem esse reaper.)
  • Gradle 9.5 + shadow + application: jar base e shadowJar com o MESMO app.jar = startScripts/build quebra. Com o plugin application, a task startScripts (e distZip/distTar) consome o output do jar; se você nomear o jar base app.jar além do shadowJar (dividir o nome), o Gradle 9.5 vê startScripts usando um output que o shadowJar produz sem dependência declarada e falha: "uses this output of task ':shadowJar' without declaring an explicit or implicit dependency". ./gradlew check (compila + testes + checkstyle) é imune — só a montagem (build/startScripts/assemble) dispara; e o deploy do Coolify usa o shadowJar direto, então não quebra em produção. Morde o dev que roda ./gradlew build local. Cura: app.jar só no shadowJar (o que a §Deploy já prescreve — dê archiveClassifier/nome distinto ao jar base, ou deixe o default app-<versão>.jar), ou tasks.startScripts { dependsOn(tasks.shadowJar) }. Ponto cego declarado: nenhum gate da casa pega — o CI roda check, não build; se aparecer, é nesse dev, com essa mensagem.