NOVO Série Monolito Modular na Prática no ar

Avançado6 min de leituraSérie: Monolito Modular na Prática

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 com go list resolve.

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:

  1. Superfície pública explícita. Outros módulos só enxergam uma fachada (API, eventos, DTOs). O resto é interno.
  2. Grafo de dependências declarado. Fica escrito quais módulos podem depender de quais, e não há ciclos.
  3. 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#

Terminal window
git clone https://github.com/dev-danilocordeiro/monolito-modular
cd monolito-modular

Cada 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.

Terminal window
poetry add --group dev import-linter

A 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
[importlinter]
root_package = escola
# Necessário para o contrato dominio-puro, que proíbe pacotes externos
include_external_packages = True
# Regra 3: camadas dentro de cada módulo
[importlinter:contract:camadas]
name = Cada módulo é api -> infrastructure -> application -> domain
type = layers
layers =
api
infrastructure
application
domain
containers =
escola.modules.matricula
escola.modules.turmas
# Regra 3: domínio puro
[importlinter:contract:dominio-puro]
name = Domínio não importa framework
type = forbidden
source_modules =
escola.modules.*.domain
forbidden_modules =
fastapi
sqlalchemy
pydantic
# Regra 1: só pela fachada
[importlinter:contract:fronteira-matricula]
name = matricula só alcança turmas pelo public.py
type = forbidden
source_modules =
escola.modules.matricula
forbidden_modules =
escola.modules.turmas.api
escola.modules.turmas.application
escola.modules.turmas.domain
escola.modules.turmas.infrastructure
allow_indirect_imports = True

O 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 (continuação)
[importlinter:contract:turmas-nao-conhece-matricula]
name = turmas não depende de matricula
type = forbidden
source_modules =
escola.modules.turmas
forbidden_modules =
escola.modules.matricula
Terminal window
poetry run lint-imports
Cada módulo é api -> infrastructure -> application -> domain KEPT
Domínio não importa framework KEPT
matricula só alcança turmas pelo public.py KEPT
turmas 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.java
build.gradle.kts
dependencies {
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:

src/test/java/escola/ModularidadeTest.java
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:

src/main/java/escola/matricula/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.go

O 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 allowed

O 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:

archtest/arch_test.go
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:

Terminal window
# Python: importe escola.modules.turmas.domain dentro de matricula
poetry 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 matricula
go build ./... # deve falhar no compilador
# importe internal/turmas dentro de financeiro
go build ./... # compila: a aresta não forma ciclo
go test ./archtest/ # deve falhar no teste de grafo

Escolha 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#

PythonJavaGo
Ferramentaimport-linterSpring Modulith (ArchUnit)compilador + teste
Superfície públicapublic.py + contrato forbiddenpacote raiz do módulo, @NamedInterfacediretório internal/ aninhado
Grafo permitidocontratos forbiddenallowedDependenciesteste com go list
Cicloscontrato independence ou layersverify()proibidos pela linguagem
Camadas internascontrato layersArchUnit / jMoleculesconvenção ou go-arch-lint
Onde falhalint no CIteste unitáriobuild 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-modular

Referências

  1. import-linter — documentação import-linter.readthedocs.io
  2. Spring Modulith — Reference docs.spring.io
  3. Go 1.4 — Internal packages go.dev
  4. ArchUnit archunit.org

Encontrou um erro? Me avise por e-mail