Boundaries your CI enforces: import-linter, Spring Modulith and internal/ in Go
A module boundary that only exists in a diagram won't survive the first tight deadline. How to make the build break when someone crosses a boundary, in Python, Java and Go.
Published · Ler em português
- #architecture
- #modular-monolith
- #python
- #Java & Spring
- #Go
TL;DR
- In Python, import-linter turns layer and boundary rules into contracts that fail the PR.
- In Java, Spring Modulith treats each top-level package as a module, hides its subpackages and checks everything in a one-line test.
- In Go, the
internal/directory is already a boundary enforced by the compiler. For the dependency graph between modules, a 60-line test withgo listdoes the job.
Introduction#
In the previous post I drew the boundaries of a school platform with strategic DDD. Now comes the practical problem: how to keep them from falling apart.
Every modular monolith starts clean. Six months later, someone needs data from another module, the deadline is tight and the internal class is right there, one import away. A year later, the “modular monolith” is a plain monolith with nice folders.
Code review doesn’t hold that line reliably, and with AI agents writing part of the code it gets even harder: the agent finds the shortest path to the class that solves the problem, and that path almost never goes through the facade. The only boundary that survives is the one that breaks the build.
I’ll show the same idea in three languages, with the most idiomatic tools for each.
Prerequisites#
- Having read the previous posts in the series, or understanding what a modular monolith is
- Python 3.12+ with Poetry, JDK 21+ with Gradle, and Go 1.23+ (only the ones you’ll run)
Concept: what a boundary has to guarantee#
Regardless of language, a well-isolated module follows three rules:
- An explicit public surface. Other modules only see a facade (API, events, DTOs). Everything else is internal.
- A declared dependency graph. It’s written down which modules may depend on which, and there are no cycles.
- Internal layers (optional, but common). Inside the module, the domain doesn’t depend on any framework.
Each tool below covers these rules in a different way. What changes is how much you get for free.
Hands-on#
git clone https://github.com/dev-danilocordeiro/monolito-modularcd monolito-modularEach language has its own folder: python/, java/ and go/. The Java code for this post is on the passo-0 (“step 0”) tag (git checkout passo-0), because main is already at the end of the extraction that the last post in the series walks through.
The identifiers are in Portuguese, as in the repository: escola is school, matricula is enrollment, turmas is classes and financeiro is finance.
Python: import-linter#
Python has no notion of visibility between packages; everything is importable. import-linter fixes that with contracts declared in a configuration file.
poetry add --group dev import-linterThe structure follows smarthome-hub: each module has a public.py and internal layers.
escola/ modules/ matricula/ public.py # the only thing other modules import api/ application/ domain/ infrastructure/ turmas/ public.py api/ application/ domain/ infrastructure/Three kinds of contract cover the three rules:
[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 = TrueThe contract names and comments are in Portuguese too. In order: each module is layered api -> infrastructure -> application -> domain (rule 3), the domain imports no framework (rule 3), and matricula only reaches turmas through public.py (rule 1).
include_external_packages = True isn’t optional here: without it, import-linter doesn’t even get to evaluate the contracts and stops with The top level configuration must have include_external_packages=True when there are external forbidden modules, because fastapi, sqlalchemy and pydantic live outside escola.
And for rule 2 (no dependency going back), a contract saying turmas doesn’t depend on matricula:
[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.allow_indirect_imports = True deserves attention: without it, matricula → turmas.public → turmas.domain also counts as a violation, because a forbidden contract looks at the whole chain by default. Since the facade has to import its own domain, you only want to block the direct import. Without the option, the lint fails correct code:
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#
Spring Modulith starts from a convention: every package directly under the application’s package is a module, and its subpackages are internal by default.
src/main/java/escola/ EscolaApplication.java matricula/ # module; public types here are the API MatriculaService.java MatriculaConfirmada.java internal/ # invisible to other modules 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")}The example uses Spring Boot 4.1.1 with Spring Modulith 2.1.1, the latest stable combination: Modulith 2.1 is compiled against Boot 4.1. If your project is still on Boot 3, the matching line is Modulith 1.x.
The check is a test:
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 }}verify() fails on cycles and on access to internal types; Documenter writes C4 diagrams in PlantUML. If matricula uses escola.turmas.internal.Turma, verify() fails and points to the dependency on a non-exposed type of the turmas module:
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)It also detects cycles between modules (Cycle detected).
For rule 2, the explicit graph, declare the allowed dependencies in package-info.java:
@org.springframework.modulith.ApplicationModule( allowedDependencies = "turmas")package escola.matricula;Now matricula depending on any module other than turmas also fails the test, with Module 'matricula' depends on module 'infra' via ... Allowed targets: turmas. And when a module needs to expose more than one package, @NamedInterface names the surface (allowedDependencies = "turmas :: api").
Go: internal/ and a test with go list#
Go is the only one of the three languages where the main boundary comes from the compiler. A package inside an internal/ directory can only be imported by code rooted at the parent of internal/.
The trick is to nest it:
go/ go.mod # module github.com/dev-danilocordeiro/monolito-modular/go cmd/api/main.go internal/ matricula/ matricula.go # the module's public API internal/ store/store.go # only matricula can see it turmas/ turmas.go internal/ store/store.go # only turmas can see it financeiro/ financeiro.go internal/ store/store.go # only financeiro can see it archtest/arch_test.goThe outer internal/ hides the modules from everything outside the Go module. The inner internal/ hides each module’s implementation from the other modules. If matricula tries to import internal/turmas/internal/store, the build fails. No extra tooling:
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 allowedThe compiler already forbids import cycles too. What’s missing is rule 2: declaring which modules may depend on which. For that, a test that reads the graph from 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) } }}permitido is the allowed graph between modules: any edge outside it fails the test. The last loop also catches a module declared in the graph that doesn’t exist.
If the graph grows, tools like go-arch-lint or depguard (from golangci-lint) do the same declaratively. For a handful of modules, the test above is easier to understand and depends on nothing.
Testing: breaking it on purpose#
The test that matters is the negative one. In each language, create a violation and confirm the build breaks:
# Python: import escola.modules.turmas.domain inside matriculapoetry run lint-imports # should fail
# Java: use escola.turmas.internal.Turma inside matricula./gradlew test --tests ModularidadeTest # should fail
# Go: import internal/turmas/internal/store inside matriculago build ./... # should fail in the compiler# import internal/turmas inside financeirogo build ./... # compiles: the edge doesn't form a cyclego test ./archtest/ # should fail in the graph testPick the edge for the graph test carefully. Importing internal/financeiro inside turmas looks like the obvious example, but it closes the cycle turmas → financeiro → matricula → turmas, and the compiler rejects it (import cycle not allowed) before the archtest runs. The case only the test catches is an edge outside the graph that does not form a cycle, like 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)Pitfalls and trade-offs#
Summary#
| Python | Java | Go | |
|---|---|---|---|
| Tool | import-linter | Spring Modulith (ArchUnit) | compiler + test |
| Public surface | public.py + forbidden contract | module root package, @NamedInterface | nested internal/ directory |
| Allowed graph | forbidden contracts | allowedDependencies | test with go list |
| Cycles | independence or layers contract | verify() | forbidden by the language |
| Internal layers | layers contract | ArchUnit / jMolecules | convention or go-arch-lint |
| Where it fails | lint in CI | unit test | build and test |
Conclusion#
The difference between a modular monolith and a monolith with tidy folders is one line in CI. In Go it comes almost for free; in Java, Spring Modulith handles it with a convention and a test; in Python, import-linter takes more configuration, but gets to the same place.
With the boundaries enforced, one question is left: how do modules talk without coupling? In the next post in the series, I cover in-process events, the transactional outbox and what changes when each module owns its schema.
Code on GitHub#
The code for this post is on GitHubdev-danilocordeiro/monolito-modularReferences
- import-linter — documentation import-linter.readthedocs.io
- Spring Modulith — Reference docs.spring.io
- Go 1.4 — Internal packages go.dev
- ArchUnit archunit.org
Found a mistake? Let me know by e-mail