Introduction: Why a Monorepository in Java Projects?
As Java applications grow in complexity, teams often face a dilemma: store all code in one repository (monorepository) or split it into many microservice repositories (polyrepository). Practice shows that a monorepository is especially effective for projects with shared business logic, libraries, and configurations. In Java, it allows centralized management of dependencies, versions, and builds, simplifying refactoring and integration.
In this article, we will break down how to organize Java code in a monorepository in practice: from folder structure to CI/CD setup. You will learn how to avoid common mistakes and speed up development.
1. Monorepository Structure for Java
The key principle of a monorepository is modularity. In Java, this is implemented through multi-module projects. Each module is responsible for its own area: shared libraries, services, utilities.
Example of a typical structure:
my-java-monorepo/├── build.gradle # Root build file├── settings.gradle # Includes all modules├── gradle.properties # Shared dependency versions├── common-lib/ # Shared library (DTOs, utilities)│ ├── build.gradle│ └── src/├── service-auth/ # Authentication service│ ├── build.gradle│ └── src/├── service-payment/ # Payment service│ ├── build.gradle│ └── src/└── integration-tests/ # Integration tests ├── build.gradle └── src/For version management, it is convenient to move shared dependencies to the root build.gradle or pom.xml (for Maven). In Gradle, this is done via the subprojects block:
subprojects { apply plugin: 'java' group = 'com.pythonlib' version = '1.0.0'
repositories { mavenCentral() }
dependencies { testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0' }}This structure makes it easy to add new services without copying configurations and ensures that all modules use the same library versions.
2. Dependency and Version Management
One of the main problems of a monorepository is version conflicts. In Java, BOM (Bill of Materials) or centralized dependency descriptions are used to solve this. Let's look at an example in Gradle using java-platform.
Let's create a platform module that defines all external dependencies:
// platform/build.gradleplugins { id 'java-platform'}
javaPlatform { allowDependencies()}
dependencies { constraints { api 'com.fasterxml.jackson.core:jackson-databind:2.15.2' api 'org.springframework.boot:spring-boot-starter-web:3.1.5' api 'org.slf4j:slf4j-api:2.0.9' }}Now, in each service, you can simply reference the platform:
// service-auth/build.gradledependencies { implementation platform(project(':platform')) implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'org.springframework.boot:spring-boot-starter-web'}This ensures that all modules use the same versions. When the version in platform is updated, the changes are automatically applied to all services.
Tip: For Maven, use <dependencyManagement> in the root pom.xml. It works similarly.
3. CI/CD for a Monorepository
Organizing continuous integration in a monorepository requires a special approach. You cannot build all modules on every commit — this will slow down the pipeline. The optimal solution is incremental builds based on changes.
Example on GitHub Actions using paths:
name: Java CIon: