Advanced7 min readSeries: Modular Monolith in Practice

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 with go list does 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:

  1. An explicit public surface. Other modules only see a facade (API, events, DTOs). Everything else is internal.
  2. A declared dependency graph. It’s written down which modules may depend on which, and there are no cycles.
  3. 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#

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

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

Terminal window
poetry add --group dev import-linter

The 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
[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

The 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 (continued)
[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.

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.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")
}

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:

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
}
}

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:

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

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

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

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)
}
}
}

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:

Terminal window
# Python: import escola.modules.turmas.domain inside matricula
poetry 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 matricula
go build ./... # should fail in the compiler
# import internal/turmas inside financeiro
go build ./... # compiles: the edge doesn't form a cycle
go test ./archtest/ # should fail in the graph test

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

PythonJavaGo
Toolimport-linterSpring Modulith (ArchUnit)compiler + test
Public surfacepublic.py + forbidden contractmodule root package, @NamedInterfacenested internal/ directory
Allowed graphforbidden contractsallowedDependenciestest with go list
Cyclesindependence or layers contractverify()forbidden by the language
Internal layerslayers contractArchUnit / jMoleculesconvention or go-arch-lint
Where it failslint in CIunit testbuild 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-modular

References

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

Found a mistake? Let me know by e-mail