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

Migration timeline

Where I Would Start

In the example repo, I picked the market side as the first reactive target:

I picked those paths because they have the right pressure profile:

At the same time, I intentionally left the admin user side blocking:

That split is not accidental. It is the whole point.

I wanted one repo state where the contrast was visible:

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.

Before and after hot-path migration

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:

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:

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:

  1. blocking Panache Next admin endpoints are still present and tested
  2. reactive Panache Next market endpoints are present and tested
  3. 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:

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:

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.

Continue Reading