A Spring Boot 3.3 sample that accepts a batch of transactions on a fully non-blocking HTTP endpoint and hands the payload to an asynchronous listener. The client gets 202 Accepted immediately; the batch is then persisted on a separate thread through reactive R2DBC.
This is the reactive counterpart of the SpringEventListener sample: same intake pattern, but the web layer runs on WebFlux/Netty and persistence goes through Spring Data R2DBC instead of a servlet stack with JDBC.
| Concern | Implementation |
|---|---|
| Reactive HTTP layer | spring-boot-starter-webflux on Netty; the controller returns Mono<ResponseEntity<...>> and never blocks the request thread |
| Decoupling intake from processing | ApplicationEventPublisher publishes a DataMapper<T> event; @Async @EventListener consumes it on a separate task thread |
| Reactive persistence | ReactiveCrudRepository<Transaction, String> over R2DBC H2, written with saveAll(...) from the listener |
| Streaming read | GET /stream returns Flux<ServerSentEvent<Transaction>> as text/event-stream — chunked, paced, cancellable by the client |
| Application-assigned keys | Transaction implements Persistable<String> — with a UUID primary key Spring Data R2DBC would otherwise emit an UPDATE against a row that does not exist yet |
| Immutable payloads | Java records — TransactionDto, ResponseResult<T> |
| Profile-based config | dev profile → H2 R2DBC, HTTP port 9091 |
- Java 20, Spring Boot 3.3.4, Maven
spring-boot-starter-webflux,spring-boot-starter-data-r2dbc,r2dbc-h2- Project Reactor, Lombok
src/main/java/com/sahuri/thread/
├── Application.java # @SpringBootApplication + @EnableAsync
├── controller/TransactionController.java # reactive entry point (publishes the event)
├── service/TransactionService.java # @Async @EventListener consumer → persists the batch
├── persistence/Transaction.java # R2DBC-mapped entity (Persistable)
├── persistence/TransactionRepository.java# ReactiveCrudRepository
└── helper/ # DataMapper (event wrapper), DTOs
src/main/resources/
├── application.properties # activates the dev profile
├── application-dev.properties # R2DBC H2 URL, username, port 9091
└── schema.sql # table created on every start (dev)
src/test/java/com/sahuri/thread/
└── TransactionIntakeTest.java # 202 response + asynchronous persistence
mvn spring-boot:runThe app starts on Netty port 9091 with an in-memory H2 database (r2dbc:h2:mem:///DB-REACTIVE), so there is nothing to install or provision.
Build a jar instead:
mvn clean package
java -jar target/spring-reactive-0.0.1-SNAPSHOT.jar --spring.profiles.active=devRun the tests:
mvn testAccepts a JSON array of transactions and returns 202 Accepted immediately.
curl -X POST http://localhost:9091/api/v1/transaction/submit \
-H "Content-Type: application/json" \
-d '[
{"orderNumber":"ORD-101","item":"Kopi","qty":2,"amount":45000},
{"orderNumber":"ORD-102","item":"Teh","qty":1,"amount":15000},
{"orderNumber":"ORD-103","item":"Roti","qty":5,"amount":60000}
]'Response (HTTP 202, ~0.4s with the app already warm):
{"statusCode":"202 ACCEPTED","statusMessage":"ACCEPTED","result":"Submitting data successful"}The batch is picked up moments later on the async executor — actual log from the request above:
INFO --- [ main] o.s.b.web.embedded.netty.NettyWebServer : Netty started on port 9091 (http)
INFO --- [ task-1] c.s.thread.service.TransactionService : Received batch of 3 transaction(s) on thread task-1
INFO --- [ task-1] c.s.thread.service.TransactionService : Persisted trx b657f741-77da-4361-a31c-ef305a76957e: Kopi
INFO --- [ task-1] c.s.thread.service.TransactionService : Persisted trx 36495769-3231-4a4e-9841-951a2bac7486: Teh
INFO --- [ task-1] c.s.thread.service.TransactionService : Persisted trx d34c43ba-7da8-4ef2-8ffc-bd4e4d3a7249: Roti
Watch the thread names: HTTP is served by Netty's event loop, while the work runs on task-1 from the async executor. Request acceptance and request processing are decoupled, which is what keeps intake latency flat while a burst is being written.
Server-Sent Events stream of the stored transactions. The response is text/event-stream with Transfer-Encoding: chunked, paced at 50 ms per element.
curl -N -H "Accept: text/event-stream" http://localhost:9091/api/v1/transaction/streamReal frames from a run right after the POST above:
id:0473206f-997b-4b81-91b6-ed8c4ad06b7f
event:transaction
data:{"id":"0473206f-997b-4b81-91b6-ed8c4ad06b7f","orderNumber":"SSE-001","item":"Kopi","qty":2,"amount":45000.00,"status":true,"updatedDate":"...","createdDate":"..."}
id:...
event:transaction
data:{...}
Because the handler returns a Flux, the client sets the pace: a consumer that reads slowly simply receives the frames more slowly instead of the server buffering rows, and dropping the connection cancels the flow.
- Controller (publisher) — the request body is wrapped in a generic
DataMapper<T>event and published; the method returnsMono.just(202 Accepted)without waiting. - Service (listener) —
@Async @EventListenerinTransactionServicereceives the event on a separate thread, maps the DTOs to entities and callsrepository.saveAll(batch). - Async support —
@EnableAsynconApplicationplus@Asyncon the listener is what moves execution to a separate thread; without them the publisher stays synchronous and the client waits for the whole batch. - Schema —
schema.sqlruns on startup (spring.sql.init.mode=always) to create theTransactiontable in the in-memory H2 database.
mvn test—TransactionIntakeTesthas two cases: the intake test asserts the202 ACCEPTEDresponse and then that the rows really landed (read back through the R2DBC repository); the stream test reads the SSE endpoint off the wire and asserts theevent:transactionframes carry the persisted order numbers.- Live run — a
POSTwith 3 transactions returned202in ~0.4s and all 3 were persisted on threadtask-1, andcurl -Non the stream returned realevent:transactionframes with no error lines in the log.
- The stream re-reads the table from the beginning on every request and ends when the client disconnects; it does not tail new inserts yet. A change stream / tailable cursor, or a
Sinks.Manyfed by the listener, is the natural next step. - The intake endpoint has no validation, idempotency key, or authentication; this is an architecture sample, not a hardened service.
- Writes use
subscribe()inside the listener because that method sits outside the request's reactive chain. A production version would model the whole flow as one pipeline (or hand off through a channel/queue) instead of subscribing at the edge.
- SpringEventListener — the same intake pattern on a blocking/servlet stack, with the event-listener mechanics explained step by step.