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:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user