Navigating the Spring Framework Monorepo: Architecture, Modules, and Gradle Build
Prerequisites
- ›Basic Java and Gradle knowledge
- ›Familiarity with Spring Framework as a user
Navigating the Spring Framework Monorepo: Architecture, Modules, and Gradle Build
Spring Framework has been under continuous development since 2002, making it one of the longest-running open-source Java projects in existence. Today, at version 7.x, it spans 22 modules in a Gradle monorepo that builds with a JDK 25 toolchain while targeting Java 17 bytecode. Before you can understand how Spring's IoC container, AOP, or web layer works, you need to understand how the codebase itself is organized. This article provides that map.
The Repository at a Glance
The root of the repository is deceptively simple. A settings.gradle file declares every module, a build.gradle configures shared conventions, and each module has its own build file named ${project.name}.gradle rather than the standard build.gradle. This naming trick — applied at line 34–36 of settings.gradle — lets you instantly know which module you're editing when you have multiple build files open:
rootProject.name = "spring"
rootProject.children.each { project ->
project.buildFileName = "${project.name}.gradle"
}
| Directory | Purpose |
|---|---|
spring-core |
Foundational utilities, type system, resource loading, vendored ASM/CGLIB/JavaPoet |
spring-beans |
IoC container: BeanFactory, BeanDefinition, dependency injection |
spring-aop |
AOP alliance model, proxy infrastructure |
spring-expression |
SpEL — Spring Expression Language |
spring-context |
ApplicationContext, annotation configuration, event system, scheduling |
spring-tx |
Transaction abstraction, PlatformTransactionManager |
spring-jdbc, spring-r2dbc |
JDBC/R2DBC data access templates |
spring-orm |
JPA/Hibernate integration |
spring-web |
Shared HTTP abstractions for MVC and WebFlux |
spring-webmvc |
Servlet-based MVC (DispatcherServlet) |
spring-webflux |
Reactive web (DispatcherHandler, Reactor-based) |
spring-websocket |
WebSocket support |
spring-messaging |
Messaging abstractions (STOMP, etc.) |
spring-jms |
JMS integration |
spring-test |
Testing utilities (MockMvc, TestContext) |
spring-aspects |
AspectJ integration |
spring-instrument |
JVM agent for class instrumentation |
spring-context-indexer |
Build-time annotation indexer |
spring-context-support |
Cache managers, mail, FreeMarker |
spring-core-test |
Test fixtures for spring-core |
spring-oxm |
Object/XML marshalling |
Tip: The root
build.gradlesplits subprojects into two useful sets at lines 13–14:moduleProjects(names starting withspring-) andjavaProjects(everything except theframework-*utility projects). You'll see these sets used throughout the build to apply plugins selectively.
The 22-Module Layered Dependency Graph
Spring's modules form a strict layered architecture. Dependencies flow in one direction — from higher-level modules down to foundational ones. No cycles are permitted (enforced by ArchUnit, as we'll see shortly).
flowchart TD
subgraph "Layer 1: Foundation"
core["spring-core"]
end
subgraph "Layer 2: IoC"
beans["spring-beans"]
end
subgraph "Layer 3: AOP & Expression"
aop["spring-aop"]
expression["spring-expression"]
end
subgraph "Layer 4: Application Context"
context["spring-context"]
ctxSupport["spring-context-support"]
end
subgraph "Layer 5: Data Access"
tx["spring-tx"]
jdbc["spring-jdbc"]
orm["spring-orm"]
r2dbc["spring-r2dbc"]
end
subgraph "Layer 6: Web Foundation"
web["spring-web"]
end
subgraph "Layer 7: Web Frameworks"
webmvc["spring-webmvc"]
webflux["spring-webflux"]
websocket["spring-websocket"]
end
beans --> core
aop --> beans
aop --> core
expression --> core
context --> aop
context --> beans
context --> expression
context --> core
ctxSupport --> context
tx --> beans
tx --> core
jdbc --> tx
jdbc --> beans
orm --> jdbc
orm --> tx
r2dbc --> tx
r2dbc --> core
web --> beans
web --> core
webmvc --> web
webmvc --> aop
webmvc --> context
webmvc --> expression
webflux --> web
webflux --> beans
webflux --> core
websocket --> web
websocket --> context
The crucial insight here is that spring-web is the shared HTTP abstraction layer. Both spring-webmvc and spring-webflux depend on it, but they never depend on each other. The MVC module pulls in servlet APIs; the WebFlux module pulls in Reactor. This clean separation is what allows Spring to support both programming models without cross-contamination.
The Custom Gradle Build Infrastructure: buildSrc Plugins
The buildSrc directory contains a custom Gradle plugin ecosystem that enforces consistent build behavior across all 22 modules. The entry point is ConventionsPlugin:
buildSrc/src/main/java/org/springframework/build/ConventionsPlugin.java#L38-L50
This plugin is applied to every Java project via the root build.gradle:
apply plugin: 'org.springframework.build.conventions'
It orchestrates five convention classes, each responsible for a specific concern:
flowchart LR
CP[ConventionsPlugin] --> AP[ArchitecturePlugin]
CP --> CC[CheckstyleConventions]
CP --> JC[JavaConventions]
CP --> KC[KotlinConventions]
CP --> TC[TestConventions]
JavaConventions is where the compilation strategy lives. It configures a JDK 25 toolchain while targeting Java 17 bytecode via the --release flag — meaning Spring can use JDK 25's compiler improvements and APIs in multi-release JAR slices while maintaining Java 17 as the minimum runtime requirement:
buildSrc/src/main/java/org/springframework/build/JavaConventions.java#L48-L54
Notice the -Werror flag in the compiler arguments at line 67 — warnings are treated as errors in production code but relaxed for tests. This is an opinionated choice that catches issues early but doesn't burden test code with the same strictness.
Architecture Enforcement via ArchUnit
Spring doesn't just hope its modules stay clean — it enforces architectural rules at build time using ArchUnit. The ArchitectureRules class defines the structural invariants that every module must satisfy:
buildSrc/src/main/java/org/springframework/build/architecture/ArchitectureRules.java#L28-L100
The rules break down into three categories:
flowchart TD
AR[ArchitectureRules] --> PT[Package Tangle Detection]
AR --> FA[Forbidden API Calls]
AR --> FT[Forbidden Type Dependencies]
PT -->|"SlicesRuleDefinition"| NoCycles["No package cycles allowed"]
FA --> NoLower["No String.toLowerCase without Locale"]
FA --> NoUpper["No String.toUpperCase without Locale"]
FT --> NoSLF4J["No SLF4J LoggerFactory"]
FT --> NoSpringNullable["No org.springframework.lang.Nullable"]
FT --> NoSpringNonNull["No org.springframework.lang.NonNull"]
The forbidden types list tells a migration story. Spring is actively migrating from its own @Nullable/@NonNull annotations in org.springframework.lang to JSpecify annotations. The ArchUnit rule at line 56–58 prevents anyone from importing the old annotations, forcing the migration forward. Similarly, org.slf4j.LoggerFactory is forbidden because Spring uses Apache Commons Logging as its facade — a deliberate choice to avoid forcing a logging framework on users.
The SpringSlices inner class (line 76–99) is noteworthy: it explicitly excludes the vendored packages (org.springframework.asm, org.springframework.cglib, etc.) from cycle detection, since those are relocated third-party code that Spring doesn't control.
Tip: If you're building a large monorepo yourself, Spring's approach of embedding ArchUnit rules directly in the build plugin — rather than as test classes — means the rules are consistent across all modules without copy-paste.
Shadow/Repack and Multi-Release JAR Strategy
One of spring-core's most unusual build configurations is how it vendors four third-party libraries directly into its own JAR under Spring's namespace. This avoids classpath conflicts when applications use different versions of the same libraries.
spring-core/spring-core.gradle#L30-L35
The repack strategy uses the Shadow Gradle plugin to relocate package namespaces:
| Original Package | Relocated Package | Library |
|---|---|---|
com.palantir.javapoet |
org.springframework.javapoet |
JavaPoet (code generation) |
org.objenesis |
org.springframework.objenesis |
Objenesis (object instantiation) |
| ASM (bundled in source) | org.springframework.asm |
ASM (bytecode manipulation) |
| CGLIB (bundled in source) | org.springframework.cglib |
CGLIB (subclass generation) |
flowchart LR
subgraph "Third-Party JARs"
JP["javapoet-0.10.0.jar"]
OB["objenesis-3.5.jar"]
end
subgraph "ShadowJar Tasks"
JPRJ["javapoetRepackJar"]
OBRJ["objenesisRepackJar"]
end
subgraph "spring-core.jar"
SJP["org.springframework.javapoet.*"]
SOB["org.springframework.objenesis.*"]
SASM["org.springframework.asm.*"]
SCGLIB["org.springframework.cglib.*"]
end
JP --> JPRJ --> SJP
OB --> OBRJ --> SOB
ASM and CGLIB are not repacked via Shadow — they're maintained as relocated source code directly in the spring-core source tree. This gives the Spring team full control to apply patches and optimizations.
The MultiReleaseJarPlugin enables Java version-specific optimizations. The spring-core module declares multi-release support for Java 21 and 24:
spring-core/spring-core.gradle#L13-L15
multiRelease {
releaseVersions 21, 24
}
This means spring-core ships a standard Java 17 codebase with optimized class variants under META-INF/versions/21/ and META-INF/versions/24/. When running on JDK 24, for example, the JVM automatically picks up the optimized versions of those classes.
The Framework Platform BOM and Dependency Management
With dozens of third-party dependencies across 22 modules, version management could easily become chaotic. Spring solves this with a central Bill of Materials (BOM) defined in framework-platform.gradle:
framework-platform/framework-platform.gradle#L1-L25
This file uses Gradle's java-platform plugin to pin every third-party dependency version — from Jackson 2.21.2 to JUnit 6.0.3 to Reactor BOM 2025.0.4. All modules then consume this platform via enforcedPlatform() in the root build:
flowchart TD
BOM["framework-platform.gradle<br/>(java-platform)"]
ROOT["build.gradle<br/>enforcedPlatform()"]
MOD1["spring-core"]
MOD2["spring-beans"]
MOD3["spring-web"]
MODN["... all other modules"]
BOM --> ROOT
ROOT --> MOD1
ROOT --> MOD2
ROOT --> MOD3
ROOT --> MODN
The enforcedPlatform() call is crucial — unlike a regular platform(), it overrides any transitive dependency versions. If a library pulls in Jackson 2.18 transitively, enforcedPlatform() will force it to 2.21.2. This guarantees version consistency across the entire framework.
The spring-module.gradle shared script (applied to all spring-* modules via the root build at line 88–90) adds the java-library plugin, JMH benchmarking support, the io.spring.nullability plugin for JSpecify validation, and standard publishing configuration:
gradle/spring-module.gradle#L1-L9
Navigating the Codebase: Practical Tips
Now that you understand the build structure, here are some practical tips for navigating the source:
-
Start with the interface, not the implementation. Spring's interfaces are extensively documented with Javadoc. Reading
BeanFactory.javawill teach you more about the design than any implementation class. -
Follow the
extendschain. Spring's implementation hierarchy is deep but intentional. Each class adds a specific layer of responsibility — understanding why a class exists is more important than memorizing what it does. -
Use the module boundaries. If you're investigating a web issue, you're either in
spring-web,spring-webmvc, orspring-webflux. If it's a DI issue, it's inspring-beans. The module structure is your best debugging guide. -
Check the
.gradlefile first. Each module's${module-name}.gradlefile shows its dependencies, which tells you what abstraction layer you're at and what external APIs are available.
Tip: When reading Spring source code, the
optional()dependency declaration in Gradle files indicates a feature that's only available when the user provides that library. This maps to Spring Boot's auto-configuration: if a library is on the classpath, its features are activated.
What's Next
With the module map in hand, we're ready to dive into the most important module: spring-beans. In the next article, we'll trace the BeanFactory interface hierarchy from its simple getBean() root through seven sub-interfaces to the 2,800-line DefaultListableBeanFactory implementation, and walk through the complete bean creation pipeline — from raw class to fully initialized, dependency-injected singleton.