Add cqrs module: CQRS in Spring Boot Without a Framework

Separate write/read DataSources, a JPA command side, and two interchangeable
projection listeners (sync and @Async) demonstrating the real latency-versus-
freshness trade-off CQRS forces. Includes a reflection-based proof that the
query side has no dependency on the write side, and a real failure/fix
transcript for the -parameters compiler flag this standalone reactor doesn't
inherit from spring-boot-starter-parent.
This commit is contained in:
2026-10-03 20:59:55 +00:00
parent d815a37f2e
commit b4a623b889
27 changed files with 1086 additions and 0 deletions
@@ -0,0 +1,76 @@
package com.ankurm.cqrsdemo;
import com.ankurm.cqrsdemo.command.OrderCommandController.LineRequest;
import com.ankurm.cqrsdemo.command.OrderCommandController.PlaceOrderRequest;
import com.ankurm.cqrsdemo.command.OrderCommandController.PlaceOrderResponse;
import com.ankurm.cqrsdemo.query.OrderSummary;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.TestRestTemplate;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
import java.time.Duration;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
/**
* {@code async-projection} profile: {@link com.ankurm.cqrsdemo.read.AsyncOrderSummaryProjection}
* is active instead. {@code POST /orders} now returns as soon as the write-side transaction
* commits, without waiting for the simulated 300ms read-model write -- but that means a query
* that lands inside that window sees the order not yet in the read model at all. This test
* deliberately queries in both places: immediately (inside the window) and after waiting for
* it to close (outside the window), and both results are the real point of the test, not the
* final consistent one alone.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
@ActiveProfiles("async-projection")
class AsyncProfileStalenessWindowTest {
@Autowired
private TestRestTemplate rest;
@Test
void placingAnOrderReturnsFastAndTheReadModelIsMomentarilyStale() {
// Warm-up request, deliberately not measured or asserted on: the very first HTTP
// request against a freshly started test context pays for connection-pool startup
// and Hibernate/JPA first-use costs that have nothing to do with this profile's
// async projection. Measuring the first request directly made the baseline ~370ms
// before the projection was even involved -- comfortably past the 300ms this test
// is trying to prove the command path *doesn't* pay. Everything after this call
// runs against an already-warm context.
rest.postForEntity("/orders", new PlaceOrderRequest("Warmup", List.of(new LineRequest("WARMUP", 1, 1))),
PlaceOrderResponse.class);
PlaceOrderRequest request = new PlaceOrderRequest("Dev",
List.of(new LineRequest("MECH-KEYBOARD", 1, 8999)));
long start = System.currentTimeMillis();
ResponseEntity<PlaceOrderResponse> placed = rest.postForEntity("/orders", request, PlaceOrderResponse.class);
long elapsedMs = System.currentTimeMillis() - start;
String orderId = placed.getBody().orderId();
System.out.println("POST /orders (async profile) took " + elapsedMs + " ms, orderId=" + orderId);
assertThat(elapsedMs).isLessThan(300L);
ResponseEntity<OrderSummary> immediately = rest.getForEntity("/order-summaries/" + orderId, OrderSummary.class);
System.out.println("Immediately after POST returned (inside the staleness window), "
+ "GET /order-summaries/" + orderId + " -> status=" + immediately.getStatusCode()
+ ", body=" + immediately.getBody());
assertThat(immediately.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
await().atMost(Duration.ofSeconds(2)).pollInterval(Duration.ofMillis(50)).untilAsserted(() -> {
ResponseEntity<OrderSummary> eventually = rest.getForEntity("/order-summaries/" + orderId, OrderSummary.class);
assertThat(eventually.getStatusCode()).isEqualTo(HttpStatus.OK);
System.out.println("After waiting for the projection (outside the staleness window), "
+ "GET /order-summaries/" + orderId + " -> status=" + eventually.getStatusCode()
+ ", body=" + eventually.getBody());
});
}
}
@@ -0,0 +1,43 @@
package com.ankurm.cqrsdemo;
import com.ankurm.cqrsdemo.query.OrderQueryController;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The query side's version of the hexagonal-architecture post's
* {@code mvn dependency:tree}: instead of trusting the Javadoc comment on
* {@link OrderQueryController} that says it cannot reach the write side, this inspects
* the actual compiled class through reflection. No Spring context needed -- this is a
* claim about the class file, not about runtime wiring.
*/
class QuerySideArchitectureTest {
@Test
void queryControllersOnlyDependencyIsTheReadJdbcTemplate() {
Constructor<?>[] constructors = OrderQueryController.class.getDeclaredConstructors();
assertThat(constructors).hasSize(1);
Class<?>[] paramTypes = constructors[0].getParameterTypes();
System.out.println("OrderQueryController constructor parameter types: "
+ java.util.Arrays.toString(paramTypes));
assertThat(paramTypes).containsExactly(JdbcTemplate.class);
}
@Test
void queryControllerDeclaresNoFieldFromTheWriteSidePackage() {
Field[] fields = OrderQueryController.class.getDeclaredFields();
for (Field field : fields) {
String packageName = field.getType().getPackageName();
System.out.println("field " + field.getName() + " : " + field.getType().getName());
assertThat(packageName)
.as("field %s should not be able to reference the write side", field.getName())
.doesNotStartWith("com.ankurm.cqrsdemo.command");
}
}
}
@@ -0,0 +1,51 @@
package com.ankurm.cqrsdemo;
import com.ankurm.cqrsdemo.command.OrderCommandController.LineRequest;
import com.ankurm.cqrsdemo.command.OrderCommandController.PlaceOrderRequest;
import com.ankurm.cqrsdemo.command.OrderCommandController.PlaceOrderResponse;
import com.ankurm.cqrsdemo.query.OrderSummary;
import org.junit.jupiter.api.Test;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
import org.springframework.boot.resttestclient.TestRestTemplate;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.ResponseEntity;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Default profile: no {@code async-projection}, so {@link com.ankurm.cqrsdemo.read.SyncOrderSummaryProjection}
* is active. The projection's simulated write delay (300ms, see {@code application.properties})
* runs on the same thread that is about to answer the command's HTTP request, so it shows up
* directly in how long {@code POST /orders} takes -- and because of that, the read model is
* already correct the instant the command returns, with no waiting required.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
class SyncProfileLatencyTest {
@org.springframework.beans.factory.annotation.Autowired
private TestRestTemplate rest;
@Test
void placingAnOrderTakesAtLeastTheSimulatedProjectionDelayAndTheReadModelIsImmediatelyConsistent() {
PlaceOrderRequest request = new PlaceOrderRequest("Priya",
List.of(new LineRequest("USB-C-CABLE", 2, 1299), new LineRequest("USB-C-HUB", 1, 4999)));
long start = System.currentTimeMillis();
ResponseEntity<PlaceOrderResponse> placed = rest.postForEntity("/orders", request, PlaceOrderResponse.class);
long elapsedMs = System.currentTimeMillis() - start;
String orderId = placed.getBody().orderId();
System.out.println("POST /orders (sync profile) took " + elapsedMs + " ms, orderId=" + orderId);
assertThat(elapsedMs).isGreaterThanOrEqualTo(300L);
ResponseEntity<OrderSummary> summary = rest.getForEntity("/order-summaries/" + orderId, OrderSummary.class);
System.out.println("Immediately after POST returned, GET /order-summaries/" + orderId
+ " -> status=" + summary.getStatusCode() + ", body=" + summary.getBody());
assertThat(summary.getBody()).isNotNull();
assertThat(summary.getBody().totalCents()).isEqualTo(2 * 1299 + 4999);
assertThat(summary.getBody().status()).isEqualTo("PLACED");
}
}