Fronteiras que o CI garante: import-linter, Spring Modulith e internal/ no Go
Fronteira de módulo que só existe no diagrama não sobrevive ao primeiro prazo apertado. Como fazer o build quebrar quando alguém cruza uma fronteira, em Python, Java e Go.
Publicado em · Read in English
TL;DR
- Em Python, o import-linter transforma regras de camada e de fronteira em contratos que reprovam o PR.
- Em Java, o Spring Modulith trata cada pacote de topo como módulo, esconde os subpacotes e verifica tudo num teste de uma linha.
- Em Go, o diretório
internal/já é uma fronteira garantida pelo compilador. Para o grafo de dependências entre módulos, um teste de 60 linhas comgo listresolve.
Introdução#
No post anterior desenhei as fronteiras de uma plataforma escolar com DDD estratégico. Agora vem o problema prático: como impedir que elas se desfaçam.
Todo monolito modular começa limpo. Seis meses depois, alguém precisa de um dado de outro módulo, o prazo está apertado e a classe interna está ali, a um import de distância. Um ano depois, o “monolito modular” é um monolito comum com pastas bonitas.
Revisão de código não segura isso de forma confiável, e com agentes de IA escrevendo parte do código fica ainda mais difícil: o agente encontra o caminho mais curto até a classe que resolve, e ele quase nunca passa pela fachada. A única fronteira que sobrevive é a que quebra o build.
Vou mostrar a mesma ideia em três linguagens, com as ferramentas mais idiomáticas de cada uma.
Pré-requisitos#
- Ter lido os posts anteriores da série, ou entender o que é um monolito modular
- Python 3.12+ com Poetry, JDK 21+ com Gradle, e Go 1.23+ (só os que for rodar)
Conceito: o que uma fronteira precisa garantir#
Independente da linguagem, um módulo bem isolado tem três regras:
- Superfície pública explícita. Outros módulos só enxergam uma fachada (API, eventos, DTOs). O resto é interno.
- Grafo de dependências declarado. Fica escrito quais módulos podem depender de quais, e não há ciclos.
- Camadas internas (opcional, mas comum). Dentro do módulo, o domínio não depende de framework.
Cada ferramenta abaixo cobre essas regras de um jeito diferente. O que muda é quanto vem de graça.
Mão na massa#
git clone https://github.com/dev-danilocordeiro/monolito-modularcd monolito-modularCada linguagem tem a sua pasta: python/, java/ e go/. O código Java deste post está na tag passo-0 (git checkout passo-0), porque o main já está no fim da extração que o último post da série faz.
Python: import-linter#
Python não tem nenhuma noção de visibilidade entre pacotes; tudo é importável. O import-linter resolve isso com contratos declarados num arquivo de configuração.
poetry add --group dev import-linterA estrutura segue a do smarthome-hub: cada módulo tem um public.py e camadas internas.
escola/ modules/ matricula/ public.py # a única coisa que outros módulos importam api/ application/ domain/ infrastructure/ turmas/ public.py api/ application/ domain/ infrastructure/Três tipos de contrato cobrem as três regras:
[importlinter]root_package = escola# Necessário para o contrato dominio-puro, que proíbe pacotes externosinclude_external_packages = True
# Regra 3: camadas dentro de cada módulo[importlinter:contract:camadas]name = Cada módulo é api -> infrastructure -> application -> domaintype = layerslayers = api infrastructure application domaincontainers = escola.modules.matricula escola.modules.turmas
# Regra 3: domínio puro[importlinter:contract:dominio-puro]name = Domínio não importa frameworktype = forbiddensource_modules = escola.modules.*.domainforbidden_modules = fastapi sqlalchemy pydantic
# Regra 1: só pela fachada[importlinter:contract:fronteira-matricula]name = matricula só alcança turmas pelo public.pytype = forbiddensource_modules = escola.modules.matriculaforbidden_modules = escola.modules.turmas.api escola.modules.turmas.application escola.modules.turmas.domain escola.modules.turmas.infrastructureallow_indirect_imports = TrueO include_external_packages = True não é opcional aqui: sem ele, o import-linter nem chega a avaliar os contratos e para com The top level configuration must have include_external_packages=True when there are external forbidden modules, porque fastapi, sqlalchemy e pydantic estão fora do escola.
E para a regra 2 (grafo sem dependência de volta):
[importlinter:contract:turmas-nao-conhece-matricula]name = turmas não depende de matriculatype = forbiddensource_modules = escola.modules.turmasforbidden_modules = escola.modules.matriculapoetry run lint-importsCada módulo é api -> infrastructure -> application -> domain KEPTDomínio não importa framework KEPTmatricula só alcança turmas pelo public.py KEPTturmas não depende de matricula KEPT
Contracts: 4 kept, 0 broken.O allow_indirect_imports = True merece atenção: sem ele, matricula → turmas.public → turmas.domain também conta como violação, porque o contrato forbidden olha a cadeia inteira por padrão. Como a fachada precisa importar o próprio domínio, você quer barrar só o import direto. Sem a opção, o lint reprova o código correto:
escola.modules.matricula is not allowed to import escola.modules.turmas.domain:- escola.modules.matricula.public -> escola.modules.turmas.public (l.6) escola.modules.turmas.public -> escola.modules.turmas.domain.turma (l.6)Java: Spring Modulith#
O Spring Modulith parte de uma convenção: cada pacote direto abaixo do pacote da aplicação é um módulo, e seus subpacotes são internos por padrão.
src/main/java/escola/ EscolaApplication.java matricula/ # módulo; tipos públicos aqui são a API MatriculaService.java MatriculaConfirmada.java internal/ # invisível para outros módulos MatriculaRepository.java turmas/ TurmasService.java internal/ Turma.javadependencies { implementation(platform("org.springframework.modulith:spring-modulith-bom:2.1.1")) implementation("org.springframework.modulith:spring-modulith-starter-core") testImplementation("org.springframework.modulith:spring-modulith-starter-test")}O exemplo usa Spring Boot 4.1.1 com Spring Modulith 2.1.1, a combinação estável mais recente: o Modulith 2.1 é compilado contra o Boot 4.1. Se o seu projeto ainda está no Boot 3, a linha correspondente é o Modulith 1.x.
A verificação é um teste:
package escola;
import org.junit.jupiter.api.Test;import org.springframework.modulith.core.ApplicationModules;import org.springframework.modulith.docs.Documenter;
class ModularidadeTest {
ApplicationModules modulos = ApplicationModules.of(EscolaApplication.class);
@Test void verificaFronteiras() { modulos.verify(); // ciclos e acesso a tipos internos reprovam o teste }
@Test void geraDocumentacao() { new Documenter(modulos).writeDocumentation(); // diagramas C4 em PlantUML }}Se matricula usar escola.turmas.internal.Turma, o verify() falha apontando a dependência para um tipo não exposto do módulo turmas:
org.springframework.modulith.core.Violations: - Module 'matricula' depends on non-exposed type escola.turmas.internal.Turma within module 'turmas'!Field <escola.matricula.Atalho.turma> has type <escola.turmas.internal.Turma> in (Atalho.java:0)Ele também detecta ciclos entre módulos (Cycle detected).
Para a regra 2, o grafo explícito, declare as dependências permitidas no package-info.java:
@org.springframework.modulith.ApplicationModule( allowedDependencies = "turmas")package escola.matricula;Agora matricula depender de qualquer módulo além de turmas também reprova o teste, com Module 'matricula' depends on module 'infra' via ... Allowed targets: turmas. E quando um módulo precisa expor mais de um pacote, @NamedInterface nomeia a superfície (allowedDependencies = "turmas :: api").
Go: internal/ e um teste com go list#
Go é a única das três linguagens em que a fronteira principal vem do compilador. Um pacote dentro de um diretório internal/ só pode ser importado por código com raiz no diretório pai de internal/.
O truque é aninhar:
go/ go.mod # module github.com/dev-danilocordeiro/monolito-modular/go cmd/api/main.go internal/ matricula/ matricula.go # API pública do módulo internal/ store/store.go # só matricula enxerga turmas/ turmas.go internal/ store/store.go # só turmas enxerga financeiro/ financeiro.go internal/ store/store.go # só financeiro enxerga archtest/arch_test.goO internal/ de fora esconde os módulos do mundo externo ao Go module. O internal/ de dentro esconde a implementação de cada módulo dos outros módulos. Se matricula tentar importar internal/turmas/internal/store, o build falha. Nenhuma ferramenta extra:
package github.com/dev-danilocordeiro/monolito-modular/go/internal/matricula internal/matricula/violacao.go:3:8: use of internal package github.com/dev-danilocordeiro/monolito-modular/go/internal/turmas/internal/store not allowedO compilador também já proíbe ciclos de import. O que falta é a regra 2: declarar quais módulos podem depender de quais. Para isso, um teste lendo o grafo do go list:
package archtest
import ( "os/exec" "slices" "strings" "testing")
const raiz = "github.com/dev-danilocordeiro/monolito-modular/go/internal/"
// Grafo permitido entre módulos. Qualquer aresta fora daqui reprova.var permitido = map[string][]string{ "matricula": {"turmas"}, "turmas": {}, "financeiro": {"matricula"},}
func moduloDe(pkg string) (string, bool) { resto, ok := strings.CutPrefix(pkg, raiz) if !ok { return "", false } nome, _, _ := strings.Cut(resto, "/") return nome, true}
func TestGrafoDeModulos(t *testing.T) { cmd := exec.Command("go", "list", "-f", `{{.ImportPath}}{{range .Imports}} {{.}}{{end}}`, "./...") cmd.Dir = ".." out, err := cmd.Output() if err != nil { t.Fatalf("go list: %v", err) }
for _, linha := range strings.Split(strings.TrimSpace(string(out)), "\n") { campos := strings.Fields(linha) origem, ok := moduloDe(campos[0]) if !ok { continue } for _, imp := range campos[1:] { destino, ok := moduloDe(imp) if !ok || destino == origem { continue } if !slices.Contains(permitido[origem], destino) { t.Errorf("%s importa %s (%s -> %s não está no grafo permitido)", campos[0], imp, origem, destino) } } }
for modulo := range permitido { if _, err := exec.Command("go", "list", "../internal/"+modulo).Output(); err != nil { t.Errorf("módulo %q declarado no grafo mas não existe", modulo) } }}Se o grafo crescer, ferramentas como o go-arch-lint ou o depguard (do golangci-lint) fazem o mesmo de forma declarativa. Para poucos módulos, o teste acima é mais fácil de entender e não depende de nada.
Testando: quebrando de propósito#
O teste que importa é o negativo. Em cada linguagem, crie uma violação e confirme que o build quebra:
# Python: importe escola.modules.turmas.domain dentro de matriculapoetry run lint-imports # deve falhar
# Java: use escola.turmas.internal.Turma dentro de matricula./gradlew test --tests ModularidadeTest # deve falhar
# Go: importe internal/turmas/internal/store dentro de matriculago build ./... # deve falhar no compilador# importe internal/turmas dentro de financeirogo build ./... # compila: a aresta não forma ciclogo test ./archtest/ # deve falhar no teste de grafoEscolha a aresta do teste de grafo com cuidado. Importar internal/financeiro dentro de turmas parece o exemplo óbvio, mas fecha o ciclo turmas → financeiro → matricula → turmas, e quem reprova é o compilador (import cycle not allowed), antes do archtest. O caso que só o teste pega é uma aresta fora do grafo que não forma ciclo, como financeiro → turmas:
--- FAIL: TestGrafoDeModulos arch_test.go:48: github.com/dev-danilocordeiro/monolito-modular/go/internal/financeiro importa github.com/dev-danilocordeiro/monolito-modular/go/internal/turmas (financeiro -> turmas não está no grafo permitido)Armadilhas e trade-offs#
Resumo#
| Python | Java | Go | |
|---|---|---|---|
| Ferramenta | import-linter | Spring Modulith (ArchUnit) | compilador + teste |
| Superfície pública | public.py + contrato forbidden | pacote raiz do módulo, @NamedInterface | diretório internal/ aninhado |
| Grafo permitido | contratos forbidden | allowedDependencies | teste com go list |
| Ciclos | contrato independence ou layers | verify() | proibidos pela linguagem |
| Camadas internas | contrato layers | ArchUnit / jMolecules | convenção ou go-arch-lint |
| Onde falha | lint no CI | teste unitário | build e teste |
Conclusão#
A diferença entre um monolito modular e um monolito com pastas organizadas é uma linha no CI. Em Go ela vem quase de graça; em Java, o Spring Modulith resolve com uma convenção e um teste; em Python, o import-linter exige mais configuração, mas chega no mesmo lugar.
Com as fronteiras garantidas, sobra a pergunta: como os módulos conversam sem se acoplar? No próximo post da série, falo de eventos dentro do processo, transactional outbox e o que muda quando cada módulo é dono do seu schema.
Código no GitHub#
O código deste artigo está no GitHubdev-danilocordeiro/monolito-modularReferências
- import-linter — documentação import-linter.readthedocs.io
- Spring Modulith — Reference docs.spring.io
- Go 1.4 — Internal packages go.dev
- ArchUnit archunit.org
Encontrou um erro? Me avise por e-mail