diff --git a/README.md b/README.md
index b61c64b..b89d3bc 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,7 @@ files.
| [`crud-mysql/`](crud-mysql) | [Spring Boot CRUD REST API with MySQL and Spring Data JPA](https://ankurm.com/spring-boot-crud-rest-api-mysql/) | a complete book API on a real MySQL 8.4.6 server with Flyway owning the schema and Hibernate set to `validate`, `ProblemDetail` errors with per-field validation messages, a duplicate ISBN caught by the database's unique key and mapped to `409` on create and on update, MySQL's own `Duplicate entry` message, and the SQL behind every request |
| [`boot42-rc1-checklist/`](boot42-rc1-checklist) | [Spring Framework 7.1 and Spring Boot 4.2: What's Coming and How to Prepare](https://ankurm.com/spring-boot-4-2-spring-framework-7-1-upgrade-checklist/) | each 4.2 milestone change run on 4.2.0-RC1 and on 4.1.2: `@DefaultValue` placeholders resolved, five new Kafka admin keys that 4.1.2 silently ignores, embedded LDAP SSL failing without a bundle, the properties migrator's real report (and the removed key it misses), and the `RestTemplate` removal warnings from the compiler |
| [`boot42-otel/`](boot42-otel) | [Spring Boot 4.2 OpenTelemetry: Semantic Conventions, Shared OTLP Settings and MDC Keys](https://ankurm.com/spring-boot-4-2-opentelemetry-semantic-conventions-shared-otlp-mdc/) | one request measured under each `management.observations.conventions` value (the documented `open-telemetry` fails to start on RC1, `opentelemetry` works), meter, span and Prometheus names before and after, `management.opentelemetry.otlp.*` verified against a local OTLP receiver (paths, merged headers, gzip, per-signal overrides), custom MDC keys, and the LongTaskTimer that is no longer on by default |
+| [`boot4-migration/`](boot4-migration) | [Spring Boot 3 to 4 Migration Guide (Updated for 4.1 and 4.2)](https://ankurm.com/spring-boot-3-to-4-migration-guide/) | one Boot 3.5.16 service migrated to 4.1.2 with the real failures captured at each step: 12 javac errors from a version bump, a start-up failure from a missing `spring-boot-starter-restclient`, Flyway silently not running without `spring-boot-starter-flyway`, removed `logging.file.*` keys silently ignored, Jackson 3 sorting bean properties, liveness/readiness groups appearing, and a `@Mock` field that is `null` without `MockitoExtension` |
`core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource`, `i18n`, `aot-cache`, `kotlin`, `testing-no-containers`, `rest-test-client` and `hexagonal` are the exception: they have **no `docs/NN-topic.md` chapter folder**. Their deeper material lives in
collapsible sections inside the articles themselves. Where their captured output sits is not
diff --git a/boot4-migration/README.md b/boot4-migration/README.md
new file mode 100644
index 0000000..2fdbefb
--- /dev/null
+++ b/boot4-migration/README.md
@@ -0,0 +1,37 @@
+# boot4-migration
+
+Companion module for the ankurm.com post
+[Spring Boot 3 to 4 Migration Guide (Updated for 4.1 and 4.2)](https://ankurm.com/spring-boot-3-to-4-migration-guide/).
+
+One small service (Web MVC, Security, Actuator, JDBC + Flyway, AOP aspect, `RestClient.Builder`,
+a Jackson serializer, three tests) in two forms:
+
+| Directory | What it is | Built by |
+|---|---|---|
+| `before/` | the service on Spring Boot 3.5.16 | never by a normal build; `scripts/run-all.sh` copies it to a scratch dir |
+| `after/` | the same service migrated to Spring Boot 4.1.2 | `cd after && mvn verify` |
+
+`run-all.sh` builds `before/` as written, then bumps only the parent version and records what
+breaks at each step: the build error, the javac errors, the start-up failure report, the
+things that start fine but behave differently, and the test failures. Every file in `output/` is
+a real transcript from that script (JDK 25.0.5, Maven 3.9).
+
+```bash
+export JAVA_HOME=/path/to/jdk-25
+./scripts/run-all.sh # 15-25 minutes on a shared 2-vCPU box; needs Maven Central
+```
+
+| Output | Shows |
+|---|---|
+| `01-before-boot35.txt` | `before/` builds and runs on 3.5.16 (the baseline every other file is compared with) |
+| `02-bump-only-compile-errors.txt` | only the parent version changed: Maven POM error, then 12 javac errors |
+| `03-compiles-but-will-not-start.txt` | imports ported, starters not: compiles, fails at start-up (no `RestClient.Builder`) |
+| `04-starts-but-silently-wrong.txt` | starts, answers 200, but Flyway never ran, logs never rotate |
+| `05-properties-migrator.txt` | what `spring-boot-properties-migrator` does and does not report |
+| `06-after-boot41.txt` | `after/`: 8 tests pass, same endpoints, new health groups |
+| `07-jackson-property-order.txt` | bean property order on Jackson 2, Jackson 3, and `use-jackson2-defaults` |
+| `08-test-compile-errors.txt` | `@MockBean` and the old `@AutoConfigureMockMvc` package |
+| `09-test-runtime-failure.txt` | a plain `@Mock` field is `null`: compiles, fails at run time |
+| `10-security-dsl-compile-errors.txt` | the real javac message for the removed `authorizeHttpRequests()` chain |
+
+Not covered: Gradle, Kotlin, native image, and any library that has not shipped a Boot 4 release.
diff --git a/boot4-migration/after/pom.xml b/boot4-migration/after/pom.xml
new file mode 100644
index 0000000..fc14cb2
--- /dev/null
+++ b/boot4-migration/after/pom.xml
@@ -0,0 +1,39 @@
+
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.2
+
+
+ com.ankurm
+ migration-demo
+ 1.0.0
+
+ 25
+
+
+
+ org.springframework.bootspring-boot-starter-webmvc
+ org.springframework.bootspring-boot-starter-actuator
+ org.springframework.bootspring-boot-starter-security
+ org.springframework.bootspring-boot-starter-jdbc
+ org.springframework.bootspring-boot-starter-flyway
+ org.springframework.bootspring-boot-starter-aspectj
+ org.springframework.bootspring-boot-starter-restclient
+ com.h2databaseh2runtime
+
+ org.springframework.bootspring-boot-starter-webmvc-testtest
+ org.springframework.bootspring-boot-starter-security-testtest
+
+
+
+ org.springframework.bootspring-boot-maven-plugin
+
+
+
diff --git a/boot4-migration/after/src/main/java/com/ankurm/migration/DemoApplication.java b/boot4-migration/after/src/main/java/com/ankurm/migration/DemoApplication.java
new file mode 100644
index 0000000..ba69bca
--- /dev/null
+++ b/boot4-migration/after/src/main/java/com/ankurm/migration/DemoApplication.java
@@ -0,0 +1,11 @@
+package com.ankurm.migration;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class DemoApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(DemoApplication.class, args);
+ }
+}
diff --git a/boot4-migration/after/src/main/java/com/ankurm/migration/Gateway.java b/boot4-migration/after/src/main/java/com/ankurm/migration/Gateway.java
new file mode 100644
index 0000000..d04d5e5
--- /dev/null
+++ b/boot4-migration/after/src/main/java/com/ankurm/migration/Gateway.java
@@ -0,0 +1,18 @@
+package com.ankurm.migration;
+
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestClient;
+
+/** Takes the auto-configured RestClient.Builder. RestClient itself lives in spring-web, so this always compiles. */
+@Component
+public class Gateway {
+ private final RestClient client;
+
+ public Gateway(RestClient.Builder builder) {
+ this.client = builder.baseUrl("http://localhost:9").build();
+ }
+
+ public String describe() {
+ return client.getClass().getSimpleName();
+ }
+}
diff --git a/boot4-migration/after/src/main/java/com/ankurm/migration/Greeting.java b/boot4-migration/after/src/main/java/com/ankurm/migration/Greeting.java
new file mode 100644
index 0000000..fecf4a1
--- /dev/null
+++ b/boot4-migration/after/src/main/java/com/ankurm/migration/Greeting.java
@@ -0,0 +1,6 @@
+package com.ankurm.migration;
+
+import java.time.Instant;
+
+public record Greeting(long id, String title, Instant createdAt, String author) {
+}
diff --git a/boot4-migration/after/src/main/java/com/ankurm/migration/GreetingController.java b/boot4-migration/after/src/main/java/com/ankurm/migration/GreetingController.java
new file mode 100644
index 0000000..3ba6fc9
--- /dev/null
+++ b/boot4-migration/after/src/main/java/com/ankurm/migration/GreetingController.java
@@ -0,0 +1,62 @@
+package com.ankurm.migration;
+
+import java.time.Instant;
+import java.util.Map;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class GreetingController {
+
+ private static final Logger log = LoggerFactory.getLogger(GreetingController.class);
+
+ private final GreetingService service;
+ private final JdbcTemplate jdbc;
+ private final Gateway gateway;
+
+ public GreetingController(GreetingService service, JdbcTemplate jdbc, Gateway gateway) {
+ this.service = service;
+ this.jdbc = jdbc;
+ this.gateway = gateway;
+ }
+
+ @GetMapping("/greetings/{id}")
+ public Greeting greeting(@PathVariable long id) {
+ log.info("served greeting {} ........................................", id);
+ return new Greeting(id, service.title(id), Instant.parse("2026-01-02T03:04:05Z"), "ankur");
+ }
+
+ /** Serialised by Jackson's default record handling (no custom serializer). */
+ @GetMapping("/summary")
+ public Summary summary() {
+ Summary s = new Summary();
+ s.setId(1);
+ s.setTitle("Hello");
+ s.setCreatedAt(Instant.parse("2026-01-02T03:04:05Z"));
+ s.setAuthor("ankur");
+ return s;
+ }
+
+ /** Reads the table that the Flyway migration V1 creates. */
+ @GetMapping("/greetings/count")
+ public Map count() {
+ Integer n = jdbc.queryForObject("select count(*) from greeting", Integer.class);
+ return Map.of("rows", n);
+ }
+
+ /** How many times the @Aspect has intercepted GreetingService. */
+ @GetMapping("/aspect")
+ public Map aspect() {
+ return Map.of("interceptions", TimingAspect.COUNT.get());
+ }
+
+ @GetMapping("/gateway")
+ public Map gateway() {
+ return Map.of("client", gateway.describe());
+ }
+}
diff --git a/boot4-migration/after/src/main/java/com/ankurm/migration/GreetingService.java b/boot4-migration/after/src/main/java/com/ankurm/migration/GreetingService.java
new file mode 100644
index 0000000..d2fa800
--- /dev/null
+++ b/boot4-migration/after/src/main/java/com/ankurm/migration/GreetingService.java
@@ -0,0 +1,10 @@
+package com.ankurm.migration;
+
+import org.springframework.stereotype.Service;
+
+@Service
+public class GreetingService {
+ public String title(long id) {
+ return "Hello #" + id;
+ }
+}
diff --git a/boot4-migration/after/src/main/java/com/ankurm/migration/JsonConfig.java b/boot4-migration/after/src/main/java/com/ankurm/migration/JsonConfig.java
new file mode 100644
index 0000000..9f95018
--- /dev/null
+++ b/boot4-migration/after/src/main/java/com/ankurm/migration/JsonConfig.java
@@ -0,0 +1,26 @@
+package com.ankurm.migration;
+
+import org.springframework.boot.jackson.JacksonComponent;
+import tools.jackson.core.JacksonException;
+import tools.jackson.core.JsonGenerator;
+import tools.jackson.databind.SerializationContext;
+import tools.jackson.databind.ser.std.StdSerializer;
+
+/** Boot 4: Jackson 3 (tools.jackson), @JacksonComponent, and no checked IOException. */
+@JacksonComponent
+public class JsonConfig extends StdSerializer {
+
+ public JsonConfig() {
+ super(Greeting.class);
+ }
+
+ @Override
+ public void serialize(Greeting g, JsonGenerator gen, SerializationContext ctxt) throws JacksonException {
+ gen.writeStartObject();
+ gen.writeNumberProperty("id", g.id());
+ gen.writeStringProperty("title", g.title());
+ gen.writeStringProperty("createdAt", g.createdAt().toString());
+ gen.writeStringProperty("author", g.author());
+ gen.writeEndObject();
+ }
+}
diff --git a/boot4-migration/after/src/main/java/com/ankurm/migration/SecurityConfig.java b/boot4-migration/after/src/main/java/com/ankurm/migration/SecurityConfig.java
new file mode 100644
index 0000000..0082be6
--- /dev/null
+++ b/boot4-migration/after/src/main/java/com/ankurm/migration/SecurityConfig.java
@@ -0,0 +1,23 @@
+package com.ankurm.migration;
+
+import org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.annotation.Order;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.web.SecurityFilterChain;
+
+@Configuration
+public class SecurityConfig {
+
+ @Bean
+ @Order(SecurityFilterProperties.BASIC_AUTH_ORDER)
+ SecurityFilterChain chain(HttpSecurity http) throws Exception {
+ http.authorizeHttpRequests(a -> a
+ .requestMatchers("/actuator/health/**", "/greetings/**", "/aspect", "/gateway").permitAll()
+ .anyRequest().authenticated())
+ .httpBasic(Customizer.withDefaults());
+ return http.build();
+ }
+}
diff --git a/boot4-migration/after/src/main/java/com/ankurm/migration/Summary.java b/boot4-migration/after/src/main/java/com/ankurm/migration/Summary.java
new file mode 100644
index 0000000..79c88bf
--- /dev/null
+++ b/boot4-migration/after/src/main/java/com/ankurm/migration/Summary.java
@@ -0,0 +1,24 @@
+package com.ankurm.migration;
+
+import java.time.Instant;
+
+/**
+ * A plain bean (not a record) whose properties are deliberately NOT in alphabetical order.
+ * Jackson 2 wrote them in declaration order; Jackson 3 sorts bean properties alphabetically by default.
+ * (Records are unaffected: creator parameters are always written first, in constructor order.)
+ */
+public class Summary {
+ private long id;
+ private String title;
+ private Instant createdAt;
+ private String author;
+
+ public long getId() { return id; }
+ public void setId(long id) { this.id = id; }
+ public String getTitle() { return title; }
+ public void setTitle(String title) { this.title = title; }
+ public Instant getCreatedAt() { return createdAt; }
+ public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
+ public String getAuthor() { return author; }
+ public void setAuthor(String author) { this.author = author; }
+}
diff --git a/boot4-migration/after/src/main/java/com/ankurm/migration/TimingAspect.java b/boot4-migration/after/src/main/java/com/ankurm/migration/TimingAspect.java
new file mode 100644
index 0000000..22a8c48
--- /dev/null
+++ b/boot4-migration/after/src/main/java/com/ankurm/migration/TimingAspect.java
@@ -0,0 +1,21 @@
+package com.ankurm.migration;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.springframework.stereotype.Component;
+
+/** Needs AspectJ weaving support on the classpath: spring-boot-starter-aop in Boot 3. */
+@Aspect
+@Component
+public class TimingAspect {
+ static final AtomicInteger COUNT = new AtomicInteger();
+
+ @Around("execution(* com.ankurm.migration.GreetingService.*(..))")
+ public Object count(ProceedingJoinPoint pjp) throws Throwable {
+ COUNT.incrementAndGet();
+ return pjp.proceed();
+ }
+}
diff --git a/boot4-migration/after/src/main/resources/application.properties b/boot4-migration/after/src/main/resources/application.properties
new file mode 100644
index 0000000..28f5e00
--- /dev/null
+++ b/boot4-migration/after/src/main/resources/application.properties
@@ -0,0 +1,7 @@
+spring.datasource.url=jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1
+management.endpoints.web.exposure.include=health,metrics
+management.endpoint.health.show-details=always
+logging.file.name=${LOG_DIR:target/logs}/app.log
+# Boot 4.1 removed the old logging.file.* rolling keys; these are the replacements
+logging.logback.rollingpolicy.max-file-size=2KB
+logging.logback.rollingpolicy.max-history=50
diff --git a/boot4-migration/after/src/main/resources/db/migration/V1__greeting.sql b/boot4-migration/after/src/main/resources/db/migration/V1__greeting.sql
new file mode 100644
index 0000000..86d6781
--- /dev/null
+++ b/boot4-migration/after/src/main/resources/db/migration/V1__greeting.sql
@@ -0,0 +1,2 @@
+create table greeting (id bigint primary key, title varchar(100) not null);
+insert into greeting values (1, 'first'), (2, 'second'), (3, 'third');
diff --git a/boot4-migration/after/src/test/java/com/ankurm/migration/CsrfProbeTest.java b/boot4-migration/after/src/test/java/com/ankurm/migration/CsrfProbeTest.java
new file mode 100644
index 0000000..fd5ab1e
--- /dev/null
+++ b/boot4-migration/after/src/test/java/com/ankurm/migration/CsrfProbeTest.java
@@ -0,0 +1,24 @@
+package com.ankurm.migration;
+
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.web.servlet.MockMvc;
+
+/** Prints the status of a Basic-authenticated POST without a CSRF token (to compare 3.5 with 4.1). */
+@SpringBootTest(properties = "spring.security.user.password=pw")
+@AutoConfigureMockMvc
+class CsrfProbeTest {
+
+ @Autowired MockMvc mvc;
+
+ @Test
+ void postWithoutCsrfToken() throws Exception {
+ int status = mvc.perform(post("/anything").with(httpBasic("user", "pw"))).andReturn().getResponse().getStatus();
+ System.out.println("CSRFPROBE status=" + status);
+ }
+}
diff --git a/boot4-migration/after/src/test/java/com/ankurm/migration/GreetingControllerTest.java b/boot4-migration/after/src/test/java/com/ankurm/migration/GreetingControllerTest.java
new file mode 100644
index 0000000..2b6cbb3
--- /dev/null
+++ b/boot4-migration/after/src/test/java/com/ankurm/migration/GreetingControllerTest.java
@@ -0,0 +1,27 @@
+package com.ankurm.migration;
+
+import static org.mockito.BDDMockito.given;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+class GreetingControllerTest {
+
+ @Autowired MockMvc mvc;
+ @MockitoBean GreetingService service;
+
+ @Test
+ void greetingUsesService() throws Exception {
+ given(service.title(7)).willReturn("mocked");
+ mvc.perform(get("/greetings/7")).andExpect(status().isOk()).andExpect(jsonPath("$.title").value("mocked"));
+ }
+}
diff --git a/boot4-migration/after/src/test/java/com/ankurm/migration/MockFieldTest.java b/boot4-migration/after/src/test/java/com/ankurm/migration/MockFieldTest.java
new file mode 100644
index 0000000..5d82415
--- /dev/null
+++ b/boot4-migration/after/src/test/java/com/ankurm/migration/MockFieldTest.java
@@ -0,0 +1,22 @@
+package com.ankurm.migration;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.boot.test.context.SpringBootTest;
+
+/** Boot 4 fix: Mockito's own extension initialises @Mock fields. */
+@SpringBootTest
+@ExtendWith(MockitoExtension.class)
+class MockFieldTest {
+
+ @Mock Runnable task;
+
+ @Test
+ void mockFieldIsInitialised() {
+ assertNotNull(task, "@Mock field was never initialised");
+ }
+}
diff --git a/boot4-migration/after/src/test/java/com/ankurm/migration/RuntimeFactsTest.java b/boot4-migration/after/src/test/java/com/ankurm/migration/RuntimeFactsTest.java
new file mode 100644
index 0000000..35c222f
--- /dev/null
+++ b/boot4-migration/after/src/test/java/com/ankurm/migration/RuntimeFactsTest.java
@@ -0,0 +1,50 @@
+package com.ankurm.migration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.web.servlet.MockMvc;
+
+/** Pins the runtime behaviour the "breaks at runtime" checklist is about, so a regression fails the build. */
+@SpringBootTest
+@AutoConfigureMockMvc
+class RuntimeFactsTest {
+
+ @Autowired MockMvc mvc;
+ @Autowired JdbcTemplate jdbc;
+
+ @Test
+ void flywayMigrationRan() {
+ assertThat(jdbc.queryForObject("select count(*) from greeting", Integer.class)).isEqualTo(3);
+ }
+
+ @Test
+ void aspectIntercepts() throws Exception {
+ mvc.perform(get("/greetings/1")).andExpect(status().isOk());
+ assertThat(TimingAspect.COUNT.get()).isPositive();
+ }
+
+ @Test
+ void restClientBuilderIsAutoConfigured() throws Exception {
+ mvc.perform(get("/gateway")).andExpect(status().isOk());
+ }
+
+ @Test
+ void metricsEndpointExists() throws Exception {
+ mvc.perform(get("/actuator/metrics").with(user("u")))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ void serializerIsTheJacksonComponent() throws Exception {
+ mvc.perform(get("/greetings/2")).andExpect(content().json("{\"id\":2,\"title\":\"Hello #2\"}"));
+ }
+}
diff --git a/boot4-migration/before/pom.xml b/boot4-migration/before/pom.xml
new file mode 100644
index 0000000..2b00929
--- /dev/null
+++ b/boot4-migration/before/pom.xml
@@ -0,0 +1,37 @@
+
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.5.16
+
+
+ com.ankurm
+ migration-demo
+ 1.0.0
+
+ 25
+
+
+ org.springframework.bootspring-boot-starter-web
+ org.springframework.bootspring-boot-starter-actuator
+ org.springframework.bootspring-boot-starter-security
+ org.springframework.bootspring-boot-starter-jdbc
+ org.springframework.bootspring-boot-starter-aop
+ com.h2databaseh2runtime
+
+ org.flywaydbflyway-core
+ org.springframework.bootspring-boot-starter-testtest
+ org.springframework.securityspring-security-testtest
+
+
+
+ org.springframework.bootspring-boot-maven-plugin
+
+
+
diff --git a/boot4-migration/before/src/main/java/com/ankurm/migration/DemoApplication.java b/boot4-migration/before/src/main/java/com/ankurm/migration/DemoApplication.java
new file mode 100644
index 0000000..ba69bca
--- /dev/null
+++ b/boot4-migration/before/src/main/java/com/ankurm/migration/DemoApplication.java
@@ -0,0 +1,11 @@
+package com.ankurm.migration;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class DemoApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(DemoApplication.class, args);
+ }
+}
diff --git a/boot4-migration/before/src/main/java/com/ankurm/migration/Gateway.java b/boot4-migration/before/src/main/java/com/ankurm/migration/Gateway.java
new file mode 100644
index 0000000..d04d5e5
--- /dev/null
+++ b/boot4-migration/before/src/main/java/com/ankurm/migration/Gateway.java
@@ -0,0 +1,18 @@
+package com.ankurm.migration;
+
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestClient;
+
+/** Takes the auto-configured RestClient.Builder. RestClient itself lives in spring-web, so this always compiles. */
+@Component
+public class Gateway {
+ private final RestClient client;
+
+ public Gateway(RestClient.Builder builder) {
+ this.client = builder.baseUrl("http://localhost:9").build();
+ }
+
+ public String describe() {
+ return client.getClass().getSimpleName();
+ }
+}
diff --git a/boot4-migration/before/src/main/java/com/ankurm/migration/Greeting.java b/boot4-migration/before/src/main/java/com/ankurm/migration/Greeting.java
new file mode 100644
index 0000000..fecf4a1
--- /dev/null
+++ b/boot4-migration/before/src/main/java/com/ankurm/migration/Greeting.java
@@ -0,0 +1,6 @@
+package com.ankurm.migration;
+
+import java.time.Instant;
+
+public record Greeting(long id, String title, Instant createdAt, String author) {
+}
diff --git a/boot4-migration/before/src/main/java/com/ankurm/migration/GreetingController.java b/boot4-migration/before/src/main/java/com/ankurm/migration/GreetingController.java
new file mode 100644
index 0000000..3ba6fc9
--- /dev/null
+++ b/boot4-migration/before/src/main/java/com/ankurm/migration/GreetingController.java
@@ -0,0 +1,62 @@
+package com.ankurm.migration;
+
+import java.time.Instant;
+import java.util.Map;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class GreetingController {
+
+ private static final Logger log = LoggerFactory.getLogger(GreetingController.class);
+
+ private final GreetingService service;
+ private final JdbcTemplate jdbc;
+ private final Gateway gateway;
+
+ public GreetingController(GreetingService service, JdbcTemplate jdbc, Gateway gateway) {
+ this.service = service;
+ this.jdbc = jdbc;
+ this.gateway = gateway;
+ }
+
+ @GetMapping("/greetings/{id}")
+ public Greeting greeting(@PathVariable long id) {
+ log.info("served greeting {} ........................................", id);
+ return new Greeting(id, service.title(id), Instant.parse("2026-01-02T03:04:05Z"), "ankur");
+ }
+
+ /** Serialised by Jackson's default record handling (no custom serializer). */
+ @GetMapping("/summary")
+ public Summary summary() {
+ Summary s = new Summary();
+ s.setId(1);
+ s.setTitle("Hello");
+ s.setCreatedAt(Instant.parse("2026-01-02T03:04:05Z"));
+ s.setAuthor("ankur");
+ return s;
+ }
+
+ /** Reads the table that the Flyway migration V1 creates. */
+ @GetMapping("/greetings/count")
+ public Map count() {
+ Integer n = jdbc.queryForObject("select count(*) from greeting", Integer.class);
+ return Map.of("rows", n);
+ }
+
+ /** How many times the @Aspect has intercepted GreetingService. */
+ @GetMapping("/aspect")
+ public Map aspect() {
+ return Map.of("interceptions", TimingAspect.COUNT.get());
+ }
+
+ @GetMapping("/gateway")
+ public Map gateway() {
+ return Map.of("client", gateway.describe());
+ }
+}
diff --git a/boot4-migration/before/src/main/java/com/ankurm/migration/GreetingService.java b/boot4-migration/before/src/main/java/com/ankurm/migration/GreetingService.java
new file mode 100644
index 0000000..d2fa800
--- /dev/null
+++ b/boot4-migration/before/src/main/java/com/ankurm/migration/GreetingService.java
@@ -0,0 +1,10 @@
+package com.ankurm.migration;
+
+import org.springframework.stereotype.Service;
+
+@Service
+public class GreetingService {
+ public String title(long id) {
+ return "Hello #" + id;
+ }
+}
diff --git a/boot4-migration/before/src/main/java/com/ankurm/migration/JsonConfig.java b/boot4-migration/before/src/main/java/com/ankurm/migration/JsonConfig.java
new file mode 100644
index 0000000..8012e25
--- /dev/null
+++ b/boot4-migration/before/src/main/java/com/ankurm/migration/JsonConfig.java
@@ -0,0 +1,22 @@
+package com.ankurm.migration;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import org.springframework.boot.jackson.JsonComponent;
+
+/** Boot 3 idiom: a Jackson 2 serializer registered through Boot's @JsonComponent. */
+@JsonComponent
+public class JsonConfig extends JsonSerializer {
+ @Override
+ public void serialize(Greeting g, JsonGenerator gen, SerializerProvider sp) throws IOException {
+ gen.writeStartObject();
+ gen.writeNumberField("id", g.id());
+ gen.writeStringField("title", g.title());
+ gen.writeStringField("createdAt", g.createdAt().toString());
+ gen.writeStringField("author", g.author());
+ gen.writeEndObject();
+ }
+}
diff --git a/boot4-migration/before/src/main/java/com/ankurm/migration/SecurityConfig.java b/boot4-migration/before/src/main/java/com/ankurm/migration/SecurityConfig.java
new file mode 100644
index 0000000..7b14ab5
--- /dev/null
+++ b/boot4-migration/before/src/main/java/com/ankurm/migration/SecurityConfig.java
@@ -0,0 +1,23 @@
+package com.ankurm.migration;
+
+import org.springframework.boot.autoconfigure.security.SecurityProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.annotation.Order;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.web.SecurityFilterChain;
+
+@Configuration
+public class SecurityConfig {
+
+ @Bean
+ @Order(SecurityProperties.BASIC_AUTH_ORDER)
+ SecurityFilterChain chain(HttpSecurity http) throws Exception {
+ http.authorizeHttpRequests()
+ .requestMatchers("/actuator/health/**", "/greetings/**", "/aspect", "/gateway").permitAll()
+ .anyRequest().authenticated()
+ .and()
+ .httpBasic();
+ return http.build();
+ }
+}
diff --git a/boot4-migration/before/src/main/java/com/ankurm/migration/Summary.java b/boot4-migration/before/src/main/java/com/ankurm/migration/Summary.java
new file mode 100644
index 0000000..79c88bf
--- /dev/null
+++ b/boot4-migration/before/src/main/java/com/ankurm/migration/Summary.java
@@ -0,0 +1,24 @@
+package com.ankurm.migration;
+
+import java.time.Instant;
+
+/**
+ * A plain bean (not a record) whose properties are deliberately NOT in alphabetical order.
+ * Jackson 2 wrote them in declaration order; Jackson 3 sorts bean properties alphabetically by default.
+ * (Records are unaffected: creator parameters are always written first, in constructor order.)
+ */
+public class Summary {
+ private long id;
+ private String title;
+ private Instant createdAt;
+ private String author;
+
+ public long getId() { return id; }
+ public void setId(long id) { this.id = id; }
+ public String getTitle() { return title; }
+ public void setTitle(String title) { this.title = title; }
+ public Instant getCreatedAt() { return createdAt; }
+ public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
+ public String getAuthor() { return author; }
+ public void setAuthor(String author) { this.author = author; }
+}
diff --git a/boot4-migration/before/src/main/java/com/ankurm/migration/TimingAspect.java b/boot4-migration/before/src/main/java/com/ankurm/migration/TimingAspect.java
new file mode 100644
index 0000000..22a8c48
--- /dev/null
+++ b/boot4-migration/before/src/main/java/com/ankurm/migration/TimingAspect.java
@@ -0,0 +1,21 @@
+package com.ankurm.migration;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.springframework.stereotype.Component;
+
+/** Needs AspectJ weaving support on the classpath: spring-boot-starter-aop in Boot 3. */
+@Aspect
+@Component
+public class TimingAspect {
+ static final AtomicInteger COUNT = new AtomicInteger();
+
+ @Around("execution(* com.ankurm.migration.GreetingService.*(..))")
+ public Object count(ProceedingJoinPoint pjp) throws Throwable {
+ COUNT.incrementAndGet();
+ return pjp.proceed();
+ }
+}
diff --git a/boot4-migration/before/src/main/resources/application.properties b/boot4-migration/before/src/main/resources/application.properties
new file mode 100644
index 0000000..9caae0e
--- /dev/null
+++ b/boot4-migration/before/src/main/resources/application.properties
@@ -0,0 +1,7 @@
+spring.datasource.url=jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1
+management.endpoints.web.exposure.include=health,metrics
+management.endpoint.health.show-details=always
+# Boot 3 logging properties that were deprecated for years (replaced by logging.logback.rollingpolicy.*)
+logging.file.name=${LOG_DIR:target/logs}/app.log
+logging.file.max-size=2KB
+logging.file.max-history=50
diff --git a/boot4-migration/before/src/main/resources/db/migration/V1__greeting.sql b/boot4-migration/before/src/main/resources/db/migration/V1__greeting.sql
new file mode 100644
index 0000000..86d6781
--- /dev/null
+++ b/boot4-migration/before/src/main/resources/db/migration/V1__greeting.sql
@@ -0,0 +1,2 @@
+create table greeting (id bigint primary key, title varchar(100) not null);
+insert into greeting values (1, 'first'), (2, 'second'), (3, 'third');
diff --git a/boot4-migration/before/src/test/java/com/ankurm/migration/CsrfProbeTest.java b/boot4-migration/before/src/test/java/com/ankurm/migration/CsrfProbeTest.java
new file mode 100644
index 0000000..76709ca
--- /dev/null
+++ b/boot4-migration/before/src/test/java/com/ankurm/migration/CsrfProbeTest.java
@@ -0,0 +1,24 @@
+package com.ankurm.migration;
+
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.web.servlet.MockMvc;
+
+/** Prints the status of a Basic-authenticated POST without a CSRF token (to compare 3.5 with 4.1). */
+@SpringBootTest(properties = "spring.security.user.password=pw")
+@AutoConfigureMockMvc
+class CsrfProbeTest {
+
+ @Autowired MockMvc mvc;
+
+ @Test
+ void postWithoutCsrfToken() throws Exception {
+ int status = mvc.perform(post("/anything").with(httpBasic("user", "pw"))).andReturn().getResponse().getStatus();
+ System.out.println("CSRFPROBE status=" + status);
+ }
+}
diff --git a/boot4-migration/before/src/test/java/com/ankurm/migration/GreetingControllerTest.java b/boot4-migration/before/src/test/java/com/ankurm/migration/GreetingControllerTest.java
new file mode 100644
index 0000000..e92b6ff
--- /dev/null
+++ b/boot4-migration/before/src/test/java/com/ankurm/migration/GreetingControllerTest.java
@@ -0,0 +1,27 @@
+package com.ankurm.migration;
+
+import static org.mockito.BDDMockito.given;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+class GreetingControllerTest {
+
+ @Autowired MockMvc mvc;
+ @MockBean GreetingService service;
+
+ @Test
+ void greetingUsesService() throws Exception {
+ given(service.title(7)).willReturn("mocked");
+ mvc.perform(get("/greetings/7")).andExpect(status().isOk()).andExpect(jsonPath("$.title").value("mocked"));
+ }
+}
diff --git a/boot4-migration/before/src/test/java/com/ankurm/migration/MockFieldTest.java b/boot4-migration/before/src/test/java/com/ankurm/migration/MockFieldTest.java
new file mode 100644
index 0000000..ef4af41
--- /dev/null
+++ b/boot4-migration/before/src/test/java/com/ankurm/migration/MockFieldTest.java
@@ -0,0 +1,22 @@
+package com.ankurm.migration;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.springframework.boot.test.context.SpringBootTest;
+
+/**
+ * A plain Mockito @Mock field inside a @SpringBootTest, with no MockitoExtension.
+ * Boot 3 initialised it through MockitoTestExecutionListener; Boot 4 removed that listener.
+ */
+@SpringBootTest
+class MockFieldTest {
+
+ @Mock Runnable task;
+
+ @Test
+ void mockFieldIsInitialised() {
+ assertNotNull(task, "@Mock field was never initialised");
+ }
+}
diff --git a/boot4-migration/output/01-before-boot35.txt b/boot4-migration/output/01-before-boot35.txt
new file mode 100644
index 0000000..33529d1
--- /dev/null
+++ b/boot4-migration/output/01-before-boot35.txt
@@ -0,0 +1,23 @@
+$ mvn package # before/ as written: Boot 3.5.16, JDK 25.0.5
+CSRFPROBE status=403
+[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
+[INFO] BUILD SUCCESS
+
+$ java -jar migration-demo-1.0.0.jar # Boot 3.5.16
+GET /summary
+{"id":1,"title":"Hello","createdAt":"2026-01-02T03:04:05Z","author":"ankur"} -> HTTP 200
+GET /greetings/1
+{"id":1,"title":"Hello #1","createdAt":"2026-01-02T03:04:05Z","author":"ankur"} -> HTTP 200
+GET /greetings/count
+{"rows":3} -> HTTP 200
+GET /aspect
+{"interceptions":1} -> HTTP 200
+GET /gateway
+{"client":"DefaultRestClient"} -> HTTP 200
+GET /actuator/metrics (first 60 chars)
+{"names":["application.ready.time","application.started.time
+GET /actuator/health (groups and status only)
+ groups=None status=UP components=['db', 'diskSpace', 'ping', 'ssl']
+GET /actuator/health/liveness
+ -> HTTP 404
+after 40 more requests, log directory holds: 1 app.log; 5 app.log...gz; (app.log is 342 bytes; limit set is logging.file.max-size=2KB or its 4.1 replacement)
diff --git a/boot4-migration/output/02-bump-only-compile-errors.txt b/boot4-migration/output/02-bump-only-compile-errors.txt
new file mode 100644
index 0000000..e5f0e90
--- /dev/null
+++ b/boot4-migration/output/02-bump-only-compile-errors.txt
@@ -0,0 +1,35 @@
+$ mvn -DskipTests package # same sources, same pom, only 3.5.16 -> 4.1.2
+[ERROR] 'dependencies.dependency.version' for org.springframework.boot:spring-boot-starter-aop:jar is missing. @ line 25, column 17
+[ERROR] 'dependencies.dependency.version' for org.springframework.boot:spring-boot-starter-aop:jar is missing. @ line 25, column 17
+
+# spring-boot-starter-aop has no managed version any more. Rename it to spring-boot-starter-aspectj and try again:
+$ mvn compile
+[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project migration-demo: Fatal error compiling: Cannot load from object array because "this.hashes" is null
+[INFO] BUILD FAILURE
+# Maven's compiler plugin 3.15.0 on JDK 25 crashed inside javac instead of listing the errors, so ask javac directly:
+$ javac -cp $(mvn dependency:build-classpath) src/main/java/**/*.java
+SecurityConfig.java:3: error: package org.springframework.boot.autoconfigure.security does not exist
+import org.springframework.boot.autoconfigure.security.SecurityProperties;
+JsonConfig.java:5: error: package com.fasterxml.jackson.core does not exist
+import com.fasterxml.jackson.core.JsonGenerator;
+JsonConfig.java:6: error: package com.fasterxml.jackson.databind does not exist
+import com.fasterxml.jackson.databind.JsonSerializer;
+JsonConfig.java:7: error: package com.fasterxml.jackson.databind does not exist
+import com.fasterxml.jackson.databind.SerializerProvider;
+JsonConfig.java:8: error: cannot find symbol
+import org.springframework.boot.jackson.JsonComponent;
+JsonConfig.java:12: error: cannot find symbol
+public class JsonConfig extends JsonSerializer {
+JsonConfig.java:11: error: cannot find symbol
+@JsonComponent
+JsonConfig.java:14: error: cannot find symbol
+ public void serialize(Greeting g, JsonGenerator gen, SerializerProvider sp) throws IOException {
+JsonConfig.java:14: error: cannot find symbol
+ public void serialize(Greeting g, JsonGenerator gen, SerializerProvider sp) throws IOException {
+SecurityConfig.java:14: error: cannot find symbol
+ @Order(SecurityProperties.BASIC_AUTH_ORDER)
+SecurityConfig.java:16: error: method authorizeHttpRequests in class HttpSecurity cannot be applied to given types;
+ http.authorizeHttpRequests()
+JsonConfig.java:13: error: method does not override or implement a method from a supertype
+ @Override
+javac error count: 12
diff --git a/boot4-migration/output/03-compiles-but-will-not-start.txt b/boot4-migration/output/03-compiles-but-will-not-start.txt
new file mode 100644
index 0000000..197117c
--- /dev/null
+++ b/boot4-migration/output/03-compiles-but-will-not-start.txt
@@ -0,0 +1,11 @@
+$ # s2 = before/ + Boot 4.1.2 + aop->aspectj + JsonConfig and SecurityConfig ported (imports only); every other dependency untouched
+$ mvn -DskipTests package
+[INFO] BUILD SUCCESS
+
+$ java -jar migration-demo-1.0.0.jar
+APPLICATION DID NOT START. The failure report:
+APPLICATION FAILED TO START
+***************************
+Description:
+Parameter 0 of constructor in com.ankurm.migration.Gateway required a bean of type 'org.springframework.web.client.RestClient$Builder' that could not be found.
+Action:
diff --git a/boot4-migration/output/04-starts-but-silently-wrong.txt b/boot4-migration/output/04-starts-but-silently-wrong.txt
new file mode 100644
index 0000000..8fd01c5
--- /dev/null
+++ b/boot4-migration/output/04-starts-but-silently-wrong.txt
@@ -0,0 +1,25 @@
+$ # s3 = s2 + spring-boot-starter-restclient (the only thing the start-up failure asked for). Flyway, properties and the rest still Boot 3 style.
+$ mvn -DskipTests package
+[INFO] BUILD SUCCESS
+
+$ java -jar migration-demo-1.0.0.jar
+GET /summary
+{"author":"ankur","createdAt":"2026-01-02T03:04:05Z","id":1,"title":"Hello"} -> HTTP 200
+GET /greetings/1
+{"id":1,"title":"Hello #1","createdAt":"2026-01-02T03:04:05Z","author":"ankur"} -> HTTP 200
+GET /greetings/count
+{"timestamp":"2026-10-29T05:55:01.660Z","status":500,"error":"Internal Server Error","path":"/greetings/count"} -> HTTP 500
+GET /aspect
+{"interceptions":1} -> HTTP 200
+GET /gateway
+{"client":"DefaultRestClient"} -> HTTP 200
+GET /actuator/metrics (first 60 chars)
+{"names":["application.ready.time","application.started.time
+GET /actuator/health (groups and status only)
+ groups=['liveness', 'readiness'] status=UP components=['db', 'diskSpace', 'livenessState', 'ping', 'readinessState', 'ssl']
+GET /actuator/health/liveness
+ -> HTTP 200
+root cause of that 500 in the application log:
+ Table "GREETING" not found
+Flyway log lines at start-up: 0
+after 40 more requests, log directory holds: 1 app.log; (app.log is 54188 bytes; limit set is logging.file.max-size=2KB or its 4.1 replacement)
diff --git a/boot4-migration/output/05-properties-migrator.txt b/boot4-migration/output/05-properties-migrator.txt
new file mode 100644
index 0000000..66868b2
--- /dev/null
+++ b/boot4-migration/output/05-properties-migrator.txt
@@ -0,0 +1,13 @@
+$ # s6 = s3 + spring-boot-properties-migrator (runtime scope)
+[INFO] BUILD SUCCESS
+app started
+$ grep -iE 'propert|logging.file|migrat' run.log # what the migrator printed at start-up
+WARN 2635 --- [ main] o.s.b.c.p.m.PropertiesMigrationListener :
+he use of configuration keys that have been renamed was found in the environment:
+Property source 'Config resource 'class path resource [application.properties]' via location 'optional:classpath:/'':
+ Key: logging.file.max-history
+ Replacement: logging.logback.rollingpolicy.max-history
+ Key: logging.file.max-size
+ Replacement: logging.logback.rollingpolicy.max-file-size
+Each configuration key has been temporarily mapped to its replacement for your convenience. To silence this warning, please update your configuration to use the new keys.
+(lines matching: 3)
diff --git a/boot4-migration/output/06-after-boot41.txt b/boot4-migration/output/06-after-boot41.txt
new file mode 100644
index 0000000..f97f720
--- /dev/null
+++ b/boot4-migration/output/06-after-boot41.txt
@@ -0,0 +1,23 @@
+$ mvn verify # after/ : Boot 4.1.2, one starter per technology, -test companions, Jackson 3, new packages
+CSRFPROBE status=403
+[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
+[INFO] BUILD SUCCESS
+
+$ java -jar migration-demo-1.0.0.jar
+GET /summary
+{"author":"ankur","createdAt":"2026-01-02T03:04:05Z","id":1,"title":"Hello"} -> HTTP 200
+GET /greetings/1
+{"id":1,"title":"Hello #1","createdAt":"2026-01-02T03:04:05Z","author":"ankur"} -> HTTP 200
+GET /greetings/count
+{"rows":3} -> HTTP 200
+GET /aspect
+{"interceptions":1} -> HTTP 200
+GET /gateway
+{"client":"DefaultRestClient"} -> HTTP 200
+GET /actuator/metrics (first 60 chars)
+{"names":["application.ready.time","application.started.time
+GET /actuator/health (groups and status only)
+ groups=['liveness', 'readiness'] status=UP components=['db', 'diskSpace', 'livenessState', 'ping', 'readinessState', 'ssl']
+GET /actuator/health/liveness
+ -> HTTP 200
+after 40 more requests, log directory holds: 1 app.log; 5 app.log...gz; (app.log is 513 bytes; limit set is logging.file.max-size=2KB or its 4.1 replacement)
diff --git a/boot4-migration/output/07-jackson-property-order.txt b/boot4-migration/output/07-jackson-property-order.txt
new file mode 100644
index 0000000..b22d7fb
--- /dev/null
+++ b/boot4-migration/output/07-jackson-property-order.txt
@@ -0,0 +1,4 @@
+# GET /summary on the same bean (id, title, createdAt, author declared in that order)
+Boot 3.5.16 (Jackson 2): {"id":1,"title":"Hello","createdAt":"2026-01-02T03:04:05Z","author":"ankur"} -> HTTP 200
+Boot 4.1.2 (Jackson 3, defaults): {"author":"ankur","createdAt":"2026-01-02T03:04:05Z","id":1,"title":"Hello"} -> HTTP 200
+Boot 4.1.2 + spring.jackson.use-jackson2-defaults: {"id":1,"title":"Hello","createdAt":"2026-01-02T03:04:05Z","author":"ankur"} -> HTTP 200
diff --git a/boot4-migration/output/08-test-compile-errors.txt b/boot4-migration/output/08-test-compile-errors.txt
new file mode 100644
index 0000000..587af37
--- /dev/null
+++ b/boot4-migration/output/08-test-compile-errors.txt
@@ -0,0 +1,6 @@
+$ mvn test-compile # after/ pom and main code, but the Boot 3 GreetingControllerTest (@MockBean, old @AutoConfigureMockMvc package)
+t1/src/test/java/com/ankurm/migration/GreetingControllerTest.java:[10,63] package org.springframework.boot.test.autoconfigure.web.servlet does not exist
+t1/src/test/java/com/ankurm/migration/GreetingControllerTest.java:[12,50] package org.springframework.boot.test.mock.mockito does not exist
+t1/src/test/java/com/ankurm/migration/GreetingControllerTest.java:[16,2] cannot find symbol
+t1/src/test/java/com/ankurm/migration/GreetingControllerTest.java:[20,6] cannot find symbol
+[INFO] BUILD FAILURE
diff --git a/boot4-migration/output/09-test-runtime-failure.txt b/boot4-migration/output/09-test-runtime-failure.txt
new file mode 100644
index 0000000..fe1311a
--- /dev/null
+++ b/boot4-migration/output/09-test-runtime-failure.txt
@@ -0,0 +1,5 @@
+$ mvn test # after/ pom and main code + the Boot 3 MockFieldTest unchanged (compiles) and CsrfProbeTest (one import fixed)
+CSRFPROBE status=403
+[ERROR] MockFieldTest.mockFieldIsInitialised:20 @Mock field was never initialised ==> expected: not
+[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
+[INFO] BUILD FAILURE
diff --git a/boot4-migration/output/10-security-dsl-compile-errors.txt b/boot4-migration/output/10-security-dsl-compile-errors.txt
new file mode 100644
index 0000000..8de0a87
--- /dev/null
+++ b/boot4-migration/output/10-security-dsl-compile-errors.txt
@@ -0,0 +1,8 @@
+$ javac SecurityConfig.java # Boot 3 file, import repointed to the 4.1.2 package; the old method-chaining DSL left as it was
+SecurityConfig.java:16: error: method authorizeHttpRequests in class HttpSecurity cannot be applied to given types;
+ http.authorizeHttpRequests()
+ ^
+ required: Customizer.AuthorizationManagerRequestMatcherRegistry>
+ found: no arguments
+ reason: actual and formal argument lists differ in length
+1 error
diff --git a/boot4-migration/scripts/run-all.sh b/boot4-migration/scripts/run-all.sh
new file mode 100755
index 0000000..cccf1c3
--- /dev/null
+++ b/boot4-migration/scripts/run-all.sh
@@ -0,0 +1,131 @@
+#!/usr/bin/env bash
+# Regenerates every file in output/. Needs JDK 25 (JAVA_HOME), Maven 3.9 and network access to Maven Central.
+# The "before" app (Boot 3.5.16) is never built in place: each stage is copied to a scratch directory.
+# Takes 15-25 minutes on a shared 2-vCPU box (each stage is a real Maven build and most start the app).
+set -u
+HERE=$(cd "$(dirname "$0")/.." && pwd); OUT=$HERE/output; W=${WORK:-/tmp/b4m-work}
+MVN=${MVN:-mvn}; PORT=${PORT:-18090}; URL=http://localhost:$PORT
+BOOT_OLD=3.5.16; BOOT_NEW=4.1.2
+mkdir -p "$OUT"; rm -rf "$W"; mkdir -p "$W"
+
+mv_() { local d=$1; shift; (cd "$d" && $MVN -B -T1 "$@" 2>&1 | grep -vE 'JAVA_TOOL_OPTIONS|^WARNING: |Download(ing|ed)' | sed "s#$W/##g"); }
+stage() { rm -rf "$W/$1"; mkdir -p "$W/$1"; cp -r "$HERE/$2/pom.xml" "$HERE/$2/src" "$W/$1/"; }
+bump() { sed -i "s#$BOOT_OLD#$BOOT_NEW#" "$W/$1/pom.xml"; }
+AFTER_MAIN=$HERE/after/src/main/java/com/ankurm/migration
+BEFORE_TEST=$HERE/before/src/test/java/com/ankurm/migration
+
+# start