Gradle Multi-Module Projects: The Architecture That Scales From Startup to Enterprise
Stop copying code between projects. Learn how to structure Gradle multi-module projects that enforce boundaries, speed up builds, and make your codebase maintainable.
Moshiour Rahman
Advertisement
The Problem: The Growing Monolith
Your project started simple. One module, clean code, fast builds.
Then features happened.
my-app/
├── src/main/java/
│ ├── controller/ # 50 files
│ ├── service/ # 80 files
│ ├── repository/ # 40 files
│ ├── model/ # 100 files
│ ├── dto/ # 60 files
│ ├── util/ # 30 files
│ ├── config/ # 20 files
│ └── ... # 200 more files
└── build.gradle # One giant build file
Problems you’re now facing:
- Build takes 5 minutes (changed one file, recompile everything)
- Service layer accidentally imports controller classes (architectural violation)
- Team A breaks Team B’s code without realizing
- Can’t deploy just the API - it’s all tangled together
- Test suite runs 20 minutes (tests everything every time)
The Solution: Multi-Module Architecture

Benefits: Compile-time boundary enforcement, parallel builds, independent deployment, clear ownership.
Project Structure: The Clean Architecture Approach
my-app/
├── settings.gradle.kts # Defines all modules
├── build.gradle.kts # Root build config
├── gradle/
│ └── libs.versions.toml # Centralized dependency versions
│
├── common/ # Shared utilities, no business logic
│ ├── build.gradle.kts
│ └── src/main/java/
│ └── io/techyowls/common/
│ ├── exception/
│ └── util/
│
├── domain/ # Business entities, zero dependencies
│ ├── build.gradle.kts
│ └── src/main/java/
│ └── io/techyowls/domain/
│ ├── user/
│ │ ├── User.java
│ │ └── UserRepository.java # Interface only!
│ └── order/
│
├── service/ # Business logic
│ ├── build.gradle.kts
│ └── src/main/java/
│ └── io/techyowls/service/
│ ├── user/
│ │ └── UserService.java
│ └── order/
│
├── infrastructure/ # Database, external services
│ ├── build.gradle.kts
│ └── src/main/java/
│ └── io/techyowls/infrastructure/
│ ├── persistence/
│ │ └── JpaUserRepository.java # Implements domain interface
│ └── external/
│
├── api/ # REST controllers
│ ├── build.gradle.kts
│ └── src/main/java/
│ └── io/techyowls/api/
│ ├── controller/
│ └── dto/
│
└── app/ # Main application, wires everything
├── build.gradle.kts
└── src/main/java/
└── io/techyowls/
└── Application.java
settings.gradle.kts: Define Your Modules
rootProject.name = "my-app"
include(
"common",
"domain",
"service",
"infrastructure",
"api",
"app"
)
// Enable type-safe project accessors (projects.common instead of project(":common"))
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
gradle/libs.versions.toml: Centralized Dependencies
Why version catalogs? Before this, every module had different Spring versions. Chaos.
[versions]
spring-boot = "3.2.0"
java = "21"
lombok = "1.18.30"
mapstruct = "1.5.5.Final"
[libraries]
# Spring Boot starters
spring-boot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "spring-boot" }
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" }
spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa", version.ref = "spring-boot" }
spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation", version.ref = "spring-boot" }
spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "spring-boot" }
# Database
postgresql = { module = "org.postgresql:postgresql" }
h2 = { module = "com.h2database:h2" }
# Tools
lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" }
mapstruct = { module = "org.mapstruct:mapstruct", version.ref = "mapstruct" }
mapstruct-processor = { module = "org.mapstruct:mapstruct-processor", version.ref = "mapstruct" }
[bundles]
# Group related dependencies
spring-web = ["spring-boot-starter-web", "spring-boot-starter-validation"]
testing = ["spring-boot-starter-test"]
[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }
spring-dependency-management = { id = "io.spring.dependency-management", version = "1.1.4" }
Root build.gradle.kts: Shared Configuration
plugins {
java
alias(libs.plugins.spring.boot) apply false
alias(libs.plugins.spring.dependency.management) apply false
}
allprojects {
group = "io.techyowls"
version = "1.0.0"
repositories {
mavenCentral()
}
}
subprojects {
apply(plugin = "java")
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
options.compilerArgs.addAll(listOf("-parameters"))
}
tasks.withType<Test> {
useJUnitPlatform()
}
// Apply Spring dependency management to all modules
apply(plugin = "io.spring.dependency-management")
// Common dependencies for all modules
dependencies {
compileOnly(rootProject.libs.lombok)
annotationProcessor(rootProject.libs.lombok)
testImplementation(rootProject.libs.bundles.testing)
testCompileOnly(rootProject.libs.lombok)
testAnnotationProcessor(rootProject.libs.lombok)
}
}
Module Build Files
common/build.gradle.kts
// No dependencies on other modules - this is the foundation
dependencies {
// Only pure Java utilities
}
domain/build.gradle.kts
dependencies {
implementation(project(":common"))
// Domain should be framework-agnostic, but JPA annotations are pragmatic
compileOnly(libs.spring.boot.starter.data.jpa)
}
service/build.gradle.kts
dependencies {
implementation(project(":common"))
implementation(project(":domain"))
implementation(libs.spring.boot.starter)
implementation(libs.spring.boot.starter.validation)
}
infrastructure/build.gradle.kts
dependencies {
implementation(project(":common"))
implementation(project(":domain"))
implementation(libs.spring.boot.starter.data.jpa)
runtimeOnly(libs.postgresql)
}
api/build.gradle.kts
dependencies {
implementation(project(":common"))
implementation(project(":domain"))
implementation(project(":service"))
implementation(libs.bundles.spring.web)
// MapStruct for DTO mapping
implementation(libs.mapstruct)
annotationProcessor(libs.mapstruct.processor)
}
app/build.gradle.kts
plugins {
alias(libs.plugins.spring.boot)
}
dependencies {
// Wire all modules together
implementation(project(":common"))
implementation(project(":domain"))
implementation(project(":service"))
implementation(project(":infrastructure"))
implementation(project(":api"))
// Only the app module has the Spring Boot plugin
implementation(libs.spring.boot.starter)
testImplementation(libs.bundles.testing)
testRuntimeOnly(libs.h2)
}
tasks.bootJar {
archiveFileName.set("my-app.jar")
}
The Dependency Rule: Arrows Point Inward

Gradle enforces this at compile time. If service tries to import from api, the build fails.
Real Example: User Domain
domain/src/…/user/User.java
package io.techyowls.domain.user;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private Instant createdAt;
// Getters, setters, constructors
}
domain/src/…/user/UserRepository.java
package io.techyowls.domain.user;
import java.util.Optional;
// Interface in domain - implementation in infrastructure
public interface UserRepository {
User save(User user);
Optional<User> findById(Long id);
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
}
infrastructure/src/…/persistence/JpaUserRepository.java
package io.techyowls.infrastructure.persistence;
import io.techyowls.domain.user.User;
import io.techyowls.domain.user.UserRepository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface JpaUserRepository extends JpaRepository<User, Long>, UserRepository {
// Spring Data JPA implements all methods automatically
}
service/src/…/user/UserService.java
package io.techyowls.service.user;
import io.techyowls.domain.user.User;
import io.techyowls.domain.user.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class UserService {
private final UserRepository userRepository; // Domain interface
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User createUser(String email, String name) {
if (userRepository.existsByEmail(email)) {
throw new IllegalArgumentException("Email already exists");
}
User user = new User();
user.setEmail(email);
user.setName(name);
user.setCreatedAt(Instant.now());
return userRepository.save(user);
}
}
api/src/…/controller/UserController.java
package io.techyowls.api.controller;
import io.techyowls.api.dto.CreateUserRequest;
import io.techyowls.api.dto.UserResponse;
import io.techyowls.api.mapper.UserMapper;
import io.techyowls.service.user.UserService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
private final UserMapper userMapper;
public UserController(UserService userService, UserMapper userMapper) {
this.userService = userService;
this.userMapper = userMapper;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
var user = userService.createUser(request.email(), request.name());
return userMapper.toResponse(user);
}
}
Build Performance: Parallel & Cached
gradle.properties
# Enable parallel execution
org.gradle.parallel=true
# Build cache - reuse outputs across builds
org.gradle.caching=true
# More memory for larger projects
org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError
# Configuration cache (Gradle 8+)
org.gradle.configuration-cache=true
Build Time Comparison

Multi-module wins when changes are isolated (most of the time), CI uses build cache, and teams work on different modules.
Testing: Module-Specific Tests
// infrastructure/build.gradle.kts
dependencies {
testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.testcontainers.postgresql)
testRuntimeOnly(libs.postgresql)
}
// infrastructure/src/test/.../JpaUserRepositoryTest.java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class JpaUserRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private JpaUserRepository repository;
@Test
void shouldFindUserByEmail() {
// Test against real PostgreSQL
}
}
# Run tests for single module (fast)
./gradlew :service:test
# Run all tests
./gradlew test
# Run only changed modules' tests
./gradlew test --build-cache
Common Patterns
Pattern 1: Shared Test Fixtures
// domain/build.gradle.kts
plugins {
`java-test-fixtures` // Enable test fixtures
}
// domain/src/testFixtures/java/.../UserFixtures.java
public class UserFixtures {
public static User aUser() {
User user = new User();
user.setEmail("test@example.com");
user.setName("Test User");
user.setCreatedAt(Instant.now());
return user;
}
public static User aUser(String email) {
User user = aUser();
user.setEmail(email);
return user;
}
}
// service/build.gradle.kts
dependencies {
testImplementation(testFixtures(project(":domain")))
}
Pattern 2: API-Only Module for Clients
// Create a module with only DTOs and interfaces
// api-client/build.gradle.kts
dependencies {
// No Spring dependencies - pure Java
implementation(libs.jackson.annotations)
}
// api-client/src/.../UserApiClient.java
public interface UserApiClient {
UserResponse getUser(Long id);
UserResponse createUser(CreateUserRequest request);
}
// Other services can depend on api-client without pulling all of Spring
Pattern 3: Feature Modules
For larger apps, organize by feature instead of layer:
my-app/
├── core/ # Shared domain, common
├── feature-user/ # User management
│ ├── user-api/
│ ├── user-service/
│ └── user-persistence/
├── feature-order/ # Order management
│ ├── order-api/
│ ├── order-service/
│ └── order-persistence/
└── app/
Migration Strategy: Monolith to Multi-Module

Key: Each phase is independently deployable. Start with low-risk extractions (common, domain), then tackle services.
Code Sample
Full working example: github.com/Moshiour027/techyowls-io-blog-public/gradle-multi-module
Summary
| Aspect | Single Module | Multi-Module |
|---|---|---|
| Setup complexity | Simple | Medium |
| Build time (incremental) | Slow | Fast |
| Boundary enforcement | None | Compile-time |
| Team scalability | Poor | Excellent |
| Deployment flexibility | All or nothing | Per-module |
Start multi-module when:
- Build exceeds 1 minute
- Team exceeds 3 developers
- You catch architectural violations in code review
- Different deployment needs emerge
Don’t over-engineer. Start with 3-4 modules (common, domain, service, app) and split only when needed.
Your future self will thank you.
Advertisement
Moshiour Rahman
Software Architect & AI Engineer
Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.
Related Articles
Spring Boot 3 + Spring AI: Build Production-Ready AI Apps in Java (2025 Complete Guide)
Master Spring AI with this comprehensive 2025 guide. Learn to build production-ready AI applications in Java with OpenAI, Ollama, RAG systems, vector stores, and complete code examples.
JavaThe Visitor Design Pattern: Add Operations Without Modifying Classes
Master the Visitor Pattern in Java. Learn how to add new operations to object structures using double dispatch.
JavaThe Template Method Pattern: The Recipe for Success
Master the Template Method Pattern in Java. Learn how to define the skeleton of an algorithm in a superclass but let subclasses override specific steps.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.