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:
- o livro (
projeto/index.md, cap. de fluxos) declara as fronteiras pretendidas; - a guarda enforça essas fronteiras;
- 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):
apinão depende derepository— conversa comservice, não com persistência direta;service/processingnão dependem deapi;domainnão depende deapi/service/repository;- pacotes top-level (features) sem ciclos;
comumnã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 só 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.errordisplicente é 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 emsrc/test/resourcesé o mesmo risco do episódiodocs/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
HttpClientinjetado por DI (base-url vazia/relativa em teste) aponta pro servidor embutido, não pra porta dinâmica do WireMock — subir o@MicronautTestinteiro 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 +TestPropertyProviderinjetando a URL; limpeza de tabelas em@BeforeEach) — um container por classe é lento. A URL mágicajdbc: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.@MicronautTestdefault (transactional=true) embrulha cada@Testnuma 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 defaultTransactionMode.SEPARATE_TRANSACTIONScommita cada@BeforeEachem transação própria — por isso seed no@BeforeEach(ou@Sql) fica visível ao controller, seed no corpo do@Testsome (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 comtransactional=falsepor reflexo: além de re-habilitar escritas persistentes indesejadas, quebra o JDBC cru do@BeforeEachque 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
@Clientque fazGETe renderiza de verdade. O./gradlew checkcompila 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:onclickString,Map.ofno 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@MicronautTestcom@Clientque fazGETna 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@Testpor view pública, mais os ramos de erro que renderizam. - Unit × integração por environment do Micronaut:
@MicronautTest(environments = "unit")= H2MODE=PostgreSQLcom 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. Oapplication-test.ymlneutraliza envs de auth do shell/CI e usaport: -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; orunnormal fica em 8080 fixo, então debug local que precisa não colidir roda comMICRONAUT_SERVER_PORT=-1e lê a URL do log de startup (Server Running: http://localhost:<porta>). No E2E com@MicronautTest, a porta vem injetada — pegue a URL doEmbeddedServer(server.getURI()/getPort()) ou@Client("/")(resolve pro embutido sozinho); nunca colelocalhost:8080no teste (colide com processo vivo/zumbi e vira flaky). O porquê geral em agentes § Loop de verificação. - Cobertura (JaCoCo) —
toolVersionamarrada 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 ajacoco.toolVersionao Java homologado: Java 25 → ≥ 0.8.15. (O baseline da fábrica homologa a versão de Java — decisão 0003; vale um PR noci-imagesamarrando a versão do JaCoCo à do Java.) - A task que lê o XML do JaCoCo é configuration-cache-safe: declare o
Providerdo arquivo comovallocal da configuração da task (não no topo do script — serializa referência ao objeto-script), use.get().asFilenodoLast, semfunaninhada e sem capturarlayout/projectnodoLast(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): declareupdated_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 noUPDATEque o ORM emite). Serve de chave-versão para consumidor externo ler "o que mudou" porORDER BY updated_at(delta-sweep);NOT NULL DEFAULT now()garante ordenação limpa desde oINSERT. 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 só quando coluna relevanteIS 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 fazUPDATEsó 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 CrudRepositorypara CRUD pontual; caminho de bulk é JDBC à mão num@Singleton(COPY + TEMP TABLE, upsert diff-aware comIS 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 @MappedEntityde uma tabela que outro app/ERP escreve) marca@Nullabletodo 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 é: oON CONFLICT DO UPDATEtira row lock na arbitragem do conflito, ANTES de avaliar oWHERE— 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.executeBatch→sorted(ORDEM_CHAVE_NATURAL)no repository antes do map (comparator comnullsFirstquando a chave tem coluna nullable);COPY + TEMP TABLE→ORDER BY <chave natural>noINSERT … 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 +DELETEseletivo) exige defesa adicional: oDELETEtrava linhas em ordem de scan, queORDER BYnão controla (DELETEnem aceitaORDER BY) — serialize a fase de banco compg_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 oORDER BYno 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 aflyway_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-migratedeixa o Flyway marcar o schema existente como linha-base em vez de abortar;baseline-version: 0é o que faz a própriaV1do app rodar — o default (1) marca tudo≤ 1como 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-size5–10,minimum-idle2. 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}noapplication.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 tabela — A 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. OdescribeXxx()repetido por tabela vira umdescribe()único viaBeanIntrospection. 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-serverhoje carimba a tríade à mão por tabela — umParsedXadmPayloadde ~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 paraxadm-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-securitynativo (AuthenticationFetcher+SecurityRule+ExceptionHandler), não um@Filterparalelo; 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 ointercept-url-map(Bearer) é a ferramenta certa — o contraste é a view server-render, que nunca deve ser liberada porintercept-url-mapgreedy (§UI · piso). - HTTP app↔app dentro de Micronaut =
@Clientdeclarativo +@Retryable, não OkHttp. O transversal da casa dizHTTP=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@Clientdeclarativo — gerado do OpenAPI do receptor no lado consumidor (openapi-generator), que é o dono do contrato (decisão 0020) — com@Retryablepara 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. (maxsulmigra 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}")casandowebstorm.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${…:-${…}}doSENTRY_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:}ewebstorm.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 runcomo rodar-em-dev (sobe a infra mínima;MICRONAUT_ENVIRONMENTS=dev).- O OpenAPI é gerado, logo é um oráculo — da rota. Com
micronaut-openapinoannotationProcessor, o spec sai dos@Controller/@Getao compilar — é a verdade sobre quais rotas o app expõe, de graça. Por isso oci.ymlroda ocheca-rotas.pydepois 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 head há um 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 passa — dentro 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 checkcom 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.jarexigearchiveFileName.set("app.jar")noshadowJar; sem isso o artefato sai versionado (app-1.2.3-all.jar) e oCOPYquebra a cada bump de versão. Só noshadowJar, nunca nojarbase: com o pluginapplication, darapp.jaraos dois faz ostartScripts/builddo 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 /appcria a pasta como root (o--chowndoCOPYé do jar, não dela), então depois doUSER appqualquer escrita sob/app— log em caminho relativo, cache, upload — falha comAccessDeniedExceptionem runtime, na produção: o build passa, o teste passa, e odocker builddo pré-flight também. Tudo que o app criar sob/appprecisa existir com donoappantes doUSER app(RUN mkdir -p /app/logs && chown app:app /app/logs). Não troque isso porchown app:app /appinteiro: o app passaria a poder sobrescrever o próprioapp.jar, que é metade do motivo de rodar não-root. E antes de criarlogs/, confira o arquétipo: servidor loga em stdout (java), quem loga em arquivo é CLI/worker —logs/num servidor costuma serlogback.xmlcopiado do arquétipo errado, e aí omkdircura o sintoma. O par disso no Coolify é oStorages: volume nomeado herda o dono do diretório da imagem (não existindo o diretório, nasceroote quebra igual); bind mount não herda — o dono no host é que precisa ser1001:1001. HEALTHCHECK+curl— o health check é obrigatório (versionamento); ocurlno 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 doARG FLUTTER_VERSION×.fvmrcno Flutter. - Cache mount do Gradle:
idpor app +sharing=locked. Troque o<slug>do template. Oidde um cache mount default para o própriotarget, então--mount=type=cache,target=/root/.gradlebare 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 comTimeout 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õesharing=lockedno 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 criaSENTRY_DSNe deixa oGLITCHTIP_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.yamldo Micronaut NÃO aninha placeholder —${A:${B}}sai lixo, sem erro de build. ODefaultPropertyPlaceholderResolvercasa o primeiro}(indexOf), não o balanceado:${NOVO:${ANTIGO:}}vira a expressãoNOVO:${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 ologback.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${…${noapplication.yaml/.properties, só em app Micronaut — Spring aninha certo).@Clientconfig-gated: declararmicronaut.http.services.<id>.urlcria bean eager que quebra o startup INTEIRO. Ummicronaut.http.services.<id>.urlnoapplication.ymlinstancia umServiceHttpClientConfigurationeager (@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@MicronautTestfalhando em massa noNettyHttpServer.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 porBeanProvider<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@Consumesexplícito. O@Postdo Micronaut assume@Consumes(application/json); um<form method="post">do navegador mandaapplication/x-www-form-urlencoded→ 415 no submit, mesmo que o handler nem leia o corpo. Anote@Consumes({MediaType.APPLICATION_FORM_URLENCODED, MediaType.APPLICATION_JSON})(ouMediaType.ALL) no método. Armadilha de teste que esconde isso: oHttpClientdo Micronaut, numPOST(uri, corpo), mandaapplication/jsonpor 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). recordno 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()). Umrecordgera acessorfoo(), nãogetFoo()→${obj.foo}estoura no render ("Property 'foo' not found on type …", não énull: é erro de template). Saídas: passe umMapno modelo (o OGNL tem accessor deMap) 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:inserttêm precedência MAIOR queth:if— no mesmo tag oth:ifnão segura. O processamento é por precedência (menor roda antes): inclusão de fragmento (th:replace/th:insert, 100) roda antes deth:if/th:unless(300). Então<div th:replace="~{frag}" th:if="${cond}">sempre inclui o fragmento — quando oth:replaceexecuta, o elemento já foi substituído e oth:ifnem é avaliado (rende com a condição falsa → quebra). Cura: ponha oth:ifnum elemento pai (ou<th:block th:if="…">) que envolva oth:replace. Mesma família dorecord/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 comth:ifno 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çaTemplateProcessingException("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 numdata-*(th:attr="data-href=@{/rota/{id}(id=${x})}") e ligue o handler em JS no rodapé — nunca monteth:onclickcom 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 (oWebContextdo Spring MVC); o Micronaut Views renderiza com Thymeleaf standalone (Contextpuro, sem contexto web — a mesma razão do OGNL no bullet dorecord), 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 dorecord/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 dodata-*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. UmViewModelProcessorinjeta dado transversal no modelo de toda view renderizada (usuário/tenant/ versão no layout — padrãolayout.html+GlobalViewModeldo molde do integrador) fazendomodel.put(...). Se o controller devolveu umMap.of(...)(imutável — comum no ramo "não encontrado"), oputestouraUnsupportedOperationException→ HTTP 500, e só naquelas rotas (as que devolvemMapmutável passam). Regra: view controller sempre devolve modelo mutável (LinkedHashMap), nuncaMap.of/Collections.emptyMap. Casa com o bullet dorecord/OGNL acima (que já empurra passarMapno modelo — só garanta que seja mutável). Guarda barata: um teste que fazGETnuma rota de not-found e espera 200, não 500. Vale pra qualquer app que adote oViewModelProcessortransversal do molde — o de referência que popula${appModo}/${appVersao}no layout está em §UI.- Cliente REST de API externa —
PUTsubstitui a coleção. UmPUTnum 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 relativa →HttpRequest.newBuilder(uri)lançaIllegalArgumentException(a URI tem de ser absoluta com host) — não a exceção de domínio que ocatchespera → 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 donewBuilder) e alargue ocatchpraRuntimeExceptioninesperada — 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/HttpServerFilterlegado dependendo de@Order. O@Filterlegado 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@ServerFilterrespeita@Orderde 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 (oJsonMessageHandlerpedindo ojsonMapperde 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 →
initializationErrorem MASSA na run seguinte (dev local). Uma run de./gradlew test/checkinterrompida (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 taskinternalStartTestResourcesServicefica UP-TO-DATE (o Gradle acha que o serviço está no ar), então os@MicronautTesttentam conectar na porta morta e falham em massa (TestResourcesException: Test resource service is not available)../gradlew --stopsozinho 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-resourcessó 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-daemone apagarbuild/entre execuções — sem daemon o serviço de TR não sobrevive à JVM efêmera e orm -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ãorm -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 viaTestPropertyProvider, §Testes, que o Ryuk do Testcontainers reapa no fim — o Test Resources não tem esse reaper.) - Gradle 9.5 + shadow +
application:jarbase eshadowJarcom o MESMOapp.jar=startScripts/buildquebra. Com o pluginapplication, a taskstartScripts(edistZip/distTar) consome o output dojar; se você nomear o jar baseapp.jaralém do shadowJar (dividir o nome), o Gradle 9.5 vêstartScriptsusando um output que oshadowJarproduz 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 buildlocal. Cura:app.jarsó noshadowJar(o que a §Deploy já prescreve — dêarchiveClassifier/nome distinto ao jar base, ou deixe o defaultapp-<versão>.jar), outasks.startScripts { dependsOn(tasks.shadowJar) }. Ponto cego declarado: nenhum gate da casa pega — o CI rodacheck, nãobuild; se aparecer, é nesse dev, com essa mensagem.