Read OSS

Navigating the Spring Framework Monorepo: Architecture, Modules, and Gradle Build

Intermediate

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:

settings.gradle#L33-L36

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.gradle splits subprojects into two useful sets at lines 13–14: moduleProjects (names starting with spring-) and javaProjects (everything except the framework-* 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:

build.gradle#L39-L51

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

Now that you understand the build structure, here are some practical tips for navigating the source:

  1. Start with the interface, not the implementation. Spring's interfaces are extensively documented with Javadoc. Reading BeanFactory.java will teach you more about the design than any implementation class.

  2. Follow the extends chain. 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.

  3. Use the module boundaries. If you're investigating a web issue, you're either in spring-web, spring-webmvc, or spring-webflux. If it's a DI issue, it's in spring-beans. The module structure is your best debugging guide.

  4. Check the .gradle file first. Each module's ${module-name}.gradle file 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.