Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 002 / 100

Setting up a modern Java project: lessons from production

JDK 21, a build tool and sensible defaults — the starting point for every service in this series.

Expona Admin6 min read

Why project setup matters

The first hour of a new Java project shapes six months of developer friction. Setting up a modern Java project: lessons from production means making decisions about JDK version, build tooling, and dependency management before you write a single line of business logic. We've seen teams spend weeks troubleshooting build failures, version conflicts, and CI/CD misconfigurations that stemmed from weak foundations. This guide walks you through the setup we use across production services at scale.

Pick an LTS JDK — and manage it per project

Java has a release cadence: new version every six months, LTS (Long Term Support) every three years. Java 21 is the current LTS with support until September 2028. Java 23 arrived in September 2024, Java 25 in September 2025. For production work, stick to LTS versions unless you have a specific reason to chase features. The cost of upgrading to a non-LTS release and then scrambling back to LTS when your dependencies lag is not worth it.

Most teams run multiple projects with different JDK requirements. The old approach—setting JAVA_HOME globally—breaks fast. Use SDKMAN (sdkman.io) to install and switch JDKs per project. It's lightweight, works on macOS, Linux, and WSL, and integrates with your shell in seconds.

Install SDKMAN, then install Java 21 from Temurin (Eclipse Adoptium's distribution, widely used in production):

java
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
sdk install java 21-tem
sdk use java 21-tem
java -version
# openjdk version "21" 2023-09-19
# OpenJDK Runtime Environment Temurin-21.0.x

Drop a .sdkmanrc file in your project root:

java
java=21-tem

Now any developer who has SDKMAN installed runs cd /path/to/project && source ~/.sdkman/bin/sdkman-init.sh once, and sdk env init auto-switches the JDK. No manual fiddling, no "it works on my machine."

Why Temurin? It's free, widely tested, backed by the Eclipse Foundation, and used across fintech and e-commerce deployments. Other options (Amazon Corretto, Azul, Oracle) are equally valid; pick one and standardize across your team.

Maven or Gradle — pick one and commit to it

Both tools work in production. Maven is older, more standardized, easier for new developers to reason about. Gradle is faster for incremental builds and more flexible for complex scenarios. The choice matters less than not switching mid-stream. Pick one per team, document it, and move forward.

Aspect Maven Gradle
Learning curve Gentler; XML is declarative Steeper; Kotlin DSL is powerful
Build speed Slower; full rebuild by default Faster; strong incremental caching
Configuration Convention over configuration; predictable Flexible; can become unwieldy
Dependencies Central Repository (Maven Central) Same, plus better transitive resolution
IDE support Excellent in all IDEs Excellent in all IDEs
Plugin ecosystem Huge, well-documented, stable Growing, sometimes less stable

In our experience, Maven wins for teams with frequent turnover or multiple projects. The pom.xml is boring, but boring is reliable. Gradle shines when you have a monorepo, complex conditional builds, or a stable team that can maintain custom build logic.

For a new Spring Boot microservice, we default to Maven. The generated pom.xml is readable in six months without documentation. For a monorepo or a build-heavy platform, Gradle's incremental build and Kotlin DSL are worth the investment.

Use the build wrapper—commit it to version control

Maven has mvnw (Maven Wrapper), Gradle has gradlew (Gradle Wrapper). These shell scripts ensure every machine—your laptop, your colleague's Mac, the CI/CD runner, the container build—uses the exact same build tool version. No surprises, no "but it builds on my machine."

When you generate a project with Spring Initializr (see next section), the wrapper is included by default. If you're migrating an older project, add it:

For Maven:

java
mvn wrapper:wrapper
git add mvnw mvnw.cmd .mvn/
git commit -m "Add Maven Wrapper for reproducible builds"

For Gradle:

java
gradle wrapper
git add gradlew gradlew.bat gradle/
git commit -m "Add Gradle Wrapper for reproducible builds"

Now in CI/CD, you run ./mvnw clean package or ./gradlew build, not a globally installed tool. Docker containers, GitHub Actions, and Jenkins all use the same version you tested locally.

Start from Spring Initializr

Spring Initializr (start.spring.io) is the canonical way to bootstrap a Spring Boot project. It's not mandatory, but it solves three problems at once: correct project structure, pre-configured plugins, and sensible defaults for common dependencies.

Open start.spring.io in your browser or use the CLI:

java
curl https://start.spring.io/starter.zip \
  -d language=java \
  -d javaVersion=21 \
  -d groupId=com.example \
  -d artifactId=payment-service \
  -d dependencies=web,data-jpa,postgresql \
  -o payment-service.zip
unzip payment-service.zip && cd payment-service

Or via the web UI: select Java 21, pick your build tool (Maven/Gradle), add dependencies (Spring Web, Spring Data JPA, PostgreSQL driver, etc.), and download. You get:

  • A folder structure that every Spring developer recognizes
  • A pom.xml or build.gradle with Spring Boot's parent BOM (Bill of Materials) pinning transitive versions
  • Maven Wrapper or Gradle Wrapper pre-installed
  • Application properties file with basic structure
  • A runnable main class and example test

The parent BOM is crucial. It manages versions of dozens of libraries (Jackson, Tomcat, Logback, Hibernate, etc.) so you don't have to. If Spring Boot 3.3 ships with Hibernate 6.5, you inherit that. No version hell.

For a simple REST API with PostgreSQL, your generated pom.xml looks like:

java
<project>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.0</version>
  </parent>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>org.postgresql</groupId>
      <artifactId>postgresql</artifactId>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
</project>

Notice: no version on any dependency. The parent BOM handles it. You want to upgrade PostgreSQL driver? Change the parent version, not individual dependencies.

Beyond the scaffold: sensible defaults

Spring Boot gives you opinionated defaults out of the box. The embedded Tomcat is tuned for typical workloads. JSON serialization (Jackson) is configured for REST. Logging goes to the console with reasonable formatting. Actuator endpoints expose health and metrics.

You can override anything, but the defaults are battle-tested across thousands of production deployments. Resist the urge to customize in week one. Use defaults for three months, then refine if needed.

One practical addition: add spring-boot-configuration-processor in the build. It generates metadata for your custom properties so your IDE autocompletes them:

java
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-configuration-processor</artifactId>
  <optional>true</optional>
</dependency>

Common mistakes

  • Skipping the JDK version file. Without .sdkmanrc, your next hire runs an old JDK and wonders why tests fail. Commit it day one.
  • Not committing the build wrapper. The wrapper script is small; commit it. If CI installs Maven or Gradle globally, you've defeated the purpose.
  • Mixing major build tool versions across developers. Maven 3.8 and Maven 3.6 behave differently with security policies. Gradle 7 and 8 have breaking changes. The wrapper solves this; use it.
  • Adding random dependencies at initialization. Only add what you know you need. Spring Initializr has 50+ options. Adding "everything" bloats your classpath and slows startup. Add dependencies when your code needs them.
  • Ignoring the IDE setup. Import the Maven or Gradle project properly in IntelliJ or VS Code. A misconfigured IDE can mask build issues for weeks.

Key takeaways

  • Use Java 21 LTS from Temurin; manage per-project with SDKMAN and .sdkmanrc.
  • Choose Maven or Gradle for your team; consistency beats optimization.
  • Commit the build wrapper (mvnw/gradlew) to ensure reproducible builds across all environments.
  • Bootstrap with Spring Initializr at start.spring.io; the parent BOM and structure save time and prevent mistakes.
  • Accept Spring Boot's defaults; they're proven in production. Customize only when you measure a concrete benefit.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles