TL;DR: Last month I published a nine-discipline roadmap for restarting my Java journey. This is the receipt.
awongCM/ecommerce-apiis a Spring Boot 3.5 / Java 25 ecommerce API where every discipline on that table — fromOptionalchains to sealed types, virtual threads, ZGC, GraalVM native image and a Spring AI triage consumer — is a file you can open, a test you can run, or an honest “not measured” row in the architecture doc. Here’s the walkthrough, the code, the gotchas that bit me, and the gaps I refused to paper over.
Generated AI image by Google Gemini Nano Banana
Introduction
In My Java Comeback post, I did the thing engineers do when they want to be held accountable: I published a plan. Nine disciplines, three delivery phases, and a capstone at the end that would prove disciplines 1 through 8 worked together rather than as nine separate weekend tutorials.
Plans are cheap. So here is the capstone: awongCM/ecommerce-api — a production-shaped REST API for an ecommerce store. Register, browse a catalogue, fill a cart, check out, get charged, receive an order-created event downstream.
Two honest notes before we start.
First, the roadmap said “payments-domain simulation.” What I actually built is an ecommerce shell around a payments hot path. That was deliberate. Everybody already has a mental model of a checkout button, which means the domain never becomes the hard part of the post — the engineering stays the hard part. And the interesting bits (idempotency, optimistic locking, circuit breakers, transactional outbox, FK-safe transaction boundaries) are payments problems regardless of the shopfront wrapped around them.
Second, this is not a “look how clever I am” tour. Two of the nine disciplines are only partially proven, and the architecture doc says so in a table with the word not measured in it. That’s the point. A capstone you can’t critique isn’t evidence of anything.
By the end of this post you’ll have:
- A scoreboard pattern you can apply to your own comeback project — discipline mapped to a verifiable artifact, not a claim
- Real code for the modern-Java moves that actually change how you write services
- The failure modes that cost me hours, so they cost you minutes
The scoreboard: nine disciplines, nine pieces of evidence
The rule I set for myself: a discipline is only “done” when someone else can verify it without taking my word for it. Not “I read about virtual threads” — a file, a flag, a test, or a measured number.
| # | Discipline | Evidence in the repo |
|---|---|---|
| 1 | Syntax & core APIs | Optional chaining in OrderService.checkout, Page<T>.map(ProductDTO::from), text blocks in AI prompts |
| 2 | Design patterns | Strategy (PaymentGatewayClient + @ConditionalOnProperty), State (Order.transitionTo), Outbox, Adapter (Jersey vs MVC) |
| 3 | Spring Boot & microservices | Layered services, ArchitectureTest (ArchUnit), Actuator custom endpoint, Testcontainers integration tests |
| 4 | JVM internals & tuning | -XX:+UseZGC in the Dockerfile, scripts/capture-checkout-jfr.sh |
| 5 | Concurrency | Virtual threads, StructuredTaskScope post-commit, InventoryServiceConcurrencyTest |
| 6 | Modern Java features | sealed interface PaymentOutcome, records everywhere, record deconstruction patterns |
| 7 | Cloud-native | Multi-stage Dockerfile, -Pnative GraalVM profile, k8s/ manifests with liveness/readiness probes |
| 8 | AI-native Java | OrderAnomalyTriageConsumer + Spring AI 1.1.x, feature-flagged, off the checkout transaction |
| 9 | Capstone | All of the above in one running system — plus a metrics table that still says “not measured” |
One note on how the rest of this post is arranged: the sections below are grouped by how these disciplines actually compose in the code, not by number. Disciplines 1, 2 and 6 share a section because modern Java collapses them into the same handful of language features, and concurrency (5) comes before JVM tuning (4) because the most interesting thing in a flight recording is a virtual-thread event — which won’t mean much until virtual threads are on the table. The scoreboard above is the index; use it if you want to jump straight to one row.
Disciplines 1, 2 and 6: the language does more of the work now
I’m collapsing these three because in modern Java they collapse in practice. Half the “patterns” I’d have hand-rolled in Java 8 are now language features.
Sealed types replace the instanceof ladder
A payment attempt has exactly three observable outcomes. In 2016 I’d have modelled that with an enum plus a nullable message field, or a small hierarchy and a chain of instanceof checks. In Java 25 it’s a sealed interface with record variants:
public sealed interface PaymentOutcome
permits PaymentOutcome.Captured,
PaymentOutcome.GatewayUnavailable,
PaymentOutcome.Failed {
/** Payment was captured successfully. */
record Captured(String gatewayReference, String cardLast4) implements PaymentOutcome {}
/** Circuit is open or all retries exhausted — gateway is not reachable. */
record GatewayUnavailable(String reason) implements PaymentOutcome {}
/** Gateway reachable but returned a business failure (declined, invalid token). */
record Failed(String reason) implements PaymentOutcome {}
}
The payoff is at the call site, inside the checkout orchestration:
PaymentOutcome outcome = paymentService.processPayment(order, request.getPaymentToken());
switch (outcome) {
case PaymentOutcome.Captured c -> {
order.transitionTo(OrderStatus.CONFIRMED);
log.info("Checkout captured, ref={}", c.gatewayReference());
}
case PaymentOutcome.GatewayUnavailable u -> {
releaseReservedStock(cart);
order.transitionTo(OrderStatus.CANCELLED);
throw new IllegalStateException("Payment gateway unavailable: " + u.reason());
}
case PaymentOutcome.Failed f -> {
releaseReservedStock(cart);
order.transitionTo(OrderStatus.CANCELLED);
throw new IllegalStateException("Payment failed: " + f.reason());
}
}
Key points:
- No
defaultbranch. Because the interface is sealed and the switch is exhaustive, the compiler proves I’ve handled every case. Add a fourth outcome — sayRequiresActionfor 3DS — and this file stops compiling. That’s the feature. - A decline is not an outage. Keeping those as distinct types forced me to keep them distinct in behaviour: retryable gateway exceptions get rethrown so Resilience4j can open the circuit, while business declines return
Failedimmediately. Collapse them into one “payment failed” boolean and you get a circuit breaker that trips on customers typing the wrong CVV. - This is the Visitor pattern, retired. Sealed hierarchy plus exhaustive matching does what Visitor existed to do, minus the ceremony.
Record patterns tighten it further. In the AI triage service, a private sealed result type gets deconstructed in the guard itself:
private sealed interface PrepareResult {
record AlreadyTriaged() implements PrepareResult {}
record NoPayment() implements PrepareResult {}
record Ready(Payment payment) implements PrepareResult {}
}
// ...
PrepareResult prepared = readTx.execute(status -> prepare(orderId, event.getOrderNumber()));
if (!(prepared instanceof PrepareResult.Ready(Payment payment))) {
return; // already triaged, or nothing to triage
}
// `payment` is in scope here, typed, non-null
Three early-exit reasons, one branch, no null checks.
The patterns that didn’t get replaced
Not everything collapses. Strategy is alive and well — the payment gateway is an interface with two implementations selected by configuration:
@Bean
@ConditionalOnProperty(
name = "app.payment-gateway.provider",
havingValue = "mock",
matchIfMissing = true)
PaymentGatewayClient mockPaymentGatewayClient() {
return new MockPaymentGatewayClient();
}
That single matchIfMissing = true is why mvn verify works on a laptop with no Stripe keys — a small decision that pays for itself every time someone clones the repo.
State survives too, as an exhaustive enum switch guarding order transitions:
private void validateTransition(OrderStatus from, OrderStatus to) {
boolean valid = switch (from) {
case PENDING -> to == OrderStatus.CONFIRMED || to == OrderStatus.CANCELLED;
case CONFIRMED -> to == OrderStatus.PROCESSING || to == OrderStatus.CANCELLED;
case PROCESSING -> to == OrderStatus.SHIPPED;
case SHIPPED -> to == OrderStatus.DELIVERED;
case DELIVERED -> to == OrderStatus.REFUNDED;
case CANCELLED, REFUNDED -> false;
};
if (!valid) {
throw new IllegalStateException("Invalid transition: " + from + " → " + to);
}
}
Illegal states become unrepresentable at the boundary where it matters — an admin can’t mark a cancelled order as shipped, and the rule lives in the domain object rather than scattered across controllers.
And discipline 1 — the unglamorous one — shows up everywhere once you look: the idempotency check is a three-line Optional chain, pagination is Page<T>.map(ProductDTO::from), and the AI system prompt is a text block instead of a StringBuilder crime scene.
Discipline 3: Spring Boot that doesn’t rot
Anyone can wire a controller to a repository. The discipline is keeping the layers honest six months later, so I made the build enforce it with ArchUnit:
@ArchTest
static final ArchRule web_layers_must_not_access_repositories =
noClasses()
.that().resideInAnyPackage("..controller..", "..jersey..")
.should().dependOnClassesThat().resideInAnyPackage("..repository..")
.because("HTTP adapters delegate to services, not repositories");
@ArchTest
static final ArchRule no_field_injection =
noFields().should().beAnnotatedWith(Autowired.class)
.because("Use constructor injection for required dependencies");
This caught a real leak: several controllers were injecting CustomerRepository just to turn a JWT email into a customer id. The fix was a one-method CustomerLookupService — and now the rule stops it coming back.
The rest of discipline 3 is the boring-but-necessary layer:
- Testing by intent, not by ritual. Mockito for service rules,
@WebMvcTestfor HTTP mapping and security wiring,@DataJpaTeston H2 for queries, and Testcontainers Postgres for checkout — because H2 is not Postgres when dialect, Flyway and locking behaviour matter. The base class degrades gracefully:@Testcontainers(disabledWithoutDocker = true)means a contributor with no Docker still gets a greenmvn verify. - Observability that says something. A custom
/actuator/inventorylow-stock endpoint and a payment-gateway health indicator, with liveness and readiness split so a flaky gateway doesn’t get the pod restarted. - Two HTTP stacks on one core. Spring MVC at
/api/v1/*and Jersey (JAX-RS) at/jersey/*, sharing security, services and persistence. It’s the Adapter pattern as a live experiment, and it proves the business logic genuinely doesn’t know what web framework is in front of it. - Resilience at the edges only. Circuit breaker and retry around the payment gateway, a rate limiter on product search. Nowhere else.
Discipline 5: concurrency, and the part everyone gets wrong
This was the discipline I was most sure was overdue, and the one that taught me the most.
Checkout ends with two independent jobs: write an audit record, and fire a notification. Classic @Async territory — except @Async inside a transaction is a trap. Fork before commit and your audit row can describe an order that never existed.
So the fan-out is registered as an after-commit callback and executed on virtual threads with StructuredTaskScope:
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow(),
cfg -> cfg.withThreadFactory(Thread.ofVirtual().factory()))) {
scope.fork(() -> auditService.logSync(
"Order", savedOrder.getId().toString(),
"CHECKOUT", null, savedOrder.getOrderNumber(),
auditContext.actor(), auditContext.traceId()));
scope.fork(() -> log.info("Checkout notification for order {} (customer {})",
savedOrder.getOrderNumber(), customerId));
scope.join();
}
Key points:
- This is post-commit parallelism, not fire-and-forget. The
join()means the response doesn’t return until both tasks finish. I’m buying parallelism, not hidden latency — and if one task fails, the scope tells me instead of swallowing it into a thread pool. - Context does not ride along. MDC and
SecurityContextHolderare thread-local; virtual threads forked from a scope don’t inherit them. Every forked audit write loggedactor=systemuntil I snapshotted the context on the request thread first:
public record AuditContext(String actor, String traceId) {}
public AuditContext captureContext() {
return new AuditContext(getCurrentUser(), MDC.get("traceId"));
}
That five-line record is the single most useful thing I learned this whole phase. If you take one thing from discipline 5: virtual threads change your throughput model, not your context-propagation model.
- Outbox stays inside the transaction. Only independent, non-transactional work belongs in the post-commit scope. The Kafka event row is written in the same transaction as the order — more on that next.
The oversell test proves the other half of the story. Two virtual threads race for the last unit of stock behind a CountDownLatch, against @Version optimistic locking plus @Retryable:
try (ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor()) {
// both threads block on startGate, then call reserveStock(productId, 1)
}
assertThat(successes.get()).isEqualTo(1);
assertThat(insufficient.get()).isEqualTo(1);
assertThat(product.getStockQuantity()).isZero();
One winner, one InsufficientStockException, zero oversell. That assertion is worth more than any paragraph I could write about optimistic locking.
The transaction decisions worth stealing
Two choices in this repo took the longest to reason about, and they’re the ones I’d defend in an interview.
Payment joins the checkout transaction. A payments row has a foreign key to orders. Run payment in REQUIRES_NEW and the insert can fire before the order row is visible to that new transaction — FK violation, or worse, an orphan payment. So PaymentService.processPayment uses default REQUIRED propagation and commits with the order. The trade-off is honest: the transaction is held open while the gateway responds. At real scale you’d authorise synchronously and capture asynchronously. At this scale, correctness beats hold time.
Kafka publishing goes through a transactional outbox. Publishing directly from the service is a dual-write: the DB commits and the broker doesn’t, or the reverse. Instead OutboxService.enqueueOrderCreated writes a PENDING row inside the checkout transaction, and a scheduled OutboxPoller publishes after commit and marks it SENT. Consumers get at-least-once delivery and must dedupe. The MVP limits are documented rather than hidden — no SELECT … FOR UPDATE SKIP LOCKED for multi-instance pollers yet, no dead-letter cap on retries.
Discipline 4: JVM tuning you can actually observe
Generational ZGC is on by default in the container:
ENTRYPOINT ["java", \
"--enable-preview", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:+UseZGC", \
"-jar", "/app/app.jar"]
On JDK 25, enabling ZGC gets you the generational collector — the separate -XX:+ZGenerational flag was removed in JDK 24. The young/old split means less live data scanned per cycle, which matters for the long-lived order and payment objects this app holds. You pay a little extra footprint for coloured pointers.
scripts/capture-checkout-jfr.sh takes a 60-second Java Flight Recorder profile you open in JDK Mission Control. Three things worth looking for:
jdk.VirtualThreadPinnedevents — if a library’ssynchronizedblock pins a carrier thread, this is where it shows up. JDK 24 unpinned most Hibernate/JDBC cases, but “most” isn’t “all”.- GC pause distribution — under ZGC these should be microseconds. If you’re seeing milliseconds, something else is going on.
jdk.ObjectAllocationInNewTLABflame graphs — which service method is manufacturing the most garbage.
One caveat baked into the script’s docs: it targets a host-run JDK, because jcmd and jps don’t exist in eclipse-temurin:25-jre.
Discipline 7: cloud-native, with the asterisk left in
The Dockerfile is multi-stage with a JVM runtime as the default target, a non-root user, an Actuator-based HEALTHCHECK, and an optional GraalVM native stage you have to ask for:
docker build --target native-runtime -t ecommerce-api:native .
k8s/ has deployment manifests with separate liveness and readiness probes, ConfigMap and Secret wiring, plus a local Colima setup.
Why bother with native at all? Startup drops from roughly eight seconds to under half a second, and idle RSS from around 350 MB to around 80 MB. Those numbers decide whether scale-to-zero is viable.
Here’s the asterisk. Native image builds succeed with GraalVM 25 (~227 MB binary on arm64), but Jersey’s reflection-heavy JAX-RS runtime is unverified under native, and the Spring AI starters are JVM-only. So the JVM image stays the production default and native is a second artifact, clearly labelled. The alternative — quietly claiming “cloud-native ✅” — would have been a lie in a table cell.
Discipline 8: AI on the JVM without wrecking the hot path
This is the discipline most likely to be done badly, and I deliberately shipped it as a separate pull request after the platform work merged, so it couldn’t quietly bend the architecture to suit itself.
The design rule I wrote down before any code: AI classifies and drafts; Java commits money and inventory.
So the triage feature is a sibling Kafka consumer on orders.created — consumer group order-anomaly-triage, running alongside the notification consumer, nowhere near OrderService.checkout. It loads payment data in a short read transaction, calls the model outside any transaction, then persists a structured label in a second write transaction:
PrepareResult prepared = readTx.execute(status -> prepare(orderId, event.getOrderNumber()));
if (!(prepared instanceof PrepareResult.Ready(Payment payment))) {
return;
}
// LLM call intentionally outside any DB transaction
AnomalyClassificationResult result = classify(event, payment);
validateResult(result);
writeTx.executeWithoutResult(status -> persist(orderId, event.getOrderNumber(), result));
Labels are a closed set — LIKELY_FRAUD, GATEWAY_NOISE, CUSTOMER_RETRY, OPS_REVIEW — with OPS_REVIEW as the uncertainty default. Everything else about it is defensive on purpose:
- Flag default off (
ORDER_ANOMALY_TRIAGE_ENABLED), and a startup validator that fails fast if you enable it in Docker without a real API key. Silent stub responses in production would be worse than a crash. - Idempotent on
order_id— a unique constraint, with a concurrent-insertDataIntegrityViolationExceptiontreated as success rather than an error. - PII redaction before the prompt leaves the building — customer name, email local-part, card last4 and gateway reference are all masked when the provider is external.
- Output validation — classification present, rationale non-blank, confidence inside 0–1, or it throws. Model output is untrusted input.
- A
@PrimaryStubChatModelin dev and tests, somvn verifyneeds no API key and no network.
Notice what’s not here: no RAG, no tools, no auto-refund, no LLM anywhere near a payment capture. Those were deliberate omissions, documented as out of scope. A thin “call an LLM from a controller” wrapper would have ticked the table cell and proved nothing.
Discipline 9: the capstone, including what it can’t prove yet
Nine is where the previous eight either compose or fall over. One checkout request touches modern language features (sealed outcome), patterns (strategy gateway, state machine), Spring Boot layering, optimistic locking, Resilience4j, the transactional outbox, post-commit virtual threads, Kafka, and — optionally, well downstream — Spring AI.
And here is the row I left in ARCHITECTURE.md:
| Metric | JVM (ZGC, Java 25) | Native (GraalVM 25) |
|---|---|---|
| Startup to first health | not measured | n/a |
| RSS at idle | not measured | n/a |
| 20-concurrent checkout (virtual threads) | not measured | n/a |
scripts/checkout-load.sh fires N concurrent checkouts with unique idempotency keys, but in its current form the concurrent requests share one cart — so after the first success the rest fail on an empty cart. It’s a working harness and weak evidence, and calling it “20-concurrent checkout proven” would be exactly the kind of résumé-driven engineering this whole roadmap was meant to avoid.
Unmeasured is a finding, not a failure. It’s the next task, written down where I can’t forget it.
Troubleshooting: the gotchas that cost me hours
| Problem | Cause | Fix |
|---|---|---|
class file version 69.0 vs runtime max 61.0 |
sdkman loads after Homebrew in ~/.zshrc and pins Java 17 |
Export JAVA_HOME to the JDK 25 path explicitly; confirm with mvn -v |
| Surefire: “Could not self-attach” | Mockito can’t dynamically attach on JDK 25 | Add -javaagent:${org.mockito:mockito-core:jar} -XX:+EnableDynamicAgentLoading to argLine |
java -jar fails after a clean build |
--enable-preview is needed at runtime too, not just compile |
Add it to the Docker ENTRYPOINT and Maven jvmArguments |
Native build: UnsupportedFeatureException on Logback |
--initialize-at-build-time=org.slf4j puts LogbackMDCAdapter in the image heap while Logback stays run-time init |
Don’t pass that flag; native-image builds cleanly without it |
| Flyway fails on Postgres in Docker | Flyway 11 split out database modules | Add flyway-database-postgresql |
No default constructor found on a service |
Two constructors confused Spring’s autowiring | Mark the production constructor @Autowired; keep test factories private |
| Mockito strict-stubbing failures on passthrough mocks | Unused stubs on a TransactionTemplate mock |
Use lenient() for genuinely conditional stubs |
Audit rows logged as actor=system |
MDC / SecurityContext don’t propagate into forked virtual threads |
Snapshot context on the request thread, pass it explicitly |
Do’s and don’ts for building your own capstone
Do:
- Make each discipline verifiable by someone else. A file, a test, a flag, a measured number. “I learned about X” is not evidence.
- Ship the risky discipline as its own PR. AI went in separately from the platform work precisely so it couldn’t reshape the architecture while nobody was looking.
- Write the trade-off down next to the decision. “Payment shares the checkout transaction, which lengthens hold time, which we’d fix with async capture at 10× traffic” is an interview answer. “We use
@Transactional“ is not. - Leave the gaps visible. The “not measured” table is the most credible thing in the repo.
Don’t:
- Don’t let a table cell drive the design. A one-line LLM call would have marked discipline 8 complete and taught me nothing.
- Don’t assume modern runtime means modern behaviour. Virtual threads give you cheap concurrency, not automatic context propagation. Sealed types give you exhaustiveness, not correctness.
- Don’t mock what correctness depends on. Checkout tests run against real Postgres via Testcontainers because H2 isn’t Postgres where it counts.
- Don’t confuse a harness with evidence. A load script that runs is not a load test that proves anything.
Conclusion
The roadmap post was the hypothesis: that treating Java as a genuine restart rather than a refresh would produce something with a bigger trajectory than picking up where I left off. This repo is the experiment, and the result is roughly what I hoped for — seven disciplines demonstrably landed, two partially, and a clear, honest list of what remains.
If you’re carrying your own “I should get back into X” list, steal the scoreboard idea more than the stack. Pick a domain your reader already understands. Force every discipline to produce an artifact someone can open. And when a discipline only half-lands, write the half down.
Your next steps:
- Clone it and run
mvn -q verifywith JDK 25 — Docker optional, no API keys needed. Then openPaymentOutcomeand try adding a fourth outcome; the compiler will show you exactly where exhaustiveness earns its keep. - Pick one discipline you’d claim on your CV and go find the artifact that proves it. If there isn’t one, that’s your next weekend.
- Steal the
AuditContextpattern before your firstStructuredTaskScopefan-out silently loses your trace IDs.
My next steps: run scripts/checkout-load.sh properly (one cart per virtual user), capture JVM and native RSS, and fill in that table. Then verify Jersey under GraalVM native, or document why it stays JVM-only for good.
If you’ve built your own comeback capstone — or you look at these transaction boundaries and think I’ve got one of them wrong — I’d genuinely like to hear it. Drop a comment or open an issue on the repo.
Till next time, Happy Coding!
PS: If you’re earlier in your career and this looks intimidating, read the scoreboard table again and notice how ordinary most of the rows are. Constructor injection. A test that asserts one winner. A flag that defaults to off. Mastery here isn’t exotic knowledge — it’s refusing to tick a box you can’t defend.