A comprehensive guide documenting the migration of member-service from Servlet/JPA (blocking) to WebFlux/R2DBC (reactive).
Commit: 7ecb2e5 (Jan 2026)
Branch: feature/webflux-migration
Why Migrate to WebFlux?
The Problem with Blocking Architecture
Traditional Spring MVC with JPA uses blocking I/O:
Request → Thread waits for DB → Thread waits for response → Return
Each request holds a thread while waiting. Under high load, threads get exhausted.
The Reactive Solution
Spring WebFlux with R2DBC uses non-blocking I/O:
Request → Subscribe to DB → Thread released → Callback when ready → Return
Threads are released immediately, allowing massive concurrency with fewer resources.
Benefits
- Better scalability — Handle more concurrent requests with fewer threads
- Resource efficiency — No thread blocking during I/O operations
- Backpressure support — Control data flow between producer and consumer
- Modern architecture — Aligns with reactive microservice patterns
Stress Test Evidence — BEFORE Migration
Test Date: 2026-01-26
Stack: Servlet (Tomcat) + JPA (JDBC) + PostgreSQL
Endpoint: POST /auth/token (login)
Flow: auth-service → member-service → PostgreSQL
Results Summary
| Concurrent Users | Success Rate | Throughput | P50 Latency |
|---|---|---|---|
| 1 (sequential) | 100% | 3.2 req/s | 292 ms |
| 2 | 95% | 7.8 req/s | 224 ms |
| 5 | 60% | 18.0 req/s | 232 ms |
| 10 | 32.5% | 27.8 req/s | 246 ms |
| 20 | 17.5% | 29.2 req/s | 615 ms |
| 50 | 20% | 36.2 req/s | 1259 ms |
Key Findings
- Breaking point at ~5 concurrent users — Success rate drops from 95% to 60%
- 80% failure rate at high concurrency — System cannot handle 20+ users
- Latency explodes under load — P50 goes from 224ms (2 users) to 1259ms (50 users) = 5.6x increase
- Throughput plateaus at ~36 req/s — Adding more users doesn’t increase throughput
Visual: Success Rate Collapse
Success Rate
100% |*
80% |
60% | *
40% | *
20% | *---*---*
0% +-------------------------
1 2 5 10 20 50 Concurrent Users
BEFORE vs AFTER Comparison (TBD)
| Metric | BEFORE (Servlet/JPA) | AFTER (WebFlux/R2DBC) | Change |
|---|---|---|---|
| Success Rate (5 users) | 60% | TBD | |
| Success Rate (50 users) | 20% | TBD | |
| P50 Latency (50 users) | 1259 ms | TBD | |
| Max Throughput | ~36 req/s | TBD |
Migration Overview — Change Impact by Layer
The migration follows a clear dependency order. Changes at one layer ripple to the next:
Model (Entity) → Repository → Service → Controller
↓ ↓ ↓ ↓
Core changes Interface Handle what Reactive
(most work) changes JPA did wrappers
Layer Summary
| Layer | Change Level | Key Changes |
|---|---|---|
| Model (Entity) | Core (most significant) | @Entity→@Table, remove @GeneratedValue/@PrePersist/@ManyToOne, remove UserDetails |
| Repository | Minimal | JpaRepository→ReactiveCrudRepository, Optional→Mono, List→Flux |
| Service | Ripple effect | Handle @PrePersist/@GeneratedValue manually, reactive chains (flatMap/switchIfEmpty) |
| Controller | Wrapper | ResponseEntity→Mono<ResponseEntity>, use .map()/.defaultIfEmpty() |
Detailed Documentation by Layer
1. Model (Entity & DTO)
Status: Documented
Core changes at the data model level including R2DBC annotations, removing JPA lifecycle callbacks, and architectural decisions.
2. Repository
Status: Documented
Interface changes from JpaRepository to ReactiveCrudRepository, return type changes, and native SQL queries.
3. Service
Status: Documented
Reactive programming patterns, manual handling of JPA callbacks, and reactive chain operators.
4. Controller
Status: Documented
Reactive response handling, Mono wrappers, and WebFlux endpoint patterns.
Quick Reference — Before/After
Dependencies (pom.xml)
<!-- Before (Servlet/JPA) -->
<dependency>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<artifactId>postgresql</artifactId>
</dependency>
<!-- After (WebFlux/R2DBC) -->
<dependency>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
<artifactId>r2dbc-postgresql</artifactId>
</dependency>
Configuration (application.yml)
# Before (JDBC)
spring:
datasource:
url: jdbc:postgresql://localhost:5432/db
username: user
password: pass
# After (R2DBC)
spring:
r2dbc:
url: r2dbc:postgresql://localhost:5432/db
username: user
password: pass
R2DBC Limitations
R2DBC is simpler than JPA — no “magic” lifecycle callbacks:
| JPA Feature | R2DBC Support | Workaround |
|---|---|---|
| @PrePersist | Not supported | Set values in Service before save() |
| @GeneratedValue | Not supported | Generate UUID manually in Service |
| @ManyToOne / @OneToMany | Not supported | Use foreign key (UUID) instead |
| JPQL | Not supported | Use native SQL with @Query |
| Lazy Loading | Not supported | Manual joins or separate queries |
Lessons Learned
- Model drives everything — Get entity changes right first, other layers follow
- R2DBC is simpler — Less magic means more explicit code in service layer
- Reactive chains take practice — flatMap, switchIfEmpty, then, thenReturn
- Testing changes too — Use StepVerifier for reactive testing
- Separation of concerns — Good opportunity to clean up (e.g., remove UserDetails from entity)
