Command Palette

Search for a command to run...

[Spring Boot Basics] Productivity Tools in Spring Boot: DevTools, Lombok and Actuator Basics

Chapter 7 is about the tools that sit around the application code rather than inside a layer of it. Spring Initializr lists three of them next to the usual starters: DevTools, which restarts the application when a class changes; Lombok, which generates getters, constructors and loggers at compile time; and Actuator, which adds operational endpoints such as /actuator/health and /actuator/info. All three are easy to add and each has a failure mode that tutorials rarely show: a restart that happens when you did not expect one, an entity whose toString takes the request down, a health endpoint that shows your database to anyone.

The examples use Spring Boot 4.1.1 and Java 21, and the app runs on port 8139 instead of the default 8080. Timings are indicative, and each is labelled with the one-minute load average at the time it was taken. Long paths in the output are shortened to /…/.

Three tool cards for DevTools, Lombok and Actuator: a restart arrow, an annotation sign and a health pulse

The article takes the tools in the order you meet them in a working day: DevTools while you edit, Lombok while you write classes, Actuator once the application runs somewhere.

The project: devtools, lombok and actuator from Spring Initializr

The project is generated with the catalogue's usual dependencies plus the three tools:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,validation,data-jpa,h2,devtools,lombok,actuator" -o demo.zip

Each tool lands in a different dependency configuration, and that choice is the first thing worth understanding about it:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-h2console'
	implementation 'org.springframework.boot:spring-boot-starter-actuator'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-actuator-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testCompileOnly 'org.projectlombok:lombok'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
	testAnnotationProcessor 'org.projectlombok:lombok'
}
ToolGradle configurationMavenOn the compile classpathIn the packaged jar
DevToolsdevelopmentOnlyruntime + optionalnono
LombokcompileOnly + annotationProcessoroptional + annotationProcessorPathsyesno
Actuatorimplementationcompile scopeyesyes

./gradlew dependencies resolved Lombok to 1.18.46, the version Spring Boot 4.1.1 manages, and showed the Actuator starter bringing four Boot modules: spring-boot-actuator, spring-boot-actuator-autoconfigure, spring-boot-health and spring-boot-micrometer-metrics with Micrometer 1.17.1. The Maven project from the same Initializr request was built with ./mvnw package: its jar contained no Lombok and no DevTools jar, although the pom has no <excludes> for them.

The application code is the product catalogue of the earlier chapters in small: the Product entity with an IDENTITY key, a unique sku and a numeric(10,2) price, a ProductRepository, a seeder that inserts two products, and a controller:

src/main/java/com/example/demo/product/ProductController.java
@RestController
@RequestMapping("/api/products")
public class ProductController {
 
    private final ProductRepository repository;
 
    public ProductController(ProductRepository repository) {
        this.repository = repository;
    }
 
    @GetMapping
    public List<ProductResponse> findAll() {
        return repository.findAll(Sort.by("id")).stream().map(ProductResponse::from).toList();
    }
 
    @GetMapping("/version")
    public String version() {
        return "v1";
    }
}
src/main/resources/application.properties
spring.application.name=demo
server.port=8139
spring.datasource.url=jdbc:h2:mem:catalog
spring.jpa.open-in-view=false

GET /api/products/version exists only to be edited while the application runs.

Spring Boot DevTools

What DevTools changes and why it is developmentOnly

Started with ./gradlew bootRun, the application's log already shows DevTools at work:

Text
[  restartedMain] com.example.demo.DemoApplication         : Starting DemoApplication using Java 21.0.6 with PID 46199 (/…/demo/build/classes/java/main started by you in /…/demo)
[  restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable
[  restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG'
[  restartedMain] o.s.b.h.a.H2ConsoleAutoConfiguration     : H2 console available at '/h2-console'. Database available at 'jdbc:h2:mem:catalog'
[  restartedMain] com.example.demo.DemoApplication         : Started DemoApplication in 1.494 seconds (process running for 1.641)
  • restartedMain is the thread name. DevTools does not run the application on main: it starts it again on a new thread inside a classloader it controls, which is what makes the restart possible.
  • Devtools property defaults active! means DevTools added development-friendly property values, listed in a section below. The H2 console line is one of them: spring.h2.console.enabled defaults to false and DevTools turns it on.

DevTools restarts the application whenever the classpath changes, which is exactly what production must never do, and it serves the H2 console and full stack traces to anyone. developmentOnly puts it on the classpath of bootRun and nowhere else. The packaged jar proves it:

Bash
./gradlew bootJar
jar tf build/libs/demo-0.0.1-SNAPSHOT.jar | grep -E 'devtools|lombok'

The grep printed nothing and exited with status 1: of the 80 jars under BOOT-INF/lib/, none is DevTools or Lombok. Run with java -jar build/libs/demo-0.0.1-SNAPSHOT.jar, the same application logged on main, printed no DevTools line and no H2 console line, and started in 1.957, 1.638 and 1.787 seconds over three runs.

DevTools also refuses to activate when it is in the jar. With bootJar { classpath configurations.developmentOnly } added for one build, BOOT-INF/lib/spring-boot-devtools-4.1.1.jar was in the jar, yet java -jar still ran on main, logged no Devtools property defaults active! and no H2 console. DevTools 4.1.1 only enables restart when the main thread's classloader is the JDK's application classloader, and under java -jar it is Spring Boot's LaunchedClassLoader. The Spring Boot reference calls such an application a "production application"; -Dspring.devtools.restart.enabled=true overrides the check, which is only for special cases.

How automatic restart works: two classloaders

A small CommandLineRunner shows where the classes come from:

src/main/java/com/example/demo/ClassLoaderReport.java
@Component
class ClassLoaderReport implements CommandLineRunner {
 
    @Override
    public void run(String... args) {
        System.out.println("your class:    " + getClass().getClassLoader());
        System.out.println("Spring Boot:   " + SpringApplication.class.getClassLoader());
        System.out.println("parent of yours: " + getClass().getClassLoader().getParent());
    }
}

Under ./gradlew bootRun:

Text
your class:    org.springframework.boot.devtools.restart.classloader.RestartClassLoader@657e2a5a
Spring Boot:   jdk.internal.loader.ClassLoaders$AppClassLoader@2c854dc5
parent of yours: jdk.internal.loader.ClassLoaders$AppClassLoader@2c854dc5

Under java -jar, the first two lines both printed org.springframework.boot.loader.launch.LaunchedClassLoader@378bf509.

DevTools splits the classpath in two. Everything in a jar, Spring, Hibernate, Tomcat, H2 and the other 80-odd libraries, stays in the base classloader, the JDK's AppClassLoader. The project's own output directories, build/classes/java/main and build/resources/main, are loaded by a restart classloader whose parent is the base one. A File Watcher thread polls those directories. When something changes, DevTools closes the application context, throws the restart classloader away, creates a new one and runs main again on a new restartedMain thread, in the same JVM.

Changing "v1" to "v2" in the controller and running ./gradlew classes in another terminal produced:

Text
[   File Watcher] rtingClassPathChangeChangedEventListener : Restarting due to 1 class path change (0 additions, 0 deletions, 1 modification)
[       Thread-1] o.s.boot.tomcat.GracefulShutdown         : Commencing graceful shutdown. Waiting for active requests to complete
[tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown         : Graceful shutdown complete
[       Thread-1] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
[       Thread-1] o.s.b.f.support.DisposableBeanAdapter    : Invocation of destroy method failed on bean with name 'inMemoryDatabaseShutdownExecutor': org.h2.jdbc.JdbcSQLNonTransientConnectionException: Database is already closed (to disable automatic closing at VM shutdown, add ";DB_CLOSE_ON_EXIT=FALSE" to the db URL) [90121-240]
[       Thread-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.
[  restartedMain] com.example.demo.DemoApplication         : Started DemoApplication in 0.223 seconds (process running for 14.396)
[  restartedMain] .ConditionEvaluationDeltaLoggingListener : Condition evaluation unchanged

curl http://localhost:8139/api/products/version answered v2. The PID did not change and process running for 14.396 counts from the original start: the JVM stayed, only the context was rebuilt. The WARN comes from inMemoryDatabaseShutdownExecutor, a bean DevTools adds to shut an in-memory database down on restart; on this H2 version it found the database already closed and logged it on every restart, with no effect on the restarted application. Condition evaluation unchanged says the restart did not change which auto-configurations apply.

A restart is faster than a cold start because the classes of those 80 jars are already loaded in the base classloader, and their hot code already JIT-compiled, when the new context starts. Measured on the same project:

StartRunsStarted … in, bestLoad average
Cold ./gradlew bootRun (DevTools present)61.488 s3.02
java -jar (no DevTools)31.638 s2.77
DevTools restart after a controller change100.185 s2.63
From saving the file to Started, with ./gradlew -t classes32.02 s2.63

The restart rebuilt the context in an eighth of the cold start time. The last row is what you actually wait for: Gradle noticing the change and compiling, then DevTools' watcher, which polls every second (spring.devtools.restart.poll-interval, default 1s) and waits for 400 ms without further changes (spring.devtools.restart.quiet-period, default 400ms) so that a compile writing many class files causes one restart, not several. The restart itself was the smallest part of those two seconds.

DevTools splits the classpath: jars stay in the base AppClassLoader while build/classes and build/resources are loaded by a RestartClassLoader that the File Watcher discards and recreates on a change, with the measured 1.488 s cold start against the 0.185 s restart

Triggering a restart from Gradle and from the IDE

DevTools watches build/classes and build/resources, not src, so a restart needs something to compile. From the command line, a second terminal runs Gradle in continuous mode:

Bash
./gradlew -t classes
Text
Waiting for changes to input files...
new file: /…/demo/src/main/java/com/example/demo/product/ProductController.java
Change detected, executing build...
 
> Task :compileJava
> Task :processResources UP-TO-DATE
> Task :classes
 
BUILD SUCCESSFUL in 468ms

The build took 468 ms, and the bootRun terminal logged Restarting due to 1 class path change right after. Nine of the ten restarts in the table above were driven this way. A one-off ./gradlew classes does the same without watching; the Spring Boot 4.1.1 reference names gradle build and mvn compile.

In an IDE the IDE's own compiler writes the output. The Spring Boot reference states that in IntelliJ IDEA building the project (Build -> Build Project) triggers the restart, and that in Eclipse saving a modified file does. JetBrains documents an Update Running Application action for Spring Boot run configurations, with an "On 'Update' action" and an "On frame deactivation" policy that can build the project or touch a trigger file. These IDE paths were not run for this article.

What does not trigger a restart

Not every file in the watched directories restarts the application. The default of spring.devtools.restart.exclude, read from the 4.1.1 configuration metadata:

Text
META-INF/maven/**,META-INF/resources/**,resources/**,static/**,public/**,templates/**,**/*Test.class,**/*Tests.class,git.properties,META-INF/build-info.properties

Static resources and templates are served straight from the classpath directory, so a change there needs no restart. Writing <p>hello v4</p> directly into build/resources/main/static/hello.html caused no log line at all, and the next GET /hello.html returned the new content with Cache-Control: no-store. Appending one line to build/resources/main/application.properties restarted the application within five seconds: Restarting due to 1 class path change (0 additions, 0 deletions, 1 modification).

With Gradle there is a catch. Editing src/main/resources/static/hello.html and running ./gradlew processResources did restart the application, with Restarting due to 2 class path changes (0 additions, 0 deletions, 2 modifications). processResources copies every resource again when any of them changes: the modification time of build/resources/main/application.properties moved from 15:04:23 to 15:04:45 although its source was untouched, and application.properties is not excluded. The same happens with -t classes, which runs processResources. To edit static files and templates without restarts under Gradle, let bootRun read resources from the source directory:

build.gradle
tasks.named('bootRun') {
	sourceResources sourceSets.main
}

With that, an edit to src/main/resources/static/hello.html was served on the next request with no restart and no build, while an edit to src/main/resources/application.properties still restarted the application. To exclude more paths without losing the defaults, use spring.devtools.restart.additional-exclude.

The property defaults DevTools applies

Devtools property defaults active! refers to values that Spring Boot modules declare in a META-INF/spring-devtools.properties file of their own, under a defaults. prefix, and that DevTools adds as a low-priority property source named devtools. Listing those files across the project's runtime classpath and the spring-boot-thymeleaf 4.1.1 jar:

PropertyWith DevToolsNormal defaultDeclared in
spring.h2.console.enabledtruefalsespring-boot-h2console
spring.web.error.include-stacktracealwaysneverspring-boot-autoconfigure
spring.web.error.include-messagealwaysneverspring-boot-autoconfigure
spring.web.error.include-binding-errorsalwaysneverspring-boot-autoconfigure
spring.web.resources.cache.period0not setspring-boot-autoconfigure
spring.web.resources.chain.cachefalsetruespring-boot-autoconfigure
spring.template.provider.cachefalsenot in the metadataspring-boot-autoconfigure
spring.mvc.log-resolved-exceptiontruefalsespring-boot-webmvc
server.servlet.session.persistenttruefalsespring-boot-web-server
server.servlet.jsp.init-parameters.developmenttruenot setspring-boot-web-server
spring.thymeleaf.cachefalsetruespring-boot-thymeleaf, when Thymeleaf is on the classpath

The error properties are the ones you notice first. Under bootRun, every Spring Boot error body in this article carried a "trace" field with the whole stack trace and a "message" field, which the packaged application, with the normal defaults of never, leaves out. That is convenient locally and one more reason DevTools must stay out of production. spring.devtools.add-properties=false switches the defaults off.

LiveReload, remote DevTools and global settings

DevTools used to start a LiveReload server that told a browser extension to refresh the page. It is deprecated as of Spring Boot 4.1.0 with no replacement, and spring.devtools.livereload.enabled now defaults to false, so none of the runs above logged a LiveReload server.

  • Remote DevTools restarts an application running elsewhere from local changes through RemoteSpringApplication and spring.devtools.remote.secret; it needs DevTools packaged into the jar, and the reference says never to enable it on a production deployment.
  • spring.devtools.restart.enabled=false in application.properties stops the watching but still starts the restart classloader; to remove it completely, set the system property before SpringApplication.run.
  • Global settings for every project on the machine go in ~/.config/spring-boot/spring-boot-devtools.properties (or .yaml, .yml), the file names DevTools 4.1.1 looks for.

Lombok

Lombok setup and what an annotation processor is

The Initializr setup is above: compileOnly so the annotations compile, annotationProcessor so javac runs Lombok, and the test… pair for test sources. Lombok is not needed at runtime, and the jar listing showed it is not packaged.

An annotation processor is a plugin that javac runs during compilation. It receives the annotated elements of the source files and, through the standard API, may generate new source files; that is how MapStruct and Spring Boot's configuration processor work. Lombok goes further than the standard API allows. It uses javac's internal classes to add methods to the syntax tree of the class being compiled, so the generated getters exist in the .class file and nowhere in the source. That design is where both its convenience and its costs come from.

A class annotated with @Data goes through javac with the Lombok annotation processor, which adds getters, setters, equals, hashCode and toString to the bytecode; beside it, the four entity traps reproduced with their real exceptions

A two-field class with @Data:

src/main/java/com/example/demo/lab/CustomerForm.java
package com.example.demo.lab;
 
import lombok.Data;
 
@Data
public class CustomerForm {
 
    private String email;
    private String fullName;
}
Bash
javap -p -cp build/classes/java/main com.example.demo.lab.CustomerForm
Text
Compiled from "CustomerForm.java"
public class com.example.demo.lab.CustomerForm {
  private java.lang.String email;
  private java.lang.String fullName;
  public com.example.demo.lab.CustomerForm();
  public java.lang.String getEmail();
  public java.lang.String getFullName();
  public void setEmail(java.lang.String);
  public void setFullName(java.lang.String);
  public boolean equals(java.lang.Object);
  protected boolean canEqual(java.lang.Object);
  public int hashCode();
  public java.lang.String toString();
}

@Data is @Getter, @Setter, @RequiredArgsConstructor, @ToString and @EqualsAndHashCode together. The equals and hashCode it generated read every field, and toString prints every field; keep that in mind for the entity section.

@Getter, @Setter, @RequiredArgsConstructor and @Slf4j in a service

The most common use in Spring code is a service:

src/main/java/com/example/demo/lab/PriceService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class PriceService {
 
    private final ProductRepository repository;
 
    public BigDecimal total() {
        BigDecimal total = repository.findAll().stream()
                .map(p -> p.getPrice())
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        log.info("Catalogue total is {}", total);
        return total;
    }
}
Text
public class com.example.demo.lab.PriceService {
  private static final org.slf4j.Logger log;
  private final com.example.demo.product.ProductRepository repository;
  public java.math.BigDecimal total();
  public com.example.demo.lab.PriceService(com.example.demo.product.ProductRepository);
  private static java.math.BigDecimal lambda$total$0(com.example.demo.product.Product);
  static {};
}
  • @RequiredArgsConstructor generated a public constructor taking every final field. With one constructor Spring injects through it, so this is ordinary constructor injection.
  • @Slf4j generated private static final org.slf4j.Logger log, the field article 14 wrote by hand.
  • @Getter and @Setter on a class or a field generate what @Data generated above, without equals, hashCode and toString.

@Value and @Builder compared with Java records

For a read-only DTO Lombok offers @Value, and @Builder for a builder. The series writes DTOs as records. The same three fields three ways:

src/main/java/com/example/demo/lab/ProductValue.java
@Value
public class ProductValue {
 
    String sku;
    String name;
    BigDecimal price;
}
src/main/java/com/example/demo/lab/ProductView.java
@Value
@Builder
public class ProductView {
 
    String sku;
    String name;
    BigDecimal price;
}
src/main/java/com/example/demo/lab/ProductRecord.java
public record ProductRecord(String sku, String name, BigDecimal price) {
}

javap -p on each:

Text
public final class com.example.demo.lab.ProductValue {
  private final java.lang.String sku;
  private final java.lang.String name;
  private final java.math.BigDecimal price;
  public com.example.demo.lab.ProductValue(java.lang.String, java.lang.String, java.math.BigDecimal);
  public java.lang.String getSku();
  public java.lang.String getName();
  public java.math.BigDecimal getPrice();
  public boolean equals(java.lang.Object);
  public int hashCode();
  public java.lang.String toString();
}
public final class com.example.demo.lab.ProductView {
  private final java.lang.String sku;
  private final java.lang.String name;
  private final java.math.BigDecimal price;
  com.example.demo.lab.ProductView(java.lang.String, java.lang.String, java.math.BigDecimal);
  public static com.example.demo.lab.ProductView$ProductViewBuilder builder();
  public java.lang.String getSku();
  public java.lang.String getName();
  public java.math.BigDecimal getPrice();
  public boolean equals(java.lang.Object);
  public int hashCode();
  public java.lang.String toString();
}
public final class com.example.demo.lab.ProductRecord extends java.lang.Record {
  private final java.lang.String sku;
  private final java.lang.String name;
  private final java.math.BigDecimal price;
  public com.example.demo.lab.ProductRecord(java.lang.String, java.lang.String, java.math.BigDecimal);
  public final java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public java.lang.String sku();
  public java.lang.String name();
  public java.math.BigDecimal price();
}

@Value and a record produce nearly the same class: final, private final fields, one constructor, value-based equals, hashCode and toString. The record needs no library and its accessors are sku() rather than getSku(). Adding @Builder to @Value made the all-arguments constructor package-private, which matters as soon as Jackson reads the class. The same JSON body was posted to a scratch endpoint taking each type as @RequestBody:

@RequestBody typeResponse
ProductRecord (record)200, ProductRecord[sku=KB-001, name=Mechanical keyboard, price=89.90]
ProductValue (@Value)200, ProductValue(sku=KB-001, name=Mechanical keyboard, price=89.90)
CustomerForm (@Data)200, CustomerForm(email=a@b.c, fullName=Alice)
ProductView (@Value + @Builder)500

The 500 logged:

Text
tools.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.example.demo.lab.ProductView` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)

Lombok's answer is @Jacksonized, which makes Jackson use the builder. On Spring Boot 4 it did not compile as is:

Text
ProductView.java:11: warning: Ambiguous: Jackson2 and Jackson3 exist; define which variant(s) you want in 'lombok.config'. See https://projectlombok.org/features/experimental/Jacksonized
@Jacksonized
^
ProductView.java:11: error: package com.fasterxml.jackson.databind.annotation does not exist
@Jacksonized
^

Lombok defaults to generating Jackson 2 annotations, and Jackson 2's @JsonDeserialize lives in com.fasterxml.jackson.databind.annotation, which is not on the classpath: Spring Boot 4 uses Jackson 3, whose databind package is tools.jackson.databind. One line in lombok.config at the project root fixed it, and the same POST answered 200:

lombok.config
config.stopBubbling = true
lombok.jacksonized.jacksonVersion += 3
Java recordLombok @ValueLombok @Data
Needs a library and annotation processingnoyesyes
Accessorssku()getSku()getSku(), setSku(…)
Immutable, final classyesyesno
equals, hashCode, toStringyesyesyes
Jackson 3 request bodyyesyes, with its public constructoryes, through setters
Builderno, unless you add Lombok @Builder, which works on a record@Builder; Jackson then needs @Jacksonized configured for Jackson 3@Builder
Record patterns in switch and instanceofyesnono

For DTOs, records already give everything @Value gives, without a build-time dependency.

Trap 1: @Data on a bidirectional relationship

Entities are where Lombok causes real failures. The order of article 28 with @Data on both sides of its @OneToMany/@ManyToOne relationship:

src/main/java/com/example/demo/order/Order.java
@Entity
@Table(name = "orders")
@Data
public class Order {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false)
    private String customerEmail;
 
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderLine> lines = new ArrayList<>();
 
    public void addLine(OrderLine line) {
        lines.add(line);
        line.setOrder(this);
    }
}
src/main/java/com/example/demo/order/OrderLine.java
@Entity
@Table(name = "order_lines")
@Data
public class OrderLine {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;
 
    @Column(nullable = false)
    private int quantity;
 
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal unitPrice;
}

A service creates an order with one line and logs it before saving:

src/main/java/com/example/demo/order/OrderService.java
    @Transactional
    public Order create(CreateOrderRequest request) {
        Product product = products.getReferenceById(request.productId());
        Order order = new Order();
        order.setCustomerEmail(request.customerEmail());
        OrderLine line = new OrderLine();
        line.setProduct(product);
        line.setQuantity(request.quantity());
        line.setUnitPrice(product.getPrice());
        order.addLine(line);
        log.info("Creating {}", order);
        return orders.save(order);
    }

Order.toString() prints lines, each OrderLine.toString() prints its order, which prints lines again. POST /api/orders returned 200 and the order was saved, but the log showed:

Text
SLF4J(E): Failed toString() invocation on an object of type [com.example.demo.order.Order]
SLF4J(E): Reported exception:
java.lang.StackOverflowError
	at java.base/java.util.AbstractCollection.toString(AbstractCollection.java:451)
	at java.base/java.lang.String.valueOf(String.java:4465)
	at com.example.demo.order.Order.toString(Order.java:18)
	at java.base/java.lang.String.valueOf(String.java:4465)
	at com.example.demo.order.OrderLine.toString(OrderLine.java:19)
	at java.base/java.lang.String.valueOf(String.java:4465)
	at java.base/java.lang.StringBuilder.append(StringBuilder.java:173)
	at java.base/java.util.AbstractCollection.toString(AbstractCollection.java:459)
[nio-8139-exec-1] com.example.demo.order.OrderService      : Creating [FAILED toString()]

SLF4J 2 formats {} arguments defensively: it caught the StackOverflowError, printed it to standard error and logged [FAILED toString()] in place of the order. The bug is still there, only hidden. Written as log.info("Creating " + order), which calls toString() before SLF4J sees anything, the same request returned 500:

Text
[nio-8139-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Handler dispatch failed: java.lang.StackOverflowError] with root cause
 
java.lang.StackOverflowError
	at com.example.demo.order.Order.toString(Order.java:18) ~[main/:na]
	at com.example.demo.order.OrderLine.toString(OrderLine.java:19) ~[main/:na]
	at com.example.demo.order.Order.toString(Order.java:18) ~[main/:na]
	at com.example.demo.order.OrderLine.toString(OrderLine.java:19) ~[main/:na]

The generated hashCode has the same cycle. A test that adds an order with one line to a HashSet:

src/test/java/com/example/demo/tag/TagEqualityTest.java
    @Test
    void orderInHashSet() {
        Order order = new Order();
        order.setCustomerEmail("alice@example.com");
        order.addLine(new OrderLine());
        Set<Order> orders = new HashSet<>();
        orders.add(order);
    }
Text
TagEqualityTest > orderInHashSet() FAILED
    java.lang.StackOverflowError
        at com.example.demo.order.Order.getId(Order.java:23)
        at com.example.demo.order.Order.hashCode(Order.java:18)
        at com.example.demo.order.OrderLine.hashCode(OrderLine.java:19)
        at com.example.demo.order.Order.hashCode(Order.java:18)
        at com.example.demo.order.OrderLine.hashCode(OrderLine.java:19)

Trap 2: @EqualsAndHashCode over all fields in a HashSet

Without a relationship there is no recursion, but an all-fields hashCode includes the id, and JPA assigns the id when the entity is persisted. A Tag entity with @Data, an IDENTITY id and a unique name, tested with @DataJpaTest:

src/test/java/com/example/demo/tag/TagEqualityTest.java
    @Test
    void tagIsLostInHashSetAfterSave() {
        Tag tag = new Tag();
        tag.setName("sale");
        Set<Tag> tags = new HashSet<>();
        tags.add(tag);
        System.out.println(">>> before save: id=" + tag.getId() + ", hashCode=" + tag.hashCode());
 
        repository.save(tag);
 
        System.out.println(">>> after save:  id=" + tag.getId() + ", hashCode=" + tag.hashCode());
        System.out.println(">>> tags.contains(tag) = " + tags.contains(tag));
        System.out.println(">>> tags.size() = " + tags.size() + ", tags.remove(tag) = " + tags.remove(tag));
        assertThat(tags.contains(tag)).isTrue();
    }
Text
    >>> before save: id=null, hashCode=3528649
    >>> after save:  id=1, hashCode=3526171
    >>> tags.contains(tag) = false
    >>> tags.size() = 1, tags.remove(tag) = false
TagEqualityTest > tagIsLostInHashSetAfterSave() FAILED
    org.opentest4j.AssertionFailedError: 
    Expecting value to be true but was false

The set still holds the tag, stored in the bucket of hash code 3528649; looked up with 3526171, it is neither found nor removable. Restricting the generated methods to the id does not help: an IdTag entity with @EqualsAndHashCode(onlyExplicitlyIncluded = true) and @EqualsAndHashCode.Include on the id printed >>> id-only: hashCode 102 -> 60, contains=false in a test of the same shape. Article 26 explained the fix, equality on the id with a constant hashCode, and Lombok cannot generate that.

Trap 3: toString on a lazy association after the transaction

With spring.jpa.open-in-view=false, as in the whole series, the persistence context closes with the service's transaction. A controller that logs the entity it received:

src/main/java/com/example/demo/order/OrderController.java
    @GetMapping("/{id}")
    public String findById(@PathVariable long id) {
        Order order = service.findById(id);
        log.info("Loaded " + order);
        return "order " + order.getId();
    }

GET /api/orders/1 returned 500, and the body (with the DevTools stack trace cut) carried:

Text
"message":"Cannot lazily initialize collection of role 'com.example.demo.order.Order.lines' with key '1' (no session)"
Text
org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'com.example.demo.order.Order.lines' with key '1' (no session)
	at org.hibernate.collection.spi.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:664)
	at org.hibernate.collection.spi.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:239)
	at org.hibernate.collection.spi.AbstractPersistentCollection.initialize(AbstractPersistentCollection.java:624)
	at org.hibernate.collection.spi.AbstractPersistentCollection.read(AbstractPersistentCollection.java:149)
	at org.hibernate.collection.spi.PersistentBag.toString(PersistentBag.java:637)
	at java.base/java.lang.String.valueOf(String.java:4465)
	at com.example.demo.order.Order.toString(Order.java:18)
	at java.base/java.lang.String.valueOf(String.java:4465)
	at com.example.demo.order.OrderController.findById(OrderController.java:28)

The controller never touched lines; the generated toString did. With log.info("Loaded {}", order) the request returned 200 and the log read Loaded [FAILED toString()], SLF4J again swallowing the exception.

Trap 4: @Builder on a JPA entity

@Builder on the @Data order is the next temptation. The build failed where the service still wrote new Order():

Text
OrderService.java:22: error: constructor Order in class Order cannot be applied to given types;
        Order order = new Order();
                      ^
  required: Long,String,List<OrderLine>
  found:    no arguments
  reason: actual and formal argument lists differ in length
Order.java:31: warning: @Builder will ignore the initializing expression entirely. If you want the initializing expression to serve as default, add @Builder.Default. If it is not supposed to be settable during building, make the field final.
    private List<OrderLine> lines = new ArrayList<>();
                            ^

@Builder adds a package-private all-arguments constructor, and @Data then generates no constructor of its own, so the no-args constructor JPA requires is gone. With the service switched to Order.builder(), a @DataJpaTest showed both consequences:

src/test/java/com/example/demo/order/OrderBuilderTest.java
    @Test
    void builderLeavesLinesNull() {
        Order order = Order.builder().customerEmail("alice@example.com").build();
        System.out.println(">>> lines = " + order.getLines());
        order.addLine(new OrderLine());
    }
 
    @Test
    void readingAnOrderBackNeedsANoArgsConstructor() {
        Order order = Order.builder().customerEmail("alice@example.com").lines(new ArrayList<>()).build();
        Long id = entityManager.persistAndFlush(order).getId();
        entityManager.clear();
        System.out.println(">>> saved order " + id + ", reading it back");
        repository.findById(id);
    }
Text
[    Test worker] org.hibernate.orm.core                   : HHH000182: No default (no-argument) constructor for class [com.example.demo.order.Order] (class must be instantiated by Interceptor)
    >>> saved order 1, reading it back
OrderBuilderTest > readingAnOrderBackNeedsANoArgsConstructor() FAILED
    org.springframework.orm.jpa.JpaSystemException: No default constructor for entity 'com.example.demo.order.Order'
        Caused by:
        org.hibernate.InstantiationException: No default constructor for entity 'com.example.demo.order.Order'
    >>> lines = null
OrderBuilderTest > builderLeavesLinesNull() FAILED
    java.lang.NullPointerException: Cannot invoke "java.util.List.add(Object)" because "this.lines" is null
        at com.example.demo.order.Order.addLine(Order.java:34)

Saving worked, reading the row back did not, and the warning above came true: the builder ignored = new ArrayList<>(), so addLine threw. Adding a plain @NoArgsConstructor next to @Builder broke the build instead, because the builder then had no all-arguments constructor to call:

Text
Order.java:21: error: constructor Order in class Order cannot be applied to given types;
@Builder
^
  required: no arguments
  found:    Long,String,List<OrderLine>

The combination that compiled without warnings and passed all three checks, a builder with an empty lines, a no-args constructor with an empty lines, and a row read back, was:

src/main/java/com/example/demo/order/Order.java
@Entity
@Table(name = "orders")
@Data
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Order {
 
    // id and customerEmail as before
 
    @Builder.Default
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderLine> lines = new ArrayList<>();

Four annotations to recover what a constructor gives, and @Data with traps 1 to 3 is still on the class.

The safe Lombok subset for entities

What remains safe on an entity is the part that does not guess: accessors, and a toString limited to plain columns. Replacing @Data on both classes:

src/main/java/com/example/demo/order/Order.java
@Entity
@Table(name = "orders")
@Data
@Getter
@ToString(onlyExplicitlyIncluded = true) 
@NoArgsConstructor(access = AccessLevel.PROTECTED) 
public class Order {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @ToString.Include
    private Long id;
 
    @Column(nullable = false)
    @ToString.Include
    private String customerEmail;
 
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderLine> lines = new ArrayList<>();
 
    public Order(String customerEmail) { 
        this.customerEmail = customerEmail; 
    } 
 
    public void addLine(OrderLine line) {
        lines.add(line);
        line.setOrder(this);
    }
 
    @Override
    public boolean equals(Object o) { 
        if (this == o) return true; 
        if (!(o instanceof Order other)) return false; 
        return id != null && id.equals(other.getId()); 
    } 
 
    @Override
    public int hashCode() { 
        return Order.class.hashCode(); 
    } 
}
src/main/java/com/example/demo/order/OrderLine.java
@Entity
@Table(name = "order_lines")
@Data
@Getter
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED) 
public class OrderLine {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id", nullable = false)
    @Setter(AccessLevel.PACKAGE) 
    @ToString.Exclude
    private Order order;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "product_id", nullable = false)
    @ToString.Exclude
    private Product product;
 
    // quantity, unitPrice, a constructor taking product and quantity,
    // and equals and hashCode on the id, written as in Order
}

With the service using new Order(email) and new OrderLine(product, quantity) and both log statements left as string concatenation, POST /api/orders and GET /api/orders/1 both returned 200 and logged:

Text
[nio-8139-exec-1] com.example.demo.order.OrderService      : Creating Order(id=null, customerEmail=alice@example.com)
[nio-8139-exec-2] com.example.demo.order.OrderController   : Loaded Order(id=1, customerEmail=alice@example.com)

A @DataJpaTest put the new order in a HashSet before persisting it:

Text
    >>> before persist: Order(id=null, customerEmail=alice@example.com) [OrderLine(id=null, quantity=2, unitPrice=89.90)]
    >>> after persist:  Order(id=1, customerEmail=alice@example.com) [OrderLine(id=1, quantity=2, unitPrice=89.90)] contains=true
On a JPA entityVerdictWhy
@Gettersafeplain accessors
@Setter on the classavoidopens id and collections to any caller; put @Setter on the fields that change
@ToString(onlyExplicitlyIncluded = true) with @ToString.Include on columnssafeno associations, no recursion, no lazy loading
@ToString.Exclude on every associationsafe, but every new association must remember ittrap 3 is what a missing exclude does
@NoArgsConstructor(access = PROTECTED)safethe constructor JPA needs
@Datado not usetraps 1, 2 and 3
@EqualsAndHashCode, even onlyExplicitlyIncluded on the iddo not usetrap 2: the hash changes when the id is assigned; write id-based equals with a constant hashCode
@Builderavoidremoves the no-args constructor, ignores field initializers without @Builder.Default
@Valuedo not usefinal fields and no no-args constructor, the two things a record entity failed on in article 26

What Lombok costs outside the code

  • Tooling. Every tool that reads your source has to understand Lombok: the IDE, static analysis, a code search. IntelliJ IDEA has bundled the Lombok plugin since 2020.3, according to Lombok's IntelliJ setup page; an editor that does not run Lombok cannot see methods that exist only in the bytecode.
  • The JDK. Because Lombok reaches into javac's internal classes, each new JDK needs a Lombok release. Its changelog lists "Initial JDK21 support" in 1.18.30, JDK 25 in 1.18.40, JDK 26 in 1.18.46 and JDK 27 in 1.18.48 of 1 September 2026, while Spring Boot 4.1.1 manages 1.18.46. Pinning an older Lombok on JDK 21 shows what that means:
build.gradle
ext['lombok.version'] = '1.18.28'
Text
> Task :compileJava FAILED
 
* What went wrong:
Execution failed for task ':compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> java.lang.NoSuchFieldError: Class com.sun.tools.javac.tree.JCTree$JCImport does not have member field 'com.sun.tools.javac.tree.JCTree qualid'

The whole module stopped compiling, not only the Lombok classes. A JDK upgrade therefore waits for a Lombok that supports it.

  • lombok.config. Project-wide settings live in lombok.config files; Lombok reads the one next to the source and every one in the parent directories, up to a file containing config.stopBubbling = true. Old advice says to add lombok.addLombokGeneratedAnnotation = true so that coverage tools skip generated methods. Since Lombok 1.18.34 that is the default: javap -v on CustomerForm found lombok.Generated on all nine generated members with no configuration at all, and java -jar lombok.jar config -g --verbose documents the key as (default: true). Gradle does not treat lombok.config as a compile input: after adding lombok.addLombokGeneratedAnnotation = false, ./gradlew compileJava reported UP-TO-DATE and the class still carried the nine annotations, until ./gradlew compileJava --rerun removed them.
  • Leaving Lombok. delombok writes the generated code back as source, which is how a project removes Lombok:
Bash
java -jar lombok.jar delombok src/main/java -d build/delombok

On CustomerForm it produced the constructor, four accessors, equals, canEqual, hashCode and toString, each with @java.lang.SuppressWarnings("all") and @lombok.Generated: 79 lines for a class of two fields.

Why this series stays Lombok-free

The series' code, including the capstone project in article 42, does not use Lombok, and these runs are the reasons. DTOs are records, which since Java 16 give what @Value gives with no processor and no configuration, and which Jackson 3 reads without @Jacksonized. Entities keep explicit constructors, getters and an id-based equals, because every Lombok shortcut that saves more than a getter, @Data, @EqualsAndHashCode, @Builder, failed on an entity above, and the failures appeared at runtime or were swallowed by the logger. And a tutorial's code should compile on the next JDK without waiting for a library. For a service, @RequiredArgsConstructor and @Slf4j are harmless, and a team that already uses Lombok loses little by keeping them; one explicit constructor is also only a few lines.

Spring Boot Actuator basics

What the Actuator starter adds

spring-boot-starter-actuator brings the modules listed at the top and registers endpoints: operations on the running application, such as health, info, metrics, loggers and environment, each of which can be exposed over HTTP or JMX. The startup log reports what is reachable over HTTP:

Text
[  restartedMain] o.s.b.a.e.web.EndpointLinksResolver      : Exposing 1 endpoint beneath base path '/actuator'

/actuator and /actuator/health by default

Bash
curl -i http://localhost:8139/actuator
Text
HTTP/1.1 200 
Content-Type: application/vnd.spring-boot.actuator.v3+json
Content-Length: 243
JSON
{"_links":{"self":{"href":"http://localhost:8139/actuator","templated":false},"health-path":{"href":"http://localhost:8139/actuator/health/{*path}","templated":true},"health":{"href":"http://localhost:8139/actuator/health","templated":false}}}

/actuator is a discovery page that links every exposed endpoint. By default that is only health: management.endpoints.web.exposure.include defaults to health in the 4.1.1 metadata.

Bash
curl -i http://localhost:8139/actuator/health
Text
HTTP/1.1 200 
Content-Type: application/vnd.spring-boot.actuator.v3+json
JSON
{"groups":["liveness","readiness"],"status":"UP"}

status is the aggregate of every health indicator. groups lists the liveness and readiness probe groups, which Spring Boot 4.1.1 creates by default (management.endpoint.health.probes.enabled defaults to true); probes for Kubernetes belong to the Advanced course. GET /actuator/health/db returned 404 with an empty body, and GET /actuator/info returned 404, Spring Boot's ordinary error JSON with "message":"No static resource actuator/info.": an endpoint that is not exposed has no HTTP mapping at all.

Health details: show-details and show-components

management.endpoint.health.show-details defaults to never. Set to always:

src/main/resources/application.properties
management.endpoint.health.show-details=always
JSON
{
  "components": {
    "db": {
      "details": { "database": "H2", "validationQuery": "isValid()" },
      "status": "UP"
    },
    "diskSpace": {
      "details": { "total": 245107195904, "free": 11658166272, "threshold": 10485760, "path": "/…/demo/.", "exists": true },
      "status": "UP"
    },
    "livenessState": { "status": "UP" },
    "ping": { "status": "UP" },
    "readinessState": { "status": "UP" },
    "ssl": {
      "details": { "expiringChains": [], "invalidChains": [], "validChains": [] },
      "status": "UP"
    }
  },
  "groups": ["liveness", "readiness"],
  "status": "UP"
}

Each component is a health indicator that Spring Boot auto-configured because its technology is on the classpath:

  • db: borrows a connection from the DataSource and runs the JDBC isValid() check.
  • diskSpace: free space where the application runs, DOWN below management.health.diskspace.threshold, 10MB by default.
  • ping: always UP; it proves the application answers.
  • livenessState and readinessState: the application's availability state, used by the probe groups.
  • ssl: certificate chains of configured SSL bundles; this application has none.

GET /actuator/health/db now answered 200 with {"details":{"database":"H2","validationQuery":"isValid()"},"status":"UP"}. The other two values of the setting:

SettingGET /actuator/health body
show-details=never (default){"groups":["liveness","readiness"],"status":"UP"}
show-components=always, details never{"components":{"db":{"status":"UP"},"diskSpace":{"status":"UP"},"livenessState":{"status":"UP"},"ping":{"status":"UP"},"readinessState":{"status":"UP"},"ssl":{"status":"UP"}},"groups":["liveness","readiness"],"status":"UP"}
show-details=when-authorized, no security{"groups":["liveness","readiness"],"status":"UP"}
show-details=alwaysthe body above

when-authorized without Spring Security behaved like never: no request is ever authenticated. The security section gives it a role.

When health goes DOWN

To see a failing indicator, the database has to fail while the application runs. H2 was started as a TCP server on its own, java -cp h2-2.4.240.jar org.h2.tools.Server -tcp -tcpPort 9139 -ifNotExists, and the application pointed at it with spring.datasource.url=jdbc:h2:tcp://localhost:9139/mem:catalog plus spring.jpa.hibernate.ddl-auto=create-drop, because for a server URL Spring Boot does not create the schema and the seeder failed with Table "PRODUCTS" not found. Then the H2 process was killed:

Bash
curl -i http://localhost:8139/actuator/health
Text
HTTP/1.1 503 
Content-Type: application/vnd.spring-boot.actuator.v3+json
Connection: close
JSON
{"components":{"db":{"details":{"error":"org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection"},"status":"DOWN"},"diskSpace":{"details":{"total":245107195904,"free":10325200896,"threshold":10485760,"path":"/…/demo/.","exists":true},"status":"UP"},"livenessState":{"status":"UP"},"ping":{"status":"UP"},"readinessState":{"status":"UP"},"ssl":{"details":{"expiringChains":[],"invalidChains":[],"validChains":[]},"status":"UP"}},"groups":["liveness","readiness"],"status":"DOWN"}

One DOWN component made the aggregate DOWN, and a DOWN status is answered with 503, which is what a load balancer checking the URL acts on. The answer took 30.1 seconds, and the log explained why:

Text
Caused by: java.sql.SQLTransientConnectionException: HikariPool-5 - Connection is not available, request timed out after 30007ms (total=0, active=0, idle=0, waiting=0)
Caused by: org.h2.jdbc.JdbcSQLNonTransientConnectionException: Connection is broken: "java.net.ConnectException: Connection refused: localhost:9139" [90067-240]
[nio-8139-exec-3] o.s.b.j.h.DataSourceHealthIndicator      : DataSource health check failed
[nio-8139-exec-3] o.s.b.h.a.e.HealthEndpointSupport        : Health contributor org.springframework.boot.jdbc.health.DataSourceHealthIndicator (db) took 30073ms to respond

The db check borrows a connection like any request, so it waits HikariPool's connectionTimeout, 30 seconds by default, before reporting the failure. Anything that polls health needs a timeout longer than that, or a shorter pool timeout. /actuator/health/liveness and /actuator/health/readiness still answered 200 at the same moment: db is not a member of the probe groups by default.

Exposing /actuator/info, and why include=* is dangerous

info becomes reachable once it is in the exposure list:

src/main/resources/application.properties
management.endpoint.health.show-details=always
management.endpoints.web.exposure.include=health,info 

The log said Exposing 2 endpoints beneath base path '/actuator', /actuator gained an info link, and GET /actuator/info answered 200 with {}: the endpoint exists, but no contributor has anything to say yet.

The shortcut found in many answers is include=*. On this application it logged Exposing 12 endpoints beneath base path '/actuator', and the discovery page listed beans, conditions, configprops, env, health, info, loggers, mappings, metrics, sbom, scheduledtasks and threaddump. None of them requires authentication on its own.

env lists every property source and property, with where each value came from. Values are masked by default, because management.endpoint.env.show-values defaults to never. GET /actuator/env/spring.datasource.url returned:

JSON
{"activeProfiles":[],"defaultProfiles":["default"],"property":{"source":"Config resource 'class path resource [application.properties]' via location 'optional:classpath:/'","value":"******"},"propertySources":[{"name":"server.ports"},{"name":"servletConfigInitParams"},{"name":"servletContextInitParams"},{"name":"systemProperties"},{"name":"systemEnvironment"},{"name":"random"},{"name":"Config resource 'class path resource [application.properties]' via location 'optional:classpath:/'","property":{"origin":"class path resource [application.properties] - 3:23","value":"******"}},{"name":"devtools"},{"name":"applicationInfo"},{"name":"Management Server"}]}

With show-values=always, a common debugging change, a test API key set in application.properties came back in plain text. The other dangerous ones:

  • configprops shows every @ConfigurationProperties bean with its bound values, spring.datasource with its url among them, masked the same way (management.endpoint.configprops.show-values defaults to never).
  • heapdump downloads a dump of the JVM heap. Spring Boot 4.1.1 does not serve it even under *, because management.endpoint.heapdump.access defaults to none: it answered 404. With access=unrestricted it answered 200 with 78 MB of heap, and strings found the test API key in it four times, whatever show-values says.
  • loggers is writable. An anonymous POST /actuator/loggers/org.hibernate.SQL with {"configuredLevel":"DEBUG"} answered 204, and the level stayed DEBUG.

List the endpoints you need by name, and put everything except health behind authentication, as the last section shows.

What /actuator/info can show

The info endpoint collects info contributors. In 4.1.1 the env, java, os and process contributors are off by default (management.info.env.enabled and the others default to false, as does ssl); the build and git contributors switch on by themselves when their files exist.

src/main/resources/application.properties
management.endpoints.web.exposure.include=health,info
management.info.env.enabled=true 
management.info.java.enabled=true 
management.info.os.enabled=true 
info.app.name=Catalogue API 
info.app.description=Products and orders 
info.app.owner=platform-team 

The build section comes from META-INF/build-info.properties, which the build tool writes:

build.gradle
springBoot {
	buildInfo()
}

Gradle ran a bootBuildInfo task before processResources; ./mvnw package wrote the same file in the Maven project. The git section comes from a git.properties file. In Gradle the com.gorylenko.gradle-git-properties plugin writes it; 4.0.1 was the current version on the Gradle Plugin Portal:

build.gradle
plugins {
	id 'java'
	id 'org.springframework.boot' version '4.1.1'
	id 'io.spring.dependency-management' version '1.1.7'
	id 'com.gorylenko.gradle-git-properties' version '4.0.1'
}

In a git init repository with one commit, ./gradlew classes ran :generateGitProperties and :bootBuildInfo, and after a fresh bootRun the endpoint returned the following (the run also had process enabled; that section is left out here and described below):

JSON
{
  "app": { "name": "Catalogue API", "description": "Products and orders", "owner": "platform-team" },
  "git": { "branch": "main", "commit": { "id": "5a9191e", "time": "2026-09-16T08:19:18Z" } },
  "build": { "artifact": "demo", "name": "demo", "time": "2026-09-16T08:19:39.782Z", "version": "0.0.1-SNAPSHOT", "group": "com.example" },
  "java": {
    "jvm": { "name": "OpenJDK 64-Bit Server VM", "vendor": "Homebrew", "version": "21.0.6" },
    "runtime": { "name": "OpenJDK Runtime Environment", "version": "21.0.6" },
    "vendor": { "name": "Homebrew", "version": "Homebrew" },
    "version": "21.0.6"
  },
  "os": { "arch": "aarch64", "name": "Mac OS X", "version": "26.6.2" }
}
  • app is every info.* property, copied as it is.
  • git in the default simple mode (management.info.git.mode) shows only branch, abbreviated commit id and commit time. The generated git.properties holds much more, 19 keys including the email address of the machine's git user, which the simple mode keeps out of the response.
  • build carries a time that changes on every build: the file said 08:19:31 after ./gradlew classes and the response 08:19:39 after bootRun built again. A file that changes on every build is one DevTools should not react to, which is why META-INF/build-info.properties and git.properties are in its restart exclusions.

management.info.process.enabled=true adds a process section with the PID, the owning OS user, the working directory, heap and GC counts. That, and the owner and versions above, are details to keep away from anonymous callers.

What /actuator, /actuator/health and /actuator/info returned by default and after the configuration of this article: the discovery page, health with its six components and the 503 DOWN, and info with app, git, build, java and os

Securing Actuator next to the API security chain

Security was then added to the same project: spring-boot-starter-security, spring-boot-starter-security-oauth2-resource-server, and a trimmed version of article 36's API chain with the same securityMatcher("/api/**"), JWT resource server and ADMIN/USER roles. Tokens for admin and alice were signed with the application's JwtEncoder.

With only that chain, and show-details=always still set:

Request, no tokenStatus
GET /actuator/health200, with every component and detail
GET /actuator/info200, with app, git, build, java and os
GET /actuator200
GET /api/orders/1401

Nothing protected Actuator. FilterChainProxy at TRACE showed why:

Text
[nio-8139-exec-1] o.s.security.web.FilterChainProxy        : Trying to match request against DefaultSecurityFilterChain defined as 'apiSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [Or [PathPattern [/api/**]]] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Logout, OAuth2ProtectedResourceMetadata, BearerTokenAuthentication, RequestCac…
[nio-8139-exec-1] o.s.security.web.FilterChainProxy        : No security for GET /actuator/info

A request that matches no chain gets no security filters at all, and Spring Boot's own default chain is not created once the application defines one. Anything exposed under /actuator is as public as a static file. The fix is a chain of its own for the Actuator endpoints:

src/main/java/com/example/demo/common/SecurityConfig.java
import org.springframework.boot.health.actuate.endpoint.HealthEndpoint; 
import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest; 
 
    @Bean
    @Order(0) 
    SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) { 
        http 
                .securityMatcher(EndpointRequest.toAnyEndpoint()) 
                .authorizeHttpRequests(auth -> auth 
                        .requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll() 
                        .anyRequest().hasRole("ADMIN")) 
                .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) 
                .csrf(csrf -> csrf.disable()) 
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); 
        return http.build(); 
    } 
  • EndpointRequest.toAnyEndpoint() matches every exposed endpoint and the discovery page, built from Actuator's own configuration rather than a hard-coded /actuator/**. In 4.1.1 the class lives in spring-boot-security, package org.springframework.boot.security.autoconfigure.actuate.web.servlet.
  • EndpointRequest.to(HealthEndpoint.class) is /actuator/health and its sub-paths, public for load balancers.
  • Everything else needs ROLE_ADMIN. Point the chain's entry point and access denied handler at the application's ProblemDetail handler as the API chain does, if the 401 and 403 bodies should match.

The health details go to admins only:

src/main/resources/application.properties
management.endpoint.health.show-details=always 
management.endpoint.health.show-details=when-authorized 
management.endpoint.health.roles=ADMIN 
management.endpoints.web.exposure.include=health,info
RequestNo tokenalice (USER)admin (ADMIN)
GET /actuator/health200, {"groups":["liveness","readiness"],"status":"UP"}200, the same200, with all components and details
GET /actuator/health/db404404200
GET /actuator/info401, WWW-Authenticate: Bearer resource_metadata="http://localhost:8139/.well-known/oauth-protected-resource"403200
GET /actuator401403200
GET /api/products200200200

A second way to keep Actuator off the public network is management.server.port: with management.server.port=9139, the log showed a second Tomcat started on port 9139, /actuator/health answered 404 on 8139 and 200 on 9139, and /api/products answered 404 on 9139. The Actuator chain above still applied on the management port, where /actuator/info without a token was 401.

Custom endpoints, custom health indicators, metrics with Micrometer and Prometheus, tracing, Kubernetes liveness and readiness probes, and Actuator security beyond this rule set belong to the Advanced course.

FAQ

Why does my Spring Boot app restart when I only change a static file?

Because with Gradle the file does not reach the classpath alone. processResources copies every resource again when any of them changes, so application.properties in build/resources/main got a new modification time and DevTools logged Restarting due to 2 class path changes. Files under static/ and templates/ are excluded from restart by default, so a change that touches only them does not restart. Add sourceResources sourceSets.main to the bootRun task to serve resources from src/main/resources without a build.

Is Spring Boot DevTools included in the production jar?

Not with the Initializr setup. Gradle's developmentOnly and Maven's optional dependency keep it out: jar tf on the Gradle bootJar and on the Maven jar found no spring-boot-devtools. Even when it was packaged deliberately, java -jar did not activate it, because DevTools 4.1.1 only enables restart when the main thread's classloader is the JDK application classloader.

Is LiveReload still available in Spring Boot 4.1?

It is deprecated since Spring Boot 4.1.0 with no replacement, and spring.devtools.livereload.enabled defaults to false, so the server does not start unless you enable it. Restart itself is not deprecated.

Should I use @Data on JPA entities?

No. On the order and its lines, @Data made toString and hashCode recurse into a StackOverflowError, made a saved tag disappear from a HashSet because its hash changed with the id, and made toString throw LazyInitializationException outside the transaction. Use @Getter, a @ToString limited to columns, a protected no-args constructor, and write equals and hashCode on the id by hand.

Should I use Lombok @Value or a Java record for DTOs?

A record. The bytecode is nearly identical: a final class, final fields, one constructor, equals, hashCode and toString. The record needs no annotation processor, supports record patterns, and Jackson 3 in Spring Boot 4 reads it as a request body, while @Value with @Builder failed with InvalidDefinitionException until @Jacksonized was configured for Jackson 3 in lombok.config.

Why does /actuator/info return 404 in Spring Boot?

Because only health is exposed over HTTP by default, and an unexposed endpoint has no mapping. Add management.endpoints.web.exposure.include=health,info. The endpoint then returns {} until a contributor has data: info.* properties need management.info.env.enabled=true, and the build and git sections need build-info.properties and git.properties on the classpath.

Is /actuator/health protected when I use Spring Security?

Only if a security filter chain matches it. With a single chain whose securityMatcher is /api/**, /actuator/health and an exposed /actuator/info answered 200 to anonymous requests, full details included, and the trace logged No security for GET /actuator/info. Add a chain with EndpointRequest.toAnyEndpoint() that permits health and requires a role for the rest.

Conclusion

DevTools, Lombok and Actuator each save work in a different place. DevTools keeps the jars in a base classloader and rebuilds only your classes, so a restart took 0.185 s where a cold start took 1.488 s, provided something compiles your changes and you know that a Gradle resource build restarts too. Lombok removes boilerplate at compile time, which is harmless for a service constructor and a logger and dangerous on entities, where @Data, @EqualsAndHashCode and @Builder all failed; records already cover DTOs, which is why this series stays without it. Actuator gives an application a health check with real components and a 503 when the database is gone, and an info page with build and git data, and it is public next to an /api/** security chain until you give it a chain of its own.

Article 40 continues Chapter 7 with common tasks in a Spring Boot application: uploading and downloading files, sending email, scheduling work with @Scheduled, and the basics of @Async.

Related Posts

[Spring Boot Basics] Pagination and Sorting in Spring Boot: Pageable, Sort and Paged API Responses

Pagination and sorting in Spring Boot 4.1.1 with Spring Data JPA, on H2 and PostgreSQL: Sort with ignoreCase, nullsFirst and nullsLast and the SQL each produced on both databases, the deprecated TypedSort, zero-based PageRequest, Page vs Slice vs List and the count query and extra row behind each, when Spring Data skips the count, @Query and native queries with Pageable, the inflated totalElements of a paged JOIN FETCH, a Pageable controller with @PageableDefault, max-page-size and one-indexed parameters, the PageImpl serialization warning, PagedModel versus a PageResponse record, a 400 ProblemDetail for an unknown sort property, and OFFSET versus keyset scrolling with Window.

[Spring Boot Basics] Server-Side Rendering with Thymeleaf in Spring Boot: Templates, Forms and Validation

Server-side rendering with Thymeleaf in Spring Boot 4.1.1: when SSR beats a JSON API, how a view name becomes classpath:/templates/products/list.html, the five standard expressions, th:text versus th:utext and XSS, th:each with #numbers and #temporals, a validated create form with th:field and th:errors, where BindingResult must go, Post/Redirect/Get with flash attributes, fragments, static CSS, what spring.thymeleaf.cache really changes, and HTML error pages.

[Spring Boot Basics] API Documentation in Spring Boot with springdoc-openapi and Swagger UI

springdoc-openapi on Spring Boot 4.1.1: the OpenAPI 3.1 document at /v3/api-docs, Swagger UI and Try it out, what springdoc infers from controllers, DTO records and Bean Validation constraints, which @RestControllerAdvice responses it adds, @Tag, @Operation, @ApiResponse, @Parameter and @Schema on records, a global OpenAPI bean and customizer, GroupedOpenApi, springdoc properties and switching the docs off in a prod profile.

[Spring Boot Basics] Packaging and Running Spring Boot: Executable JAR, Profiles and a Simple Dockerfile

Packaging and running a Spring Boot 4.1.1 application: ./gradlew build vs bootJar and the -plain jar, what the executable JAR contains (MANIFEST.MF, JarLauncher, BOOT-INF/classes, BOOT-INF/lib, classpath.idx), naming the artifact in Gradle and Maven, java -jar with the postgres profile against PostgreSQL, environment variables and an external config file for the password, graceful shutdown and exit codes, a Dockerfile on eclipse-temurin:21-jre, exec form vs shell form ENTRYPOINT timed with docker stop, a non-root user, a multi-stage build and what its Gradle dependency layer really caches, the default heap under --memory=512m, and the application plus PostgreSQL on a Docker network and in Docker Compose.