Java / Micronaut — servidor / API¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-12
Aplica-se a: perfil app, stack java (server Micronaut). O que for Micronaut vale também para os
módulos do xadm-commons, e a autoria de lib está em Bibliotecas da casa.
Donos vizinhos: build, checkstyle, logging por arquétipo e exit codes em Java; consumo e
versão das libs em Bibliotecas da casa; o piso de segurança em Segurança; o
contrato do /health em Versionamento; níveis de teste e
definição de pronto em CI, gates e testes; o fio
ponta a ponta em e2e-local.
Arquitetura¶
- Package-by-feature, com as camadas dentro da feature:
login/,processamento/,powersync/— controller, service, repository e entidade convivem no pacote da feature; o transversal vive emcomum/{config,exception,util,…}. Repo novo não nasce em package-by-layer. - Injeção por construtor:
@Singleton+ dependências no construtor, nenhum@Injectem campo. Dependência opcional é@Nullableembrulhada emOptional; variação por estratégia é classe irmã (…Impl/…Stub), não flag booleana. - Controller fino: injeta um ou dois services e delega; HTTP, validação e OpenAPI moram nele, o negócio não.
- Tipos: DTO HTTP é
record @Serdeablecom sufixo*Request/*Response/*View(@Serdeableé allowlist de segurança: todo DTO HTTP leva); entidade é@MappedEntitycom@MappedPropertysnake_case explícito; a tela recebe view-model, nunca a entidade do banco. - Config: bloco coeso vira
@ConfigurationProperties(classe…Settingscom javadoc mapeando cada env), construtor-injetado; valor avulso vira@Value("${x.y:default}")no construtor. O comentário noapplication.ymlexplica o porquê do valor atual; o histórico vai para o CHANGELOG. - Nomes: domínio em pt-BR com sufixo técnico em inglês (
ProcessamentoService); javadoc em pt-BR citando a decisão que motivou o código. Pacote basebr.com.xadm. - Rodar em dev:
./gradlew runcomMICRONAUT_ENVIRONMENTS=dev.
Guarda de fronteiras¶
A guarda das fronteiras do package-by-feature é ArchUnit, obrigatória no check, com asserção de
import não-vazio. As fronteiras que ela cobra são as que o livro do app declara; as três primeiras vêm
prontas na fábrica RegrasArquitetura da xadm-comum-teste:
- features sem ciclos entre si;
comumnão depende de feature (semCiclosEntreFatias,baseNaoDependeDeFatias); - controller não acessa repository direto — passa pelo service (
controllerNaoAcessaRepository()); - parsing e regra de domínio não dependem de controller (
nadaDependeDeController()); - config e infra não vazam para o domínio.
Poucas regras, de alto sinal. Exceção local é comentário no teste ou decisão, nunca silêncio.
ESCRITA_DECLARA_CONSUMES: em controller de view (classe com método@Viewou@Produces(TEXT_HTML)), todo@Post/@Put/@Patchdeclara@Consumes— a anotação presente, qualquer media type. API/api/**só-JSON fica fora. Esboço:methods().that().areAnnotatedWith(Post.class).and().areDeclaredInClassesThat(<é controller de view>).should().beAnnotatedWith(Consumes.class).- Import não-vazio:
RegrasArquitetura.importNaoVazio()daxadm-comum-teste, ouassertThat(importedClasses).isNotEmpty(). A guarda lê bytecode, e classe num class file major que o ASM não conhece é pulada no import: a regra roda sobre zero classe e passa verde. No Java 25 o piso é ArchUnit ≥ 1.4.1 (ou 1.3.2, que tem o backport), cobrado pelopipeline.yml; o CI avisa quando a suíte não tem a asserção. A guarda não vê versão herdada — axadm-comum-testeexporta o ArchUnit porapi, e quem cobre é o piso da lib —, ehasNext/isEmptysoltos não contam como defesa. - Constante inlinada é invisível: dependência que existe só por
public static finalsome do bytecode do chamador. Fronteira que importa ali se guarda por outro meio. - Literal de pacote nas regras: a fatia se escreve com o nome completo (
br.com.<app>.seguranca..) — no destino,..seguranca..casa também o pacote da lib (br.com.xadm.comum.seguranca). Os literais (@AnalyzeClasses(packages = …),resideInAPackage,slices().matching,logback.xml,Class.forName) ficam para trás num rename ingênuo: a guarda importa zero classe, ou a origem da regra fica vazia, que só falha semallowEmptyShould(true)(reservado à anti-vácuo). Varra o literal comgrep -rl "<pacote>"e prove que a guarda ainda morde quebrando uma regra de propósito. - Checkstyle: as regras são campos
static final ArchRuleem camelCase; osuppressions.xmlda casa já suprimeConstantNameno teste — não relaxe ocheckstyle.xml.
Erros e observabilidade¶
- Exceções: as de fluxo HTTP estendem
HttpStatusException(BadRequest,Conflict,Forbidden,NotFound,StorageIndisponivelException); exceção de domínio própria quando carrega semântica. Nada deExceptioncrua na borda. - O corpo RFC 7807 é o processor da
xadm-comum-web; o app não escreve processor. Exceção de domínio implementaPortadoraDeProblema: ocodede máquina e otitledo tipo vêm dela, o específico da ocorrência vai nodetail, e ocodeé uniforme também nas falhas de Bean Validation. Fluxo que exige resposta especial (view → 302 para o login) ganhaExceptionHandlerdedicado. - Chamada externa num fluxo com estado: o
catchem volta dela pega tambémRuntimeExceptioninesperada e leva o registro a um estado terminal (falha ou retry); a montagem da chamada (URI vinda de config, corpo, headers) é validada antes, virando exceção de domínio tratável. - Níveis de log (todo
ERRORvai ao GlitchTip): ERROR é falha real, com a exceção anexada; WARN é degradação ou recusa esperada, sem stacktrace; INFO é marco de ciclo de vida e auditoria leve. - Invariante: todo 5xx, e todo 404 de rota não casada em requisição autenticada (sem
RouteMatch), chegam ao GlitchTip exatamente uma vez; o 404 de negócio fica em DEBUG. O critério é a credencial: cliente com token acredita que a rota existe, e 404 nele é rota sumida (a poda do AOT é o caso típico); scanner não manda token. O processor daxadm-comum-webcumpre o invariante — o app não loga de novo o que ele já logou.
Observabilidade (GlitchTip/Sentry)¶
- O DSN é lido pelo
SentryInitializerdaxadm-comum-web(envSENTRY_DSN); ologback.xmldeclara oSentryAppendersem<dsn>, com<minimumEventLevel>ERROR</minimumEventLevel>eappender-refno<root>: todo logERROR, inclusive exceção não-tratada, chega ao GlitchTip sem código no negócio. DSN vazio é no-op. O nome éSENTRY_DSNem toda stack, porque é o que o SDK lê. - A metadata native do Sentry vem da
xadm-comum-web; o app não carregareflect-configde Sentry/logback. - Tag de tenant e PII: o app que precisa carimbar o cliente faz
Sentry.init(options -> …)no startup —options.setTag("cliente", <slug>)quando 1 processo = 1 tenant; app com N tenants por request põe o cliente por request (escopo/MDC).options.setSendDefaultPii(true)é o padrão da casa (Segurança §Segredos). Assinaturas conferem-se porjavap(Ferramentas). - Rota
/test/glitchtip(opt-in,@Secured(SecurityRule.IS_AUTHENTICATED)): dispara umLOG.error("teste GlitchTip <app_id>", new RuntimeException("smoke-test"))e responde 200 — confirma em produção que o erro chega. O/xadm-setupoferece o scaffold.
Banco (Postgres compartilhado)¶
- Persistência é
micronaut-data-jdbc, obrigatória:@JdbcRepository(dialect = POSTGRES)e@Transactional; sem Hibernate/JPA; SQL cru viaDataSource.getConnection()como camada de persistência é proibido (bulk à mão num@Singletoné permitido, abaixo). OannotationProcessoré obrigatório — sem ele@Transactionalvira no-op e o acesso quebra em runtime com "No current connection present":
annotationProcessor("io.micronaut.data:micronaut-data-processor")
implementation("io.micronaut.data:micronaut-data-jdbc")
App sem persistência (broker, proxy) não configura datasources e não é alcançado. O pipeline.yml
reprova quem configura datasources sem o processor.
- Migrando de SQL cru: todo @JdbcRepository exige entidade-raiz; o mapeador lê a propriedade da raiz
antes da do record de projeção; parâmetro nomeado da @Query é só [a-zA-Z0-9]; @Query com CAST vai
em SQL literal, e records de projeção levam @Introspected/@Nullable. Uma regra ArchUnit com allowlist
de DataSource.getConnection() trava a regressão.
- Owner-writes: cada tabela tem um dono, que roda o Flyway e escreve; os demais leem via PowerSync
(Plataforma de Integração). Consumidor que lê
tabela alheia direto não tem migração de produção para ela: o schema que o teste precisa vem de uma
location test-only (db/migration-test), e o deploy é coordenado (dono primeiro).
- Chave de mudança: dono único da tabela anota updated_at TIMESTAMPTZ NOT NULL DEFAULT now() com
@DateUpdated; tabela com vários escritores (apply, admin, PowerSync) mantém a coluna por trigger
(BEFORE INSERT OR UPDATE, bump só em IS DISTINCT FROM) e não a mapeia na entidade — o UPDATE
do Micronaut Data zeraria o valor.
- Bulk é JDBC à mão num @Singleton (COPY + TEMP TABLE, upsert diff-aware); CrudRepository
para o CRUD pontual.
- Batch upsert ordena pela chave natural: o ON CONFLICT DO UPDATE trava a linha na arbitragem,
antes do WHERE, e duas transações travando em ordens diferentes deadlockam (40P01). Ordene as linhas
antes de executar (sorted no executeBatch, ORDER BY no INSERT … SELECT). Transação com mais de uma
fase de escrita (upsert + DELETE) serializa com pg_advisory_xact_lock(<chave do app>). O teste afirma
a ordem das linhas, não o deadlock.
- Read-model de tabela de terceiro marca @Nullable todo campo que a fonte pode deixar null e trata a
ausência como anomalia da linha (WARN, pula), não como falha do lote.
- Tabela que o PowerSync replica: o @Id da entidade é a PK real do banco, PK simples
(PowerSync §Fonte).
- Somar componente a record @MappedEntity quebra todo new Entity(...) (o Data binda o construtor
canônico): construção de entidade em teste passa por factory de test-data, e a migração pontual usa o
compilador como checklist.
- Flyway: VN__descricao_em_snake_pt_br.sql, congelada por checksum; tabela de histórico por app
(flyway_history_<app>) no Postgres compartilhado; app secundário (schema já povoado por outro) usa
flyway.baseline-on-migrate: true e baseline-version: 0, senão a própria V1 é pulada.
- DDL destrutivo segue expand/contract: DROP COLUMN/DROP TABLE/RENAME/DROP CONSTRAINT nunca na
release que para de usar o objeto — a release N para de usar (expand), a N+1 remove (contract). Rename é
add-new → backfill → dual-write → drop-old. O .sql leva
-- destrutivo-ok: contract; <objeto> sem uso desde v<N> (expand em v<M>); o pipeline.yml avisa sem ele, olhando só migration nova ou alterada desde a última tag.
- Tabela que o app cria em schema-espelho leva prefixo <app>_, inclusive a de domínio; tabela sem
prefixo que já existe não é exemplo e renomeia ao tocar
(Plataforma de Integração §Taxonomia).
- Outbox em schema compartilhado: cada relay reivindica só os tipo que registra, e só enfileira quem
tem o EnviadorMensagem do tipo (0021).
- Job agendado elege instância: duas instâncias do mesmo app coexistem em sobreposição de deploy,
réplica ou religamento do fallback, e todo @Scheduled dispara em cada uma. Job cujo efeito não é
idempotente abre o tick com pg_try_advisory_lock(hashtext('<app>_<job>')) e quem não adquire sai do
tick. Não confunda com o pg_advisory_xact_lock do batch: aquele serializa escrita dentro da transação;
este elege instância e atravessa a chamada externa. O pipeline.yml avisa @Scheduled sem eleição
(// agendado-ok: silencia job idempotente); a guarda olha por arquivo, segue um salto de delegação, e a
citação da eleição é textual.
- @Scheduled de lib: a lib da casa elege sozinha (mensageria.relay.cluster-lock, ligado por
padrão). Trazer o trabalho dela para a mesma eleição do app é desligar o agendador da lib e reagendar
chamando RelayM2m.executarSeEleito().
- Dedup de notificação externa mora no banco, nunca só na RAM: a memória do processo zera no reinício e
a outra instância não a vê — nem a do rolling deploy, que sobrepõe as duas —, então cada reinício realerta
tudo. A marca é reivindicada por escrita condicional, e a contagem de linhas afetadas decide (1 envia, 0 é
duplicata): UPDATE … SET marca = :valor WHERE id = :id AND marca IS DISTINCT FROM :valor quando a marca
é coluna da linha notificada, INSERT … ON CONFLICT DO UPDATE … WHERE <vencida> quando ela é linha
própria, com prazo. O dedup sobrevive a reinício e só uma instância ganha. A migration que cria a marca a preenche nas
linhas já notificadas, senão o deploy dispara tudo de novo. Custo aceito: envio que falha depois da
reserva não se repete. A cota do provedor de e-mail é compartilhada entre os apps da casa.
- Pool (HikariCP) pequeno: maximum-pool-size 5–10, minimum-idle 2, como chaves planas de
datasources.default (um sub-bloco hikari: é ignorado) — o Postgres é dividido entre os apps.
- Segredo entra no application.yml como ${ENV_VAR:default-dev-inócuo}; em produção é env do
recurso (Segurança).
Específico de Micronaut: HTTP, integração e libs¶
Consumo das libs da casa¶
Registro, versão e migração em Bibliotecas da casa. No app Micronaut:
/healthe/info: use oHealthController/InfoControllerdaxadm-comum-webe desligue os de management (endpoints.health.enabled: false,endpoints.info.enabled: false). O contrato está em Versionamento.- Storage é a
xadm-comum-storage: injete oS3Clientque ela expõe (ou o ponto de injeçãoObjetoStorage, para gravar e ler objeto), configuregarage.*pelas envs que o broker injeta (GARAGE_ENDPOINT,GARAGE_BUCKET,GARAGE_REGION,GARAGE_ACCESS_KEY_ID,GARAGE_SECRET_ACCESS_KEY— Garage). - Login e views: a tela de login e o enriquecedor das views vêm da
xadm-seguranca(UI).
HTTP e integração¶
micronaut.server.thread-selection: AUTOnoapplication.yaml: o defaultMANUALroda método imperativo no event loop, e JDBC e SOAP bloqueiam o loop sem aviso.@ExecuteOn(TaskExecutors.BLOCKING)fica como override local. Endpoint reativo é reativo ponta a ponta. Controller de lib não conhece othread-selectiondo adotante: Bibliotecas da casa.- Listagem de tabela que cresce recebe
Pageable(page/size) e devolve oPage/Slicedo micronaut-data como está (cobrado em review), sem DTO de paginação próprio; a tela pagina pelokit/pager.jtedo kit de UI. Omicronaut.data.pageable.default-page-sizeé declarado: sem ele, o default é omax-page-size(100). Teste de forma sem contexto usaObjectMapper.create(Map.of(), "io.micronaut.data.model"): ogetDefault()não carrega oPageSerializere escreve oPagecomo array. - Auth no
micronaut-securitynativo (AuthenticationFetcher+SecurityRule+ExceptionHandler), nunca um@Filterparalelo; API por Bearer. O piso está em Segurança. - CORS por superfície, no scaffold: app cuja UI é outra origem liga o CORS nativo
(
micronaut.server.cors.enabled: true,configurations.<nome>.allowed-origins), com as origens em config — lista base noapplication.yaml+${<APP>_CORS_EXTRA_ORIGINS:}. O gap só aparece no browser (failed to fetch);curle teste de servidor não fazem preflight. - Callback de um app a outro mora sob
/api/<origem>/<recurso>, com<origem>= o app chamador (/api/integrador/produto): o/api/**é Bearer (ROLE_API) e o prefixo dá rota previsível. - Chamada app ↔ app é
@Clientdeclarativo +@Retryable, com a interface vinda do módulo<svc>-apique o receptor publica (0020). OkHttp é dos arquétipos Java sem Micronaut (Stack). Destino sempre configurado →micronaut.http.services.<id>; destino que só existe em alguns deploys →@Client("${<app>.url}")injetado porBeanProvider. - Env de cliente outbound é nomeada pelo destino:
<APP>_URL+<APP>_API_TOKEN, mapeadas numa prop por destino (webstorm.url: ${WEBSTORM_URL:}). Rename de env é dual-read, sempre: duas props de um nível só, o bean pega a primeira não-vazia, e a antiga sai quando todo deploy tiver o nome novo — pela lista "Coolify — depois do deploy" da/xadm-release. - Endereço de serviço externo nasce em property com o default de produção, nunca em constante — é o
que deixa o teste apontar ao stub:
@Value("${central.resend.endpoint:`https://api.resend.com/emails`}") String endpoint(crases no default com:— Armadilhas). - XLSX se lê com
org.dhatim:fastexcel-reader, não Apache POI (0023): POI lê XLSX por XMLBeans e quebra no native. POI fica só emtestImplementation, para gerar fixture. Abra comnew ReadableWorkbook(is, new ReadingOptions(true, false)): sem otrueo reader não resolve o formato da célula e data Excel sai serial crua. Data é formato built-in 14–22 ou 45–47, ou tokeny/d/h/snogetDataFormatString(); a leitura isola-se numa camada que produz tipo neutro (String[]por linha). - O OpenAPI gerado é o oráculo da rota: com
micronaut-openapinoannotationProcessor, opipeline.ymlroda ocheca-rotas.pydepois dochecke reprova rota citada na prosa que o spec desmente (CI, gates e testes).
UI (server-render)¶
Todo app que renderiza HTML no servidor, inclusive tela de admin, usa o kit de UI do manifesto
(kit/layout.jte, kit/headerRight.jte, o paginador kit/pager.jte, custom-theme.css, logo, favicon e o
Bootstrap vendorizado); o app escreve o conteúdo de cada tela e o seu public/css/app.css. O motor é JTE
(0025).
JTE — setup, kit, @View, native¶
plugins { id("gg.jte.gradle") version "3.2.4" } // mesma versão do gg.jte:jte do BOM
dependencies {
implementation("io.micronaut.views:micronaut-views-jte")
jteGenerate("gg.jte:jte-native-resources:3.2.4")
}
jte {
generate() // .java compilado junto do app
contentType.set(gg.jte.ContentType.Html) // escape XSS por contexto
jteExtension("gg.jte.nativeimage.NativeResourcesExtension")
}
tasks.named("compileJava") { dependsOn("generateJte") }
- Templates em
src/main/jte; em produçãomicronaut.views.jte.dynamic=false(o default). - Controller:
@View("admin/clienteForm")+ modelMap/POJO; o model casa nos@paramdo template por nome. Rota@Viewleva@Produces(MediaType.TEXT_HTML). - Template type-safe:
${x.metodo()}é chamada Java compilada — erro vira erro de compile. A página chama o layout:@template.kit.layout(title = "…", appNome = "…", content = @`…`). - A extensão
NativeResourcesExtensiongera o registro de reflexão de todas as views; lista à mão deJte*Generatedé proibida — view nova esquecida é podada no AOT e responde 404 só no native, só quando recebe tráfego. Opipeline.ymlreprova app JTE + native sem a extensão. - Helpers da casa:
Formatadaxadm-comum-util(${Formata.data(x, "dd/MM/yyyy")},${Formata.trim(s)}, null-safe);XadmViewModeldaxadm-seguranca, que injeta os globais do layout (opt-in porxadm.views.app-nome). - Armadilhas de build: o
gg.jte3.2.4 não temwriteUserContent(Object)— temporal e UUID direto não compilam (${x == null ? "" : x.toString()}); o checkstyle e o JaCoCo contam o gerado — exclua**/gg/jte/generated/**dos dois. - Estático que o kit linka é anônimo: os estáticos são servidos pelo app e o
intercept-url-mapdecide antes da regra de view (ordem −100; quando um padrão casa, a regra Java nem é consultada). Libere/css/**,/js/**e/images/**no YAML; senão a tela de login sobe estilizada e sem JS, sem nada no log. Trave com um teste que lê oshref/srcdokit/layout.jtee fazGETsem credencial em cada um, esperando nem 401 nem 403. - O Bootstrap vem vendorizado no kit, com versão, origem e hash SRI no cabeçalho; o SDK do Firebase fica
no
gstatic(vendoriza-se apresentação, não SDK de identidade).
Tela de login — servida pela lib¶
O xadm-seguranca serve a tela (xadm/login.jte) pelo XadmLoginController; o app configura:
xadm:
views:
app-nome: "Integração X-Adm Webstorm"
login:
marca: true # default false — logo grande + tagline
tagline: "Um ERP Completo para sua empresa"
# titulo: default "Login — ${app-nome}"; rodape: default ${app-nome}
- Property ausente degrada para o default: são cosméticas.
- Adotar é apagar a
login.jtee o processor local deappVersaodo app, no mesmo PR. AappVersaovem da lib, e okit/headerRight.jtea mostra quando presente. - A tela da lib usa classes do
custom-theme.css: marca sem estilo é kit desatualizado — re-derive.
Testes e fixtures (Micronaut)¶
Unit = sem contexto Micronaut. Integração = @MicronautTest + o Postgres singleton da xadm-comum-teste,
pinado à versão do deploy. Tudo roda no check.
- Postgres de teste é o singleton da
xadm-comum-teste: a classe de teste, ou uma base abstrata dela, implementaPostgresTestPropertyProvider, com@TestInstance(Lifecycle.PER_CLASS)(sem ele as props não entram). OPostgresTestResourcesobe umpostgres:18-alpinepor JVM, com tmpfs em/var/lib/postgresql; o Flyway real roda nele e o Ryuk o recolhe. A URL chega injetada — o teste não declaradatasources.default.urlnem porta — e.withReuse(true)é proibido. - Limpeza entre testes pela lib: o banco sobrevive entre classes, então a classe de teste estende
IntegracaoComPostgresdaxadm-comum-teste, que já traz oPostgresTestPropertyProvidere o@TestInstance(PER_CLASS), ou chamaPostgresTestResource.limparTabelas()no próprio@BeforeEach. A limpeza éTRUNCATE … RESTART IDENTITY CASCADEpor conexão direta, fora do pool e da transação do teste, e poupa o histórico do Flyway: truncado, o próximo contexto re-aplicaria as migrations sobre o schema existente. O@BeforeEachda subclasse roda depois e semeia sobre banco vazio. Tabela semeada por migration (configuração, domínio fixo) não passa por essa limpeza: o Flyway já rodou e não regrava o seed. App com seed escreve a própria limpeza, só das tabelas que os testes gravam. - Não são padrão, e migram quando o repo tocar os testes:
jdbc:tc:semTC_DAEMON=true(o container para quando a última conexão fecha, e o Micronaut fecha o pool ao fim de cada classe: Postgres e Flyway recomeçam a cada classe) e o Micronaut Test Resources (o servidor fica órfão depois decleanou de interrupção e carrega o próprio Testcontainers, que o app não consegue subir). - Trava vira falha, não espera:
tasks.test { timeout = Duration.ofMinutes(15) }no Gradle, comimport java.time.Durationno topo dobuild.gradle.kts;junit.jupiter.execution.timeout.default = 2 mejunit.jupiter.execution.timeout.mode = disabled_on_debugemsrc/test/resources/junit-platform.properties(não cobre a subida do contexto, que o timeout da task cobre); edatasources.default.connection-timeout: 5000commaximum-pool-size: 4noapplication-test, para container morto falhar em 5 s, não em 30 s por teste. - Alvo que exige estado próprio sai do singleton para container dedicado: flag de servidor
(
wal_level=logical, com a flag de produção emwithCommand("postgres", "-c", "wal_level=logical", …)) ou timeline de migração (Flyway até umtarget, dado anterior semeado, depois a versão seguinte). - DDL de teste por JDBC (
DriverManager+Statement.execute), nãowithInitScript, cujo shading quebra entre versões. - Compose com Postgres 18+ monta o volume em
/var/lib/postgresql— no caminho/datao banco grava num volume anônimo e o nomeado fica vazio. Testcontainers não é afetado. - Fixture golden-master, datada e com a receita de captura: entrada real + esperado em
src/test/resources/fixtures/<dominio>-<AAAAMMDD>/, o comando que gerou o esperado no javadoc, dado de cliente anonimizado. XLSX de fixture sai de ferramenta que emitenumFmt(POI notestImplementation, ou arquivo do Excel), lido pelo reader comReadingOptions(true, false). - Entidade com muitos campos entra no seed por factory de test-data (
umPedido()com defaults). - API externa: WireMock com o shape capturado da instância real. Serviço que chama HTTP externo se testa com client construído à mão na porta do WireMock; o controller, só nos ramos sem rede.
- Validador de token de terceiro (JWKS/RS256): a suíte assina com chave própria e aponta a busca do
JWKS para ela por property com default de produção
(
${AUTH_FIREBASE_JWKS_URL:`https://www.googleapis.com/…`}). O override é de config, nãoif (teste)(e2e-local). - Transporte próprio (SMTP por
Socket, HTTP montado à mão) se testa com servidor fake na JVM, em porta efêmera (new ServerSocket(0),com.sun.net.httpserver.HttpServer): ordem dos comandos, umRCPT TO:por destinatário, dot-stuffing, 5xx e porta fechada. - Teste de endpoint semeia no
@BeforeEach/@Sql, nunca no corpo do@Test: o@MicronautTestembrulha o teste numa transação com rollback, e o servidor embutido atende em outra conexão. Pipeline@Asyncque persiste é a exceção:transactional = false, com limpeza por conexão direta. - Código que roda por entrypoint sem request (
@Scheduled,@EventListener, thread própria, boot, CLI) se testa disparando o entrypoint real, não chamando o método sob o contexto do teste; a anotação de contexto (@Connectable/@Transactional) mora no método de produção. - Guarda de cluster se testa com sessão distinta: o advisory lock é por sessão e reentrante, então duas
threads do mesmo pool não provam nada. Abra uma conexão fora do pool (
DriverManager), segure opg_advisory_lock(hashtext('<nome>'))e chame a guarda injetada esperando coalesce. O fim da sessão rival solta o lock de forma assíncrona: capturepg_backend_pid()da rival e, depois doclose(), espere (com prazo)pg_lockssem advisory desse pid antes de acionar o relay. - View controller tem teste
@Clientque fazGET, espera 200 e afirma um trecho do HTML: ochecknão renderiza template sem ele. - Integração que depende de Docker pula só com Docker ausente e aviso alto (uma
ExecutionConditiondo JUnit sobre oDockerClientFactory.instance().isDockerAvailable(), com log no skip); odisabledWithoutDockerdo@Testcontainerscala, e integração atrás de flag (-PdockerTests) é reprovada pelopipeline.yml. - Porta efêmera em todo servidor que o teste sobe (
micronaut.server.port: -1); a URL vem deEmbeddedServer.getURI()ou@Client("/"), nuncalocalhost:8080(agentes). - Fake com
@Replacesno source-set de teste vaza para todos os@MicronautTest: use@MockBean, ou@Requires(property=…)no fake +@Propertyna classe que o quer. Classe com cobertura ~0% sob teste de integração verde é o sintoma. - Relógio injetável: bean que depende de tempo (janela, TTL, expiração, backoff) recebe
Clockpelo construtor, comClock.systemUTC()como bean padrão; o teste avança o tempo. - Cobertura (JaCoCo):
toolVersion≥ 0.8.15 no Java 25 (abaixo, o relatório sai vazio); a coleta roda numa invocação só, com--rerun-tasks(oucleanTest test), e apagabuild/reports/jacocoebuild/jacoco/*.execna dúvida; o gerado sai do denominador (**/*$Introspection*,**/*$Definition*,**/*$IntrospectionRef*,**/*$Intercepted*,**/Serde*,**/gg/jte/generated/**). A task que lê o XML declara oProvidercomovallocal e soma o último<counter>de cada tipo.
Build native¶
A regra — native por padrão, jar com motivo, build.targets como discriminador — é da
constituição; o caminho do jar está em
Fallback JVM. O Dockerfile é o dockerfile-java-native do kit.
import org.graalvm.buildtools.gradle.dsl.GraalVMExtension // no topo do build.gradle.kts
configure<GraalVMExtension> { // o accessor graalvmNative {} nem sempre é gerado
binaries.named("main") {
imageName.set("application") // o COPY do Dockerfile.native espera este nome
buildArgs.add(if (project.hasProperty("nativeQuick")) "-Ob" else "-Os")
buildArgs.add("--gc=serial")
buildArgs.add("-H:IncludeLocales=pt-BR") // a GraalVM embarca só "en"
buildArgs.add("-march=compatibility") // o default x86-64-v3 exige a CPU do runner
}
}
- Community Edition: sem
--gc=G1, PGO nem build-report;-O3equivale a-O2.-PnativeQuick(-Ob) é para o loop de dev e e2e. - Locale pt-BR: app native que formata em pt-BR declara
-H:IncludeLocales=pt-BR(opipeline.ymlreprovaLocale.of("pt")/forLanguageTag("pt-BR")sem a flag), e o e2e native afirma1.234,56no binário. -march=compatibility: o default do native-image éx86-64-v3(AVX2 e as demais instruções da máquina de build); servidor sem elas morre comIllegal instructionem runtime, sem aviso no build. O custo é de poucos por cento de desempenho.- glibc do builder ≤ a do runtime: o binário exige a glibc com que foi compilado; para rodar numa
glibc mais velha, o builder vira o
ol8(glibc 2.28). - Reflexão se auto-gera: as views pela extensão do JTE; Sentry e
version.propertiespela metadata daxadm-comum-web. Recurso próprio lido porgetResourceAsStreamregistraresource-config. Factory JAXP usanewDefaultInstance(), nãonewInstance(). @Requiresem@Controller: controller de contrato de plataforma (/health,/info) é incondicional. Controller ligado por segredo usa@Requires(property=…, pattern=".+"), que é avaliado em runtime no native. Podam no AOT, medido:missingBeanse property com valor conhecido no build. Todo@Controllercom@Requirestem a rota provada no e2e native.- O AOT pode podar rota registrada (404 no native, 200 no jar): o e2e native afirma as rotas-chave, e em produção o 404 autenticado vai ao GlitchTip.
- O
nativeCompileé CC-safe — não passe--no-configuration-cache. - Quem builda é o
pipeline.yml, no jobbuild_native, num runner fora do host de produção; o Coolify só puxa (Operação no Coolify). O gate do repo do app é onativeCompilecompilar; o runtime é provado pelo e2e native. O job libera disco do runner antes (sobram cerca de 25 GB) e usa cachemode=minsem cache-dance:mode=maxexporta os stages multi-GB do GraalVM e estoura o timeout. - O
nativeCompileroda antes da tag: o pré-flight da/xadm-releasede app native builda oDockerfile.nativenum container limpo, que resolve toda dependência pelo registro (semmavenLocal).
Deploy — o Dockerfile¶
App deployável tem Dockerfile (e Dockerfile.native), .dockerignore e .gitattributes no repo desde
o primeiro commit de código, derivados do kit — gradlew text eol=lf impede o ./gradlew: not found de
checkout Windows. A imagem é buildada pelo pipeline.yml; o gate de PR não builda imagem, e o pré-flight
da /xadm-release roda o docker build como gate de container.
- Multi-stage:
temurin:<v>-jdk-alpinebuilda,temurin:<v>-jre-jammyroda — Jammy e não Alpine, porque o Temurin Alpine tem suporte irregular e arrisca lib JNI (o native roda emdebian:12-slim). Build-only: teste de integração roda no jobgate, não no Dockerfile. - Nome do artefato:
app.jarsó noshadowJardo fallback JVM (archiveFileName.set("app.jar"), nunca nojarbase); CLI publica<app>-<versão>.jar. - O app não escreve no próprio
WORKDIR: diretório que o app cria sob/app(log, cache, upload) nasce comRUN mkdir -p /app/<x> && chown app:app /app/<x>antes doUSER app— nos dois Dockerfiles; opipeline.ymlreprova a divergência. Nuncachowndo/appinteiro: o app poderia sobrescrever o próprio jar. Servidor loga em stdout (Java). HEALTHCHECK+curlcontra o/healthflat e anônimo; liveness, nunca readiness. Cadência de boot longo:start-period5s,interval15s,retries15.tinicomo PID1 (ENTRYPOINT ["/usr/bin/tini", "--", …]): colhe ocurlórfão do healthcheck e repassa sinais. Opipeline.ymltrava a regressão.- JVM:
-XX:MaxRAMPercentage=75(o default, cerca de 25% da RAM, desperdiça heap) e-XX:+ExitOnOutOfMemoryError(o container morre e o Coolify reinicia, em vez de travar em GC). - Versões: o JDK e o
FROMbatem comtoolchain.javadodocs/app.json; digest@sha256:e imagem base são do repo, e placeholder (<DIGEST>,<slug>) no Dockerfile é reprovado pelopipeline.yml. O digest é o do índice multi-arch (docker buildx imagetools inspect <imagem>:<tag>) — o dodocker pullé o da plataforma do host — e se renova ao subir a versão da imagem. - Cache mount do Gradle:
id=gradle-<slug>esharing=locked, senão todos os apps dividem um cache e deploys concorrentes travam no lock do journal. No CI do jar o cache tem duas metades — oactions/cachepersiste o diretório e obuildkit-cache-danceo injeta no mount, com oiddo cache-map igual ao doRUN --mount; faltando uma, o mount nasce frio. .dockerignorenão é.gitignore:*não cruza/, então**/build;*.jarfica só na raiz (senão sai ogradle-wrapper.jar); exceção!docs/app.jsonvem depois dedocs.- No native, o
SENTRY_DSNvem por env: oENTRYPOINTé o binário, sem shell.
Fallback JVM¶
Jar só se declara com motivo registrado:
- (a) bloqueio de native medido, em ADR do próprio app (biblioteca hostil ao AOT, falta de e2e native);
- (b) host de runtime sem a arquitetura do binário native (ARM).
O repo mantém o Dockerfile JVM — a guarda de paridade de diretórios continua valendo — e os recursos
*-jar-pull do Coolify ficam parados. Religar a perna JVM é release com jar no build.targets, que
builda imagem nova; nunca se religa o recurso parado com a imagem antiga. App com jar no build.targets
descomenta o build e o deploy jar do pipeline.yml, que vêm comentados com esse rótulo.
Armadilhas¶
| Sintoma | Causa | Cura |
|---|---|---|
ClassCastException no StylesTable, só no native |
Apache POI lê XLSX por XMLBeans (reflexão) | FastExcel no main; POI só em teste (o pipeline.yml reprova POI em app native) |
nativeCompile quebra; a JVM passa |
@Info(title=…) com acento vira nome de recurso em META-INF/swagger/ |
title/name em ASCII; description em pt-BR livre |
| Consumidor recebe chave ausente e estoura NPE | micronaut.serde.serialization.inclusion = NON_EMPTY omite coleção, mapa e string vazios |
DTO de contrato com @JsonInclude(ALWAYS); o consumidor coalesce mesmo assim |
| Token ou URL de config sai lixo, sem erro | placeholder aninhado ${A:${B}} — o resolver casa o primeiro } |
nunca aninhe no application.yaml; rename é dual-read em código |
Default com URL perde o https: |
default com : sem crases é re-parseado como nova expressão |
crases: ${X:`https://…`} (o pipeline.yml bloqueia :// sem crase) |
| Bean sobe e responde 500 no primeiro request | @Requires(property=X, notEquals="") passa com a property ausente |
@Requires(property=X, pattern=".+") |
Config de @ConfigurationProperties ignora override |
objeto criado com new não é bindado |
injete pelo construtor |
| Pool roda no default de 10, não no tamanho do yml | datasources.default.hikari.* é ignorado em silêncio: o DatasourceConfiguration do micronaut-jdbc-hikari estende o HikariConfig e lê as chaves direto no datasource |
chaves planas: datasources.default.maximum-pool-size, datasources.default.minimum-idle (o pipeline.yml reprova o sub-bloco) |
| App sobe com config de dev | java -jar deduz o ambiente dev |
MICRONAUT_ENVIRONMENTS explícito ou MICRONAUT_ENV_DEDUCTION=false |
| N testes falham no start do servidor | micronaut.http.services.<id>.url vazia cria bean eager |
destino opcional via @Client("${<app>.url}") + BeanProvider |
| Handler não roteia; subclasse não compila | método executável private/package-private |
public (em lib, é contrato) |
415 no submit de <form> |
@Post assume JSON |
@Consumes explícito (guarda ESCRITA_DECLARA_CONSUMES) |
406 no browser numa rota @View |
o default do controller é application/json |
@Produces(MediaType.TEXT_HTML) |
| 500 só em algumas views | ViewModelProcessor faz put num Map.of |
copiar para LinkedHashMap e setModel |
| Rota que a regra de view fecharia abre para anônimo | @Secured(IS_ANONYMOUS) na classe: a SecuredAnnotationRule (ordem −200) decide antes do intercept-url-map (−100) e das regras próprias (0), e a primeira que decide encerra |
anônimo só no método; teste com a auth ligada esperando 401 (Segurança) |
PUT numa API externa apaga itens |
PUT substitui a coleção |
read-modify-write |
Listagem paginada responde 500 com missing FROM-clause entry |
@Query com Pageable: o micronaut-data acrescenta o ORDER BY qualificado com o alias da entidade (processamento_xls_), que o FROM não declara |
declare o alias no FROM (FROM xls_processamento processamento_xls_) |
Listagem de tela responde 500 com ?sort= na URL |
o Pageable bindado da query aceita qualquer propriedade, e uma inexistente quebra a consulta |
pageable.withoutSort() na listagem de tela, com a ordem fixa na query |
@Query de escrita com WITH … RETURNING falha com Um resultado foi retornado quando nenhum era esperado |
com readOnly = false o micronaut-data executa por executeUpdate |
decida pela contagem de linhas afetadas, sem RETURNING |
500 e registro preso em ENVIANDO |
URI relativa (base de config vazia) no HttpRequest.newBuilder |
valide a URI antes; catch largo com estado terminal |
| Dados cruzados entre apps num provider | recurso nomeado sem escopo | nomeie pelo app_id |
| Ordem de filtro errada só no fat jar | o @Filter (HttpServerFilter) ignora @Order |
@ServerFilter |
Failed to inject … jsonMapper no fim da suíte |
teardown do contexto compartilhado | ruído — suíte verde não se caça |
initializationError em massa, service not available |
Test Resources morto e estado do Gradle apontando a porta antiga | ./gradlew --stop && rm -rf .micronaut/test-resources .gradle/configuration-cache |
check pendura por minutos |
containers órfãos (reuse ligado, SIGKILL repetido) | docker rm -f $(docker ps -aq --filter "label=org.testcontainers=true"), à mão |
startScripts/build quebra no Gradle 9.5 |
jar base e shadowJar com o mesmo app.jar |
app.jar só no shadowJar |
Timeout waiting to lock journal cache no build da imagem |
cache mount do Gradle sem id por app e sharing=locked |
--mount=type=cache,id=gradle-<slug>,sharing=locked |
Unresolved reference 'time' no build.gradle.kts |
java.time.Duration escrito qualificado: no Kotlin DSL, java resolve para a extensão java {} do Gradle |
import java.time.Duration no topo do script |
| Task quebra só com configuration cache | a task lê project.version (ou outra propriedade do projeto) dentro do doLast |
capture o valor na fase de configuração |
Garage rejeita o PUT com Invalid payload signature |
o AWS SDK v2 ≥ 2.30 calcula checksum CRC32 por padrão | requestChecksumCalculation e responseChecksumValidation = WHEN_REQUIRED |
| Conflito de Netty no classpath do app | o s3 do SDK v2 puxa o netty-nio-client |
exclua o módulo e use o url-connection-client (síncrono) |
Startup cai com AccessDenied no createBucket |
a key por app do Garage não cria bucket; quem provisiona é o central-backend |
garage.criar-bucket: false (o default) |
Valor lido num static final sai errado só no native |
o inicializador estático pode rodar no build da imagem | leia em tempo de execução, dentro do método |
Advisory lock do @Scheduled fica preso |
sem contexto de conexão, lock e unlock pegam conexões diferentes do pool, e o lock é da sessão | @Connectable no método do tick |
| Regra anti-vácuo do ArchUnit falha com failed to check any classes | o failOnEmptyShould dispara antes da mensagem da regra |
allowEmptyShould(true) nessa regra |
| Regra "nada depende de controller" reprova sem violação real | o micronaut-serde gera Serde<Controller>_<Dto>Serializer para o DTO aninhado no controller, só com @Prototype: sem @Generated e sem $ no nome |
RegrasArquitetura.nadaDependeDeController(), que já tira o código gerado da origem; em regra própria, tire assignableTo(Serializer.class) e assignableTo(Deserializer.class) |
| Testcontainers não acha o Docker (client version 1.32 is too old) | o Docker Engine 29 exige API ≥ 1.44 | Testcontainers ≥ 1.21.4 (linha 1.x) ou ≥ 2.0.2 |
| Suíte lenta, Postgres recriado a cada classe de teste | jdbc:tc: sem TC_DAEMON=true: o container para quando o Micronaut fecha o pool no fim da classe |
o singleton da xadm-comum-teste |
Classe de teste para de compilar ao adotar o PostgresTestPropertyProvider, ou o get(…) do WireMock deixa de resolver |
o TestPropertyProvider estende Supplier<Map<String, String>> e herda um get(), que colide com o get() da classe e esconde o import static WireMock.get |
renomeie o método da classe; chame WireMock.get(…) qualificado |
garage node id falha logo depois do start do container |
o Wait.forListeningPort() libera antes de a CLI responder |
retry no comando |
| Encurtar um Javadoc reprova o checkstyle | o UnusedImports com processJavadoc=true conta {@link} como uso |
apague o import junto |
./gradlew --stopderruba todos os daemons da versão, inclusive os do IDE: é gesto manual, nunca passo de harness. Harness que chamagradlewcom Testcontainers encerra só o daemon com identificação positiva — nascido na janela da chamada, pai morto (ou PID reciclado) e com filho apontando para obuild/do repo da chamada —, filhos primeiro, reconferindo PID e hora de criação. Container não se mata à mão: o Ryuk o recolhe quando a conexão cai.