Hybrid Persistence in Quarkus: Blocking, Reactive, and Panache Next in One Codebase
This series is based on hands-on migration work, not on a clean-room framework comparison.
That matters, because the most useful things I learned did not come from reading feature lists. They came from trying to move a real Quarkus application forward without breaking everything around it.
The main lesson was simple:
I got much better results once I stopped asking, "Should this app be blocking or reactive?" and started asking, "Which parts of this app actually need to change first?"
That is the frame for this series and for the example code that supports it.
Repo branch for this article: article-1-baseline
Initial starting point: base
The Mistake in the Usual Discussion
A lot of discussions around Quarkus persistence get trapped in the wrong shape.
They tend to sound like this:
- use blocking execution or use reactive execution
- start with Panache Next now or postpone it
- rewrite the whole persistence layer or leave it alone
In practice, none of those choices is precise enough to help much.
What helped me much more was looking at the system by workload:
- which endpoints are hot
- which paths are just admin CRUD
- which parts depend on managed entity lifecycle semantics
- which modules are small enough to experiment with safely
Once I started looking at it that way, hybrid persistence stopped feeling like a compromise and started feeling like the only realistic migration plan.
Why I Chose a Crypto-Exchange Example
The public demo repo for this series uses a compact crypto-exchange style domain:
UserAccountWalletMarketOrderTradePriceAlertSubscription
I did not choose that domain because I care about exchange demos in general. I chose it because it naturally contains different kinds of persistence pressure in one app.
It gives me:
- low-frequency admin flows
- high-traffic read paths
- feed-style queries
- background jobs
- alerting and subscriptions
That is exactly the kind of mix where one execution style across the whole codebase starts to feel more ideological than practical.
What I Would Not Change First
One of the easiest things to overestimate in migration work is low-frequency CRUD.
If an endpoint is:
- internal
- low traffic
- easy to understand
- and not especially latency-sensitive
then I usually do not want to spend early migration energy there.
In the example repo, the admin user side is deliberately still blocking:
GET /api/admin/usersPOST /api/admin/usersGET /api/admin/users/{userId}/wallets
That is not because blocking is somehow better. It is because I do not need reactive machinery there first.
The code is straightforward and honest about what it is. The baseline example is already Panache Next, just still blocking:
@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);
}
There is nothing wrong with leaving that alone while more valuable work moves first.
Where Reactive Started Making Sense
The first places where reactive work paid off for me were never the easy CRUD endpoints.
They were the paths that were:
- hit repeatedly
- dominated by reads
- naturally query-oriented
- likely to grow into projection-heavy or feed-style logic
That is why the example repo puts reactive behavior around the market side first:
GET /api/markets/search?q=btcGET /api/markets/{symbol}/trades
Those endpoints are intentionally the first candidates to become Uni-based in the next branch:
public Uni<List<MarketView>> search(@QueryParam("q") String query) {
return marketQueryService.searchMarkets(query);
}
and:
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);
}
This is the kind of split I wish more migration discussions started with:
- blocking where the system can afford it
- reactive where the system is already paying for blocking behavior
What Changed Once I Stopped Thinking in One Style
The biggest practical shift was not technical. It was architectural honesty.
As soon as I allowed blocking and reactive paths to coexist intentionally, the system became easier to reason about:
- service contracts became more truthful
- hot paths became easier to identify
- future cleanup work became easier to sequence
- Panache Next stopped being a moving target because it was already the base model
That last point matters a lot.
Panache Next Is the Stable Layer Here
This is the migration shape I wish I had chosen earlier:
going reactive and adopting Panache Next do not have to be two separate modeling migrations.
Panache Next is the stable modeling layer from article 1 onward.
Reactive migration then changes how selected paths execute inside that same model.
That makes the example progression much cleaner:
- article 1: blocking Panache Next baseline
- article 2: reactive hot path in the same Panache Next codebase
- later articles: mapper purity, metamodel usage, managed vs stateless, hot paths, jobs
Why I Chose Panache Next From the Start
Quarkus now has an explicit Panache Next direction:
- blog post: Hibernate ORM Panache Next
- guide: Using Hibernate ORM Panache Next
That matters because one of the practical lessons from the migration work behind this series was that using classic ORM Panache for blocking code and reactive Panache for reactive code in parallel is possible, but it is also easy to make that mix messy.
The pain is not theoretical. In practice, you end up with:
- two similar but different repository entry styles
- two similar but different query APIs
- more chances to blur managed vs stateless assumptions
- more chances to teach the wrong lesson
If the migration approach is to change execution mode gradually while keeping the domain model understandable, starting with one stable model from the beginning is a better fit. Panache Next helps with that.
It also gives one thing that is very useful both in real code and in examples: generated metamodel access. That is where calls like UserAccount_.repo() for a custom blocking repository and Market_.managedReactive() for reactive access come from.
Those _ classes are not handwritten helpers. They are generated by the Hibernate processor, as described in the Panache Next guide and in the Hibernate metamodel generator documentation:
That is why the examples in this series can use a style like this on the reactive side:
return Market_.managedReactive()
.find("lower(symbol) like ?1 order by symbol", normalizedQuery.toLowerCase() + "%")
.list();
and a custom repository shortcut like this on the blocking admin side:
return UserAccount_.repo().listNewestFirst();
without injecting a separate top-level repository just to make one query call.
One important caveat: Panache Next is still marked experimental in the official guide at the time of writing. I think that needs to be said clearly.
So this series is not claiming “everyone should move production systems to Panache Next immediately.” It is claiming something narrower:
- if the goal is to explain blocking and reactive execution in one codebase, keeping the Panache Next model stable from article 1 makes the story much cleaner
- and if the examples are based on actual migration experience, reducing unnecessary framework-model drift makes the tradeoffs easier to see honestly
The Split I Actually Trust
Here is the split I trust more now than I did before doing this kind of work:
| Area | Style | Why |
|---|---|---|
| Admin user management | Blocking Panache Next | low-frequency CRUD, low-value optimization target |
| Wallet maintenance | Blocking Panache Next | internal path |
| Market search | Reactive Panache Next later | hot read path |
| Recent trades | Reactive Panache Next later | repeated feed-style reads |
| Alerts and subscriptions | reactive candidates inside Panache Next | scheduler and candidate-lookup pressure |
| Entity and repo model | Panache Next from the start | stable baseline across the series |
This is not a purity model. It is a sequencing model.
That is why I find it more useful.
What the Example Repo Is Trying To Show
The public repo is not trying to prove that mixed persistence is elegant in the abstract.
It is trying to show something more useful:
you can keep low-risk blocking paths in place while moving one meaningful hot path to reactive, and the application becomes easier to evolve once that split is explicit.
The example used in this series is meant to show a cleaner migration story:
- blocking Panache Next baseline first
- reactive market query path introduced in the next branch
- one Quarkus app, one Panache Next model
- one in-memory H2 baseline for the example
That is a much better starting point for a migration story than pretending the whole app should switch styles in one move.
Closing
The hands-on lesson for me was not that reactive is always better or that blocking is somehow outdated.
The real lesson was that migration improves when you stop treating persistence as one global choice.
Different workloads deserve different treatment, and Panache Next does not have to be the thing that changes every time the execution mode changes.
That is the reason this series starts with a stable blocking baseline and then moves selected hot paths to reactive execution.
Not because it is fashionable, but because it is closer to how useful migration work actually happens.
In the next article, I will stay with that practical angle and show the first reactive step inside this Panache Next baseline: not as a rewrite, but as a controlled move on one hot path while the blocking side stays intact.