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 /…/.
![]()
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:
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.zipEach tool lands in a different dependency configuration, and that choice is the first thing worth understanding about it:
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'
}<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- and in maven-compiler-plugin, for default-compile and default-testCompile: -->
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>| Tool | Gradle configuration | Maven | On the compile classpath | In the packaged jar |
|---|---|---|---|---|
| DevTools | developmentOnly | runtime + optional | no | no |
| Lombok | compileOnly + annotationProcessor | optional + annotationProcessorPaths | yes | no |
| Actuator | implementation | compile scope | yes | yes |
./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:
@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";
}
}spring.application.name=demo
server.port=8139
spring.datasource.url=jdbc:h2:mem:catalog
spring.jpa.open-in-view=falseGET /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:
[ 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)restartedMainis the thread name. DevTools does not run the application onmain: 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.enableddefaults tofalseand 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:
./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:
@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:
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@2c854dc5Under 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:
[ 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 unchangedcurl 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:
| Start | Runs | Started … in, best | Load average |
|---|---|---|---|
Cold ./gradlew bootRun (DevTools present) | 6 | 1.488 s | 3.02 |
java -jar (no DevTools) | 3 | 1.638 s | 2.77 |
| DevTools restart after a controller change | 10 | 0.185 s | 2.63 |
From saving the file to Started, with ./gradlew -t classes | 3 | 2.02 s | 2.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.

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:
./gradlew -t classesWaiting 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 468msThe 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:
META-INF/maven/**,META-INF/resources/**,resources/**,static/**,public/**,templates/**,**/*Test.class,**/*Tests.class,git.properties,META-INF/build-info.propertiesStatic 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:
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:
| Property | With DevTools | Normal default | Declared in |
|---|---|---|---|
spring.h2.console.enabled | true | false | spring-boot-h2console |
spring.web.error.include-stacktrace | always | never | spring-boot-autoconfigure |
spring.web.error.include-message | always | never | spring-boot-autoconfigure |
spring.web.error.include-binding-errors | always | never | spring-boot-autoconfigure |
spring.web.resources.cache.period | 0 | not set | spring-boot-autoconfigure |
spring.web.resources.chain.cache | false | true | spring-boot-autoconfigure |
spring.template.provider.cache | false | not in the metadata | spring-boot-autoconfigure |
spring.mvc.log-resolved-exception | true | false | spring-boot-webmvc |
server.servlet.session.persistent | true | false | spring-boot-web-server |
server.servlet.jsp.init-parameters.development | true | not set | spring-boot-web-server |
spring.thymeleaf.cache | false | true | spring-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
RemoteSpringApplicationandspring.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=falseinapplication.propertiesstops the watching but still starts the restart classloader; to remove it completely, set the system property beforeSpringApplication.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 two-field class with @Data:
package com.example.demo.lab;
import lombok.Data;
@Data
public class CustomerForm {
private String email;
private String fullName;
}javap -p -cp build/classes/java/main com.example.demo.lab.CustomerFormCompiled 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:
@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;
}
}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 {};
}@RequiredArgsConstructorgenerated a public constructor taking everyfinalfield. With one constructor Spring injects through it, so this is ordinary constructor injection.@Slf4jgeneratedprivate static final org.slf4j.Logger log, the field article 14 wrote by hand.@Getterand@Setteron a class or a field generate what@Datagenerated above, withoutequals,hashCodeandtoString.
@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:
@Value
public class ProductValue {
String sku;
String name;
BigDecimal price;
}@Value
@Builder
public class ProductView {
String sku;
String name;
BigDecimal price;
}public record ProductRecord(String sku, String name, BigDecimal price) {
}javap -p on each:
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 type | Response |
|---|---|
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:
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:
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:
config.stopBubbling = true
lombok.jacksonized.jacksonVersion += 3| Java record | Lombok @Value | Lombok @Data | |
|---|---|---|---|
| Needs a library and annotation processing | no | yes | yes |
| Accessors | sku() | getSku() | getSku(), setSku(…) |
| Immutable, final class | yes | yes | no |
equals, hashCode, toString | yes | yes | yes |
| Jackson 3 request body | yes | yes, with its public constructor | yes, through setters |
| Builder | no, 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 instanceof | yes | no | no |
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:
@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);
}
}@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:
@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:
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:
[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:
@Test
void orderInHashSet() {
Order order = new Order();
order.setCustomerEmail("alice@example.com");
order.addLine(new OrderLine());
Set<Order> orders = new HashSet<>();
orders.add(order);
}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:
@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();
} >>> 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 falseThe 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:
@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:
"message":"Cannot lazily initialize collection of role 'com.example.demo.order.Order.lines' with key '1' (no session)"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():
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:
@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);
}[ 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:
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:
@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:
@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();
}
}@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:
[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:
>>> 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 entity | Verdict | Why |
|---|---|---|
@Getter | safe | plain accessors |
@Setter on the class | avoid | opens id and collections to any caller; put @Setter on the fields that change |
@ToString(onlyExplicitlyIncluded = true) with @ToString.Include on columns | safe | no associations, no recursion, no lazy loading |
@ToString.Exclude on every association | safe, but every new association must remember it | trap 3 is what a missing exclude does |
@NoArgsConstructor(access = PROTECTED) | safe | the constructor JPA needs |
@Data | do not use | traps 1, 2 and 3 |
@EqualsAndHashCode, even onlyExplicitlyIncluded on the id | do not use | trap 2: the hash changes when the id is assigned; write id-based equals with a constant hashCode |
@Builder | avoid | removes the no-args constructor, ignores field initializers without @Builder.Default |
@Value | do not use | final 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:
ext['lombok.version'] = '1.18.28'> 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 inlombok.configfiles; Lombok reads the one next to the source and every one in the parent directories, up to a file containingconfig.stopBubbling = true. Old advice says to addlombok.addLombokGeneratedAnnotation = trueso that coverage tools skip generated methods. Since Lombok 1.18.34 that is the default:javap -vonCustomerFormfoundlombok.Generatedon all nine generated members with no configuration at all, andjava -jar lombok.jar config -g --verbosedocuments the key as(default: true). Gradle does not treatlombok.configas a compile input: after addinglombok.addLombokGeneratedAnnotation = false,./gradlew compileJavareportedUP-TO-DATEand the class still carried the nine annotations, until./gradlew compileJava --rerunremoved them.- Leaving Lombok.
delombokwrites the generated code back as source, which is how a project removes Lombok:
java -jar lombok.jar delombok src/main/java -d build/delombokOn 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:
[ restartedMain] o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint beneath base path '/actuator'/actuator and /actuator/health by default
curl -i http://localhost:8139/actuatorHTTP/1.1 200
Content-Type: application/vnd.spring-boot.actuator.v3+json
Content-Length: 243{"_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.
curl -i http://localhost:8139/actuator/healthHTTP/1.1 200
Content-Type: application/vnd.spring-boot.actuator.v3+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:
management.endpoint.health.show-details=alwaysmanagement:
endpoint:
health:
show-details: always{
"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 theDataSourceand runs the JDBCisValid()check.diskSpace: free space where the application runs,DOWNbelowmanagement.health.diskspace.threshold,10MBby default.ping: alwaysUP; it proves the application answers.livenessStateandreadinessState: 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:
| Setting | GET /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=always | the 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:
curl -i http://localhost:8139/actuator/healthHTTP/1.1 503
Content-Type: application/vnd.spring-boot.actuator.v3+json
Connection: close{"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:
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 respondThe 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:
management.endpoint.health.show-details=always
management.endpoints.web.exposure.include=health,info management:
endpoint:
health:
show-details: always
endpoints:
web:
exposure:
include: health,infoThe 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:
{"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:
configpropsshows every@ConfigurationPropertiesbean with its bound values,spring.datasourcewith itsurlamong them, masked the same way (management.endpoint.configprops.show-valuesdefaults tonever).heapdumpdownloads a dump of the JVM heap. Spring Boot 4.1.1 does not serve it even under*, becausemanagement.endpoint.heapdump.accessdefaults tonone: it answered 404. Withaccess=unrestrictedit answered 200 with 78 MB of heap, andstringsfound the test API key in it four times, whatevershow-valuessays.loggersis writable. An anonymousPOST /actuator/loggers/org.hibernate.SQLwith{"configuredLevel":"DEBUG"}answered 204, and the level stayedDEBUG.
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.
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 management:
endpoints:
web:
exposure:
include: health,info
info:
env:
enabled: true
java:
enabled: true
os:
enabled: true
info:
app:
name: Catalogue API
description: Products and orders
owner: platform-teamThe build section comes from META-INF/build-info.properties, which the build tool writes:
springBoot {
buildInfo()
}<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>build-info</goal>
</goals>
</execution>
</executions>
</plugin>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:
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):
{
"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" }
}appis everyinfo.*property, copied as it is.gitin the defaultsimplemode (management.info.git.mode) shows only branch, abbreviated commit id and commit time. The generatedgit.propertiesholds much more, 19 keys including the email address of the machine's git user, which thesimplemode keeps out of the response.buildcarries atimethat changes on every build: the file said08:19:31after./gradlew classesand the response08:19:39afterbootRunbuilt again. A file that changes on every build is one DevTools should not react to, which is whyMETA-INF/build-info.propertiesandgit.propertiesare 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.

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 token | Status |
|---|---|
GET /actuator/health | 200, with every component and detail |
GET /actuator/info | 200, with app, git, build, java and os |
GET /actuator | 200 |
GET /api/orders/1 | 401 |
Nothing protected Actuator. FilterChainProxy at TRACE showed why:
[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/infoA 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:
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 inspring-boot-security, packageorg.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest.to(HealthEndpoint.class)is/actuator/healthand 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'sProblemDetailhandler as the API chain does, if the 401 and 403 bodies should match.
The health details go to admins only:
management.endpoint.health.show-details=always
management.endpoint.health.show-details=when-authorized
management.endpoint.health.roles=ADMIN
management.endpoints.web.exposure.include=health,infomanagement:
endpoint:
health:
show-details: always
show-details: when-authorized
roles: ADMIN
endpoints:
web:
exposure:
include: health,info| Request | No token | alice (USER) | admin (ADMIN) |
|---|---|---|---|
GET /actuator/health | 200, {"groups":["liveness","readiness"],"status":"UP"} | 200, the same | 200, with all components and details |
GET /actuator/health/db | 404 | 404 | 200 |
GET /actuator/info | 401, WWW-Authenticate: Bearer resource_metadata="http://localhost:8139/.well-known/oauth-protected-resource" | 403 | 200 |
GET /actuator | 401 | 403 | 200 |
GET /api/products | 200 | 200 | 200 |
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.