Hibernate Reactive Without a Rewrite: A Practical Migration Story
If I had followed my first instinct during reactive migration, I probably would have started with easy CRUD.
That would have been the wrong move.
The more useful lesson from hands-on work was this:
the first reactive step should usually land on a path the system actually feels, not on the path that is easiest to convert in a demo.
That is what article 2 is about.
Repo branch for this article: article-2-reactive-search
Where I Would Start
In the example repo, I picked the market side as the first reactive target:
GET /api/markets/search?q=btcGET /api/markets/{symbol}/trades
I picked those paths because they have the right pressure profile:
- repeated reads
- query-oriented behavior
- likely future growth into projections and feed-style responses
At the same time, I intentionally left the admin user side blocking:
GET /api/admin/usersPOST /api/admin/usersGET /api/admin/users/{userId}/wallets
That split is not accidental. It is the whole point.
I wanted one repo state where the contrast was visible:
- the admin side still uses blocking Panache Next
- the market side already uses reactive Panache Next / Hibernate Reactive
That is a much more realistic first step than trying to "go reactive" everywhere at once.
Why I Did Not Start With CRUD
CRUD is a tempting first target because it makes a migration look tidy.
You can point at one resource, one service, one entity, and show a clean before/after.
But that kind of progress can be misleading.
If the application is still serving its hottest reads through blocking paths, then a CRUD-first migration may have improved the code story more than the runtime behavior.
That is why I now prefer a harder but more honest question:
Which path would I actually care about under load?
In the exchange demo, the answer is clearly closer to market search and recent trades than to admin user creation.
What the Split Looks Like in Code
The blocking admin side stays ordinary in service shape, even though the entity/repo model is already Panache Next:
@Transactional
public UserAccountView createUser(CreateUserRequest request) {
UserAccount entity = new UserAccount();
entity.email = request.email();
entity.displayName = request.displayName();
entity.countryCode = request.countryCode();
entity.kycStatus = "PENDING";
entity.createdAt = Instant.now();
entity.persist();
return mapper.toView(entity);
}
The reactive search side changes the contract openly:
@WithSession
public Uni<List<MarketView>> searchMarkets(String query) {
String normalizedQuery = query == null ? "" : query.trim();
return Market_.managedReactive()
.find("lower(symbol) like ?1 order by symbol", normalizedQuery.toLowerCase() + "%")
.list()
.map(mapper::toViews);
}
and for recent trades:
@WithSession
public Uni<List<TradeView>> recentTrades(String symbol) {
return Trade_.managedReactive()
.find("market.symbol = ?1 order by executedAt desc", symbol)
.page(Page.first(10))
.list()
.map(mapper::toTradeViews);
}
and the resource matches it:
@GET
@Path("/search")
public Uni<List<MarketView>> search(@QueryParam("q") String query) {
return marketQueryService.searchMarkets(query);
}
That change matters for one reason above all others:
the service and web contracts now tell the truth about how this path executes.
That is a real architectural improvement, not just a new return type.
What Stayed the Same
A useful migration step does not need to change everything around it.
That was another practical lesson for me. I used to think leaving old code in place during a migration was a sign that the migration was incomplete. Now I think it is often a sign that the migration is scoped properly.
In the article-2 repo state:
- admin user management stays blocking
- wallet listing stays blocking
- market search is reactive
- recent trades are reactive
That is exactly enough change to make the execution split visible without inventing more churn than the example needs.
What the First Reactive Step Exposes
The first hot-path migration is valuable partly because of what it reveals.
Once a real query path goes reactive, a few design issues become much harder to ignore:
- mappers that quietly perform lookups
- helper classes that assume one query family everywhere
- service contracts that were written as if all data access were interchangeable
This is one reason I like starting with a hot path instead of easy CRUD.
The hot path forces the architecture to show its weak points sooner.
That is not a downside. That is useful feedback.
The Repo State for This Article
I wanted the public repo to reflect article 2 directly instead of making readers infer it from the code.
So the current baseline now demonstrates the contrast in three ways:
- blocking Panache Next admin endpoints are still present and tested
- reactive Panache Next market endpoints are present and tested
- the README explains the split as an article-2 repo state, not just as a generic baseline
That means the repo is not only saying "hybrid persistence is possible." It is showing the first migration boundary concretely.
What I Tested
I also wanted the article-2 state to be validated, not just described.
The repo now has tests that cover both sides of the split:
- blocking admin list and create
- reactive market search
- reactive recent trades
That matters because it turns the article from a design note into a reproducible repo state.
Why This Feels More Honest Than a Rewrite Story
The part of reactive migration that I trust least is the story where everything becomes reactive in one clean motion.
That is not how most systems move.
What I trust more is a narrow, measurable step:
- choose one hot path
- make the contract honest
- leave unrelated blocking code alone
- let the next architectural cleanup follow from the pressure created by that change
That is what this repo state is meant to demonstrate.
Closing
For me, the most important reactive migration insight was not "use Uni here" or "pick this repository base class."
It was that the first step should land where blocking hurts, not where conversion is easy.
That is why this example keeps the admin side blocking and moves the market side first.
It is not a halfway solution. It is the beginning of a migration sequence that follows actual value.
In the next article, I will move to the design issue that showed up almost immediately once reactive paths entered the picture: mapper code that was doing more than mapping and the service boundaries that should own that work instead.