Pular para conteúdo

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 em comum/{config,exception,util,…}. Repo novo não nasce em package-by-layer.
  • Injeção por construtor: @Singleton + dependências no construtor, nenhum @Inject em campo. Dependência opcional é @Nullable embrulhada em Optional; 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 @Serdeable com sufixo *Request/*Response/*View (@Serdeable é allowlist de segurança: todo DTO HTTP leva); entidade é @MappedEntity com @MappedProperty snake_case explícito; a tela recebe view-model, nunca a entidade do banco.
  • Config: bloco coeso vira @ConfigurationProperties (classe …Settings com javadoc mapeando cada env), construtor-injetado; valor avulso vira @Value("${x.y:default}") no construtor. O comentário no application.yml explica 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 base br.com.xadm.
  • Rodar em dev: ./gradlew run com MICRONAUT_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; comum nã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 @View ou @Produces(TEXT_HTML)), todo @Post/@Put/@Patch declara @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() da xadm-comum-teste, ou assertThat(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 pelo pipeline.yml; o CI avisa quando a suíte não tem a asserção. A guarda não vê versão herdada — a xadm-comum-teste exporta o ArchUnit por api, e quem cobre é o piso da lib —, e hasNext/isEmpty soltos não contam como defesa.
  • Constante inlinada é invisível: dependência que existe só por public static final some 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 sem allowEmptyShould(true) (reservado à anti-vácuo). Varra o literal com grep -rl "<pacote>" e prove que a guarda ainda morde quebrando uma regra de propósito.
  • Checkstyle: as regras são campos static final ArchRule em camelCase; o suppressions.xml da casa já suprime ConstantName no teste — não relaxe o checkstyle.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 de Exception crua na borda.
  • O corpo RFC 7807 é o processor da xadm-comum-web; o app não escreve processor. Exceção de domínio implementa PortadoraDeProblema: o code de máquina e o title do tipo vêm dela, o específico da ocorrência vai no detail, e o code é uniforme também nas falhas de Bean Validation. Fluxo que exige resposta especial (view → 302 para o login) ganha ExceptionHandler dedicado.
  • Chamada externa num fluxo com estado: o catch em volta dela pega também RuntimeException inesperada 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 ERROR vai 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 da xadm-comum-web cumpre o invariante — o app não loga de novo o que ele já logou.

Observabilidade (GlitchTip/Sentry)

  • O DSN é lido pelo SentryInitializer da xadm-comum-web (env SENTRY_DSN); o logback.xml declara o SentryAppender sem <dsn>, com <minimumEventLevel>ERROR</minimumEventLevel> e appender-ref no <root>: todo log ERROR, inclusive exceção não-tratada, chega ao GlitchTip sem código no negócio. DSN vazio é no-op. O nome é SENTRY_DSN em toda stack, porque é o que o SDK lê.
  • A metadata native do Sentry vem da xadm-comum-web; o app não carrega reflect-config de 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 por javap (Ferramentas).
  • Rota /test/glitchtip (opt-in, @Secured(SecurityRule.IS_AUTHENTICATED)): dispara um LOG.error("teste GlitchTip <app_id>", new RuntimeException("smoke-test")) e responde 200 — confirma em produção que o erro chega. O /xadm-setup oferece o scaffold.

Banco (Postgres compartilhado)

  • Persistência é micronaut-data-jdbc, obrigatória: @JdbcRepository(dialect = POSTGRES) e @Transactional; sem Hibernate/JPA; SQL cru via DataSource.getConnection() como camada de persistência é proibido (bulk à mão num @Singleton é permitido, abaixo). O annotationProcessor é obrigatório — sem ele @Transactional vira 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:

  • /health e /info: use o HealthController/InfoController da xadm-comum-web e desligue os de management (endpoints.health.enabled: false, endpoints.info.enabled: false). O contrato está em Versionamento.
  • Storage é a xadm-comum-storage: injete o S3Client que ela expõe (ou o ponto de injeção ObjetoStorage, para gravar e ler objeto), configure garage.* 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: AUTO no application.yaml: o default MANUAL roda 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 o thread-selection do adotante: Bibliotecas da casa.
  • Listagem de tabela que cresce recebe Pageable (page/size) e devolve o Page/Slice do micronaut-data como está (cobrado em review), sem DTO de paginação próprio; a tela pagina pelo kit/pager.jte do kit de UI. O micronaut.data.pageable.default-page-size é declarado: sem ele, o default é o max-page-size (100). Teste de forma sem contexto usa ObjectMapper.create(Map.of(), "io.micronaut.data.model"): o getDefault() não carrega o PageSerializer e escreve o Page como array.
  • Auth no micronaut-security nativo (AuthenticationFetcher + SecurityRule + ExceptionHandler), nunca um @Filter paralelo; 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 no application.yaml + ${<APP>_CORS_EXTRA_ORIGINS:}. O gap só aparece no browser (failed to fetch); curl e 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 é @Client declarativo + @Retryable, com a interface vinda do módulo <svc>-api que 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 por BeanProvider.
  • 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ó em testImplementation, para gerar fixture. Abra com new ReadableWorkbook(is, new ReadingOptions(true, false)): sem o true o 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 token y/d/h/s no getDataFormatString(); a leitura isola-se numa camada que produz tipo neutro (String[] por linha).
  • O OpenAPI gerado é o oráculo da rota: com micronaut-openapi no annotationProcessor, o pipeline.yml roda o checa-rotas.py depois do check e 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ção micronaut.views.jte.dynamic=false (o default).
  • Controller: @View("admin/clienteForm") + model Map/POJO; o model casa nos @param do template por nome. Rota @View leva @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 NativeResourcesExtension gera o registro de reflexão de todas as views; lista à mão de Jte*Generated é proibida — view nova esquecida é podada no AOT e responde 404 só no native, só quando recebe tráfego. O pipeline.yml reprova app JTE + native sem a extensão.
  • Helpers da casa: Formata da xadm-comum-util (${Formata.data(x, "dd/MM/yyyy")}, ${Formata.trim(s)}, null-safe); XadmViewModel da xadm-seguranca, que injeta os globais do layout (opt-in por xadm.views.app-nome).
  • Armadilhas de build: o gg.jte 3.2.4 não tem writeUserContent(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-map decide 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ê os href/src do kit/layout.jte e faz GET sem 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.jte e o processor local de appVersao do app, no mesmo PR. A appVersao vem da lib, e o kit/headerRight.jte a 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, implementa PostgresTestPropertyProvider, com @TestInstance(Lifecycle.PER_CLASS) (sem ele as props não entram). O PostgresTestResource sobe um postgres:18-alpine por 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 declara datasources.default.url nem porta — e .withReuse(true) é proibido.
  • Limpeza entre testes pela lib: o banco sobrevive entre classes, então a classe de teste estende IntegracaoComPostgres da xadm-comum-teste, que já traz o PostgresTestPropertyProvider e o @TestInstance(PER_CLASS), ou chama PostgresTestResource.limparTabelas() no próprio @BeforeEach. A limpeza é TRUNCATE … RESTART IDENTITY CASCADE por 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 @BeforeEach da 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: sem TC_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 de clean ou 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, com import java.time.Duration no topo do build.gradle.kts; junit.jupiter.execution.timeout.default = 2 m e junit.jupiter.execution.timeout.mode = disabled_on_debug em src/test/resources/junit-platform.properties (não cobre a subida do contexto, que o timeout da task cobre); e datasources.default.connection-timeout: 5000 com maximum-pool-size: 4 no application-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 em withCommand("postgres", "-c", "wal_level=logical", …)) ou timeline de migração (Flyway até um target, dado anterior semeado, depois a versão seguinte).
  • DDL de teste por JDBC (DriverManager + Statement.execute), não withInitScript, cujo shading quebra entre versões.
  • Compose com Postgres 18+ monta o volume em /var/lib/postgresql — no caminho /data o 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 emite numFmt (POI no testImplementation, ou arquivo do Excel), lido pelo reader com ReadingOptions(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ão if (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, um RCPT TO: por destinatário, dot-stuffing, 5xx e porta fechada.
  • Teste de endpoint semeia no @BeforeEach/@Sql, nunca no corpo do @Test: o @MicronautTest embrulha o teste numa transação com rollback, e o servidor embutido atende em outra conexão. Pipeline @Async que 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 o pg_advisory_lock(hashtext('<nome>')) e chame a guarda injetada esperando coalesce. O fim da sessão rival solta o lock de forma assíncrona: capture pg_backend_pid() da rival e, depois do close(), espere (com prazo) pg_locks sem advisory desse pid antes de acionar o relay.
  • View controller tem teste @Client que faz GET, espera 200 e afirma um trecho do HTML: o check não renderiza template sem ele.
  • Integração que depende de Docker pula só com Docker ausente e aviso alto (uma ExecutionCondition do JUnit sobre o DockerClientFactory.instance().isDockerAvailable(), com log no skip); o disabledWithoutDocker do @Testcontainers cala, e integração atrás de flag (-PdockerTests) é reprovada pelo pipeline.yml.
  • Porta efêmera em todo servidor que o teste sobe (micronaut.server.port: -1); a URL vem de EmbeddedServer.getURI() ou @Client("/"), nunca localhost:8080 (agentes).
  • Fake com @Replaces no source-set de teste vaza para todos os @MicronautTest: use @MockBean, ou @Requires(property=…) no fake + @Property na 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 Clock pelo construtor, com Clock.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 (ou cleanTest test), e apaga build/reports/jacoco e build/jacoco/*.exec na dúvida; o gerado sai do denominador (**/*$Introspection*, **/*$Definition*, **/*$IntrospectionRef*, **/*$Intercepted*, **/Serde*, **/gg/jte/generated/**). A task que lê o XML declara o Provider como val local 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; -O3 equivale 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 (o pipeline.yml reprova Locale.of("pt")/forLanguageTag("pt-BR") sem a flag), e o e2e native afirma 1.234,56 no 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 com Illegal instruction em 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.properties pela metadata da xadm-comum-web. Recurso próprio lido por getResourceAsStream registra resource-config. Factory JAXP usa newDefaultInstance(), não newInstance().
  • @Requires em @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: missingBeans e property com valor conhecido no build. Todo @Controller com @Requires tem 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 job build_native, num runner fora do host de produção; o Coolify só puxa (Operação no Coolify). O gate do repo do app é o nativeCompile compilar; o runtime é provado pelo e2e native. O job libera disco do runner antes (sobram cerca de 25 GB) e usa cache mode=min sem cache-dance: mode=max exporta os stages multi-GB do GraalVM e estoura o timeout.
  • O nativeCompile roda antes da tag: o pré-flight da /xadm-release de app native builda o Dockerfile.native num container limpo, que resolve toda dependência pelo registro (sem mavenLocal).

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-alpine builda, temurin:<v>-jre-jammy roda — Jammy e não Alpine, porque o Temurin Alpine tem suporte irregular e arrisca lib JNI (o native roda em debian:12-slim). Build-only: teste de integração roda no job gate, não no Dockerfile.
  • Nome do artefato: app.jar só no shadowJar do fallback JVM (archiveFileName.set("app.jar"), nunca no jar base); 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 com RUN mkdir -p /app/<x> && chown app:app /app/<x> antes do USER app — nos dois Dockerfiles; o pipeline.yml reprova a divergência. Nunca chown do /app inteiro: o app poderia sobrescrever o próprio jar. Servidor loga em stdout (Java).
  • HEALTHCHECK + curl contra o /health flat e anônimo; liveness, nunca readiness. Cadência de boot longo: start-period 5s, interval 15s, retries 15.
  • tini como PID1 (ENTRYPOINT ["/usr/bin/tini", "--", …]): colhe o curl órfão do healthcheck e repassa sinais. O pipeline.yml trava 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 FROM batem com toolchain.java do docs/app.json; digest @sha256: e imagem base são do repo, e placeholder (<DIGEST>, <slug>) no Dockerfile é reprovado pelo pipeline.yml. O digest é o do índice multi-arch (docker buildx imagetools inspect <imagem>:<tag>) — o do docker pull é o da plataforma do host — e se renova ao subir a versão da imagem.
  • Cache mount do Gradle: id=gradle-<slug> e sharing=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 — o actions/cache persiste o diretório e o buildkit-cache-dance o injeta no mount, com o id do cache-map igual ao do RUN --mount; faltando uma, o mount nasce frio.
  • .dockerignore não é .gitignore: * não cruza /, então **/build; *.jar fica só na raiz (senão sai o gradle-wrapper.jar); exceção !docs/app.json vem depois de docs.
  • No native, o SENTRY_DSN vem por env: o ENTRYPOINT é 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 --stop derruba todos os daemons da versão, inclusive os do IDE: é gesto manual, nunca passo de harness. Harness que chama gradlew com Testcontainers encerra só o daemon com identificação positiva — nascido na janela da chamada, pai morto (ou PID reciclado) e com filho apontando para o build/ 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.