Pular para conteúdo

0022 — Organização package-by-feature

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-18 · Decidido em: 2026-06-18

Contexto

O código nasceu package-by-layer: pacotes globais api/, service/, repository/, domain/, security/, dto/, exception/ — cada um juntando arquivos de funcionalidades diferentes. Ao consolidar o padrão Micronaut da casa (skill micronaut, decisão de Estrutura de código), o default ficou sendo package-by-feature: cada funcionalidade carrega suas próprias camadas, e o que é transversal vai para uma base comum. Este app era o outlier; como ele deve seguir o default da casa salvo divergência justificada (§1.6), migra-se.

Decisão

Package-by-feature com camadas Controller → Service → Repository dentro de cada feature. Pacotes de topo sob br.com.vantroba.bi.transporte:

Pacote Papel
processamento pipeline XLS ponta a ponta: controllers, services, repositórios, entidades bi_*/xls_*, storage; subpacote processamento.processing — leitura Excel SAX (POI streaming) e mapeamento de colunas
powersync reset do PowerSync e o que orbita o sinal de reset
configuracao configuração global runtime (admin)
seguranca as duas portas de auth — Bearer estático (/api/**) + login Google das views, handlers de rejeição
comum base transversal, não é feature: comum.config (typed config, modos de storage), comum.exception (só StorageIndisponivelException, 503 do domínio XLS), comum.util, comum.notificacao (Telegram). A infra web (RFC 7807 ProblemDetail + processor, exceções HTTP base, health, Sentry, versão) migrou para a lib br.com.xadm.comum.web — decisão 0024

Regras:

  • Entidade (@MappedEntity) não cruza o controller. A borda HTTP fala DTO (record + @Serdeable) na API ou view-model no Thymeleaf.
  • Injeção por construtor; service concentra regra de negócio e @Transactional; repository só acessa dado (Micronaut Data JDBC, decisão 0001).
  • comum não depende de feature — sem exceção. O Telegram fica partido em transporte (comum.notificacao.TelegramService, recebe texto pronto) e formatação (um notificador por feature: ProcessamentoNotificador, ResetarPowersyncNotificador), então quem conhece o domínio é a feature, não comum.

Trava: ArchUnit (divergência consciente do default da casa)

A skill da casa diz que a governança das camadas é convenção + revisão de PR + ./gradlew check, e descarta ArchUnit. Este app mantém o ArchitectureTest (que já existia para o layout antigo), reescrito para três invariantes do package-by-feature:

  1. sem ciclos entre as features de topo (a invariante central);
  2. nenhum subpacote de comum (inclusive comum.notificacao) depende de feature;
  3. processamento.processing não depende da camada HTTP (nada que termine em Controller) — a leitura do Excel não conhece HTTP.

Mantido porque o teste já estava no projeto (custo zero), roda em ./gradlew check (não é dependência nova) e trava exatamente as invariantes que importam no package-by-feature. É divergência declarada do default da casa → registrada como feedback §6 para a constituição reavaliar o "ArchUnit descartado" categórico.

Consequências

  • O código de uma feature fica junto — some o vai-e-volta entre seis pastas de camada conforme o app cresce.
  • comum vira a fronteira explícita do que é transversal; o ArchUnit impede que ela volte a depender de feature por descuido.
  • Migração mecânica (mover arquivo + ajustar package/imports), sem mudança de comportamento: 108 classes main + 44 de teste movidas, suíte verde (293 testes, ArchUnit e checkstyle limpos).

Alternativas consideradas

  • Manter package-by-layer: era o estado do app; divergente do default da casa, dispersa os arquivos de uma mesma funcionalidade. Descartado.
  • Remover o ArchUnit para alinhar 100% à casa: descartado por ora — o teste já existia e trava as invariantes do package-by-feature de graça. A reavaliação fica para o §6 (se a casa mantiver "sem ArchUnit", removê-lo aqui é trivial).