This sample shows how to structure a software product that consists of multiple components as a set of connected Gradle builds.

It shows how Gradle is used to model a project’s architecture. This is reflected in the physical structure of the files that make up the software.

Download the sample

The software product built in this sample is an application that displays Gradle Build Tool releases.

The application lists Gradle releases with links to release notes (user feature) and offers an administration interface for the range of releases to be listed (admin feature).

You can open this sample inside an IDE.

The sample explained

As software projects grow, organizing large systems into connected components is common. Typically, artifacts (such as source code) are organized in repositories and folder structures that reflect component boundaries and architecture.

Gradle can help organize and enforce boundaries between components. To exemplify this, the sample project has the following architecture:

software architecture

The structure follows commonly used software architectures.

At the bottom, we define our domain model. There are two components:

  1. a domain-model component that contains the model definition (i.e., a set of data classes) and,

  2. a state component responsible for managing a modifiable state of the model during application runtime.

On top of the model, business logic for different (end-user) features is implemented independently. We have two features:

  1. user and,

  2. admin.

At the top, we have concrete applications users use to interact with the features. We build a Spring Boot web application that supports both features. And an Android app that only supports the user feature.

Our components rely on external components, the Spring Boot and Android frameworks, that are retrieved from binary repositories.

Apart from the production code, some components deal with building and delivering the product:

  1. The build-logic component contains the configuration details about building the software.
    It defines a Java version to use and configures the test framework.
    It also contains additional build logic in custom plugins with custom tasks.

  2. The platforms component is a central place to define which versions of external components are to be used in all of our own components.
    It defines the constraints for the environments – that is, the platforms – to build, test, and run the software product.

  3. The aggregation component contains the setup of the delivery pipeline required to push the product to production and do automated end-to-end testing.
    This is the part of the build typically reserved for CI servers.

The project structure

Let’s look at the architecture of the sample. Each component is a separate Gradle build. Each Gradle build has its own folder.

Since each folder is a separate build, each one has its own settings.gradle(.kts) file:

├── android-app
│   └── settings.gradle.kts
├── server-application
│   └── settings.gradle.kts
│
├── admin-feature
│   └── settings.gradle.kts
├── user-feature
│   └── settings.gradle.kts
│
├── state
│   └── settings.gradle.kts
│
├── domain-model
│   └── settings.gradle.kts
│
├── build-logic
│   └── settings.gradle.kts
│
├── platforms
│   └── settings.gradle.kts
│
└── aggregation
    └── settings.gradle.kts
├── android-app
│   └── settings.gradle
├── server-application
│   └── settings.gradle
│
├── admin-feature
│   └── settings.gradle
├── user-feature
│   └── settings.gradle
│
├── state
│   └── settings.gradle
│
├── domain-model
│   └── settings.gradle
│
├── build-logic
│   └── settings.gradle
│
├── platforms
│   └── settings.gradle
│
└── aggregation
    └── settings.gradle

The components are arranged as a flat list in a root folder. The root folder can be used as the root of a Git repository.

A build is added by using the includeBuild() construct in the root settings file:

settings.gradle.kts
settings.gradle

Component structure

A (sub)project is added using the include() construct in the settings file.

Let’s zoom into the domain-model component:

└── domain-model              <-- component
    ├── settings.gradle.kts   <-- define inner structure of component and where to locate other components
    └── release               <-- (sub)project in component
        └── build.gradle.kts  <-- defines type of the project and its dependencies
└── domain-model              <-- component
    ├── settings.gradle       <-- define inner structure of component and where to locate other components
    └── release               <-- (sub)project in component
        └── build.gradle      <-- defines type of the project and its dependencies

When we look at the domain-model settings file, we see that release is included as a (sub)project:

domain-model/settings.gradle.kts
include("release") // a project for data classes that represent software releases
domain-model/settings.gradle
include('release') // a project for data classes that represent software releases

Assigning types to components

In Gradle, you assign a type to a project by applying a plugin.

In the sample, the custom type com.example.kotlin-library is applied to the domain-model component:

domain-model/release/build.gradle.kts
plugins {
    id("com.example.kotlin-library")
}
domain-model/release/build.gradle
plugins {
    id('com.example.kotlin-library')
}

Note that com.example.kotlin-library is applied to several other components, including state and admin-feature.

Using Convention plugins

Where does the com.example.kotlin-library plugin from?

It is defined in the build-logic component.

The build-logic component contains build configuration as Gradle plugins called convention plugins. The build-logic component in the sample has several projects that each define a project type through a convention plugin:

  • java-library

  • kotlin-library

  • spring-application

  • android-application

There is also a project called commons for build configuration shared by all the project types.

To apply a convention plugin and assign a custom type to a component:

build-logic/spring-boot-application/build.gradle.kts
plugins {
    `kotlin-dsl` (1)
}

dependencies {
    implementation(platform("com.example.platform:plugins-platform")) (2)

    implementation(project(":commons")) (3)

    implementation("org.springframework.boot:org.springframework.boot.gradle.plugin")  (4)
}
build-logic/spring-boot-application/build.gradle
plugins {
    id('groovy-gradle-plugin') (1)
}

dependencies {
    implementation(platform('com.example.platform:plugins-platform')) (2)

    implementation(project(':commons')) (3)

    implementation('org.springframework.boot:org.springframework.boot.gradle.plugin')  (4)
}
1 That it is of type groovy-gradle-plugin or kotlin-dsl to allow convention plugins written in the corresponding DSL
2 It depends on our own plugins-platform from the platforms component
3 It depends on the commons project from build-logic to have access to our own commons convention plugin
4 It depends on the Spring Boot Gradle plugin from the Gradle Plugin Portal so that we may apply that plugin to our Spring Boot projects

Let’s take a look at the code in build-logic/spring-boot-application where we define a custom project type as a convention plugin:

build-logic/spring-boot-application/src/main/kotlin/com.example.spring-boot-application.gradle.kts
plugins {
    id("com.example.commons")
    id("org.springframework.boot")
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
}
build-logic/spring-boot-application/src/main/groovy/com.example.spring-boot-application.gradle
plugins {
    id('com.example.commons')
    id('org.springframework.boot')
}

dependencies {
    implementation('org.springframework.boot:spring-boot-starter-web')
    implementation('org.springframework.boot:spring-boot-starter-thymeleaf')
}

The com.example.commons plugin is applied, which is a convention plugin that configures the Java version and adds a dependency to a platform (com.example.platform:product-platform from the platforms component). The spring boot plugin is applied. Two dependencies that Spring Boot projects require are also added.

Connecting components

The production code components depend on each other.

To make components (i.e., builds) known to each other, you use the includeBuild statement in the settings file. This does not directly add a dependency between (projects of) components. It simply makes the physical location of one component known to another.

Consider the setup of the server-application component:

server-application/settings.gradle.kts
// == Define locations for build logic ==
pluginManagement {
    repositories {
        gradlePluginPortal()
    }
    includeBuild("../build-logic")
}

// == Define locations for components ==
dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
}
includeBuild("../platforms")
includeBuild("../user-feature")
includeBuild("../admin-feature")

// == Define the inner structure of this component ==
rootProject.name = "server-application" // the component name
include("app")
server-application/settings.gradle
// == Define locations for build logic ==
pluginManagement {
    repositories {
        gradlePluginPortal()
    }
    includeBuild('../build-logic')
}

// == Define locations for components ==
dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
}
includeBuild('../platforms')
includeBuild('../user-feature')
includeBuild('../admin-feature')

// == Define the inner structure of this component ==
rootProject.name = 'server-application' // the component name
include('app')

We see that the settings.gradle(.kts) file only defines the location for build logic components, other production code components, and the inner structure of the component. We need the location of build-logic to apply the com.example.spring-boot-application to the server application component.

The build.gradle(.kts) file in the server-application:app project defines the actual dependencies by applying the com.example.spring-boot-application convention plugin and utilizing the dependencies block:

server-application/app/build.gradle.kts
plugins {
    id("com.example.spring-boot-application")
}

group = "${group}.server-application"

dependencies {
    implementation("com.example.myproduct.user-feature:table")
    implementation("com.example.myproduct.admin-feature:config")

    implementation("org.apache.juneau:juneau-marshall")
}
server-application/app/build.gradle
plugins {
    id('com.example.spring-boot-application')
}

group = "${group}.server-application"

dependencies {
    implementation('com.example.myproduct.user-feature:table')
    implementation('com.example.myproduct.admin-feature:config')

    implementation('org.apache.juneau:juneau-marshall')
}

To declare dependencies between projects of components (i.e., subprojects in builds), you use the dependencies { } block of a build.gradle(.kts) file: implementation("com.example.platform:product-platform"). If the included component provides a plugin, you apply the plugin by ID: plugins { id("com.example.java-library") }