Managed vs Stateless in Panache Reactive: What Actually Changes
The previous article separated repository entry points by operation mode:
PriceAlert_.adminRepo()for admin-facing queriesPriceAlert_.publicRepo()for public-facing queriesPriceAlert_.managedReactive()for reactive lifecycle work
That separation is useful, but it raises the next question: when should reactive code use managed access, and when should it use stateless access?
This is not just a naming choice. Managed and stateless access represent different persistence models. If the service assumes one model while the code uses another, the bug is usually not obvious at the call site.
Repo branch for this article: article-5-managed-vs-stateless
The Difference That Matters
managedReactive() gives the service entity lifecycle semantics.
That means the service can:
- create or load an entity
- attach it to the persistence context
- mutate fields
- rely on transaction boundaries and dirty checking
statelessReactive() is different. It is better treated as explicit, operation-oriented database access.
That usually fits:
- projection-heavy reads
- feed-style queries
- read models where entity lifecycle behavior adds no value
- bulk or candidate-selection queries where the result is not meant to be mutated
The mistake is to treat both as interchangeable ways to “run a reactive query.”
Managed Reactive Fits The Alert Create Flow
The alert create flow from the current branch is a managed reactive workflow:
@WithTransaction
public Uni<PriceAlertView> create(CreatePriceAlertRequest request) {
PriceAlert alert = mapper.toEntity(request);
return PriceAlert_.managedReactive()
.persist(alert)
.replaceWith(alert)
.map(mapper::toView);
}
That is a good fit for managedReactive() because the operation is lifecycle-oriented:
- build a new entity
- attach id-only
UserAccountandMarketreferences from the mapper - persist the entity
- return the mapped view
There is no reason to force this into a stateless shape. The service wants entity persistence semantics, so managed access is the honest choice.
Managed Reactive Also Fits Load-And-Mutate
A second managed candidate is an enable/disable toggle for alerts.
That service would naturally load an alert, mutate a field, and rely on the transaction:
@WithTransaction
public Uni<PriceAlertView> setEnabled(Long id, boolean enabled) {
return PriceAlert_.managedReactive().findById(id)
.onItem().ifNull().failWith(() -> new NotFoundException("Price alert not found"))
.invoke(alert -> alert.enabled = enabled)
.map(mapper::toView);
}
This is exactly the managed model:
- load current state
- mutate the entity
- let the persistence context track the change
If a service is written with that mental model, using stateless access would be misleading.
Stateless Reactive Fits The Trade Feed Better
Now compare that with the market trade feed.
The current branch still reads recent trades as managed entities and then maps them:
@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);
}
That works, but the service does not really need managed Trade entities. It only returns a read model:
public record TradeView(
Long id,
BigDecimal price,
BigDecimal quantity,
Instant executedAt
) {
}
A stateless version is a better expression of the intent:
@WithTransaction(stateless = true)
public Uni<List<TradeView>> recentTrades(String symbol) {
return Trade_.statelessReactive()
.find(
"""
select t.id, t.price, t.quantity, t.executedAt
from Trade t
where t.market.symbol = ?1
order by t.executedAt desc
""",
symbol
)
.project(TradeView.class)
.page(Page.first(10))
.list();
}
No entity lifecycle behavior is needed there. The query already knows the exact shape the endpoint returns.
That is where statelessReactive() starts to pay off: not because it looks more advanced, but because it matches a projection-oriented read path.
One practical detail matters: do not wrap this method in @WithSession. That opens a managed reactive session, and statelessReactive() needs a stateless session. In this branch the service marks that explicitly with @WithTransaction(stateless = true) so the session mode matches the access style. Mixing managed and stateless sessions in one method is exactly the kind of semantic mismatch this article is about.
The Update Trap
The dangerous version is this:
return PriceAlert_.statelessReactive().findById(id)
.invoke(alert -> alert.enabled = false);
That shape looks like the managed toggle, but it does not carry the same persistence-context assumptions.
If the service needs dirty checking, it should not use stateless access. If the service uses stateless access, updates should be expressed as explicit operations, not as “load and mutate and hope the framework tracks it.”
The rule is simple:
- load-and-mutate means managed
- projection or explicit operation means stateless can be considered
Where Stateless Blocking Fits
This article focuses on reactive access, but Panache Next also exposes the same managed/stateless distinction on the blocking side.
The two decisions are separate:
Execution model: blocking or reactive
Persistence model: managed or stateless
That gives four useful shapes:
managedBlocking()
statelessBlocking()
managedReactive()
statelessReactive()
statelessBlocking() can be useful for a blocking read path that returns projections and does not need persistence-context behavior:
public List<TradeView> recentTradesBlockingProjection(String symbol) {
return Trade_.statelessBlocking()
.find(
"""
select t.id, t.price, t.quantity, t.executedAt
from Trade t
where t.market.symbol = ?1
order by t.executedAt desc
""",
symbol
)
.project(TradeView.class)
.page(Page.first(10))
.list();
}
That path is still blocking. The word “stateless” only says that the service is not relying on managed persistence-context semantics.
So statelessBlocking() is not a step toward reactive by itself. It is a way to make a blocking read model more explicit when lifecycle behavior is unnecessary.
How This Relates To Multiple Repositories
The previous article introduced multiple nested repositories for different operation modes:
PriceAlert_.adminRepo().findForUser(userId);
PriceAlert_.publicRepo().findVisibleForUser(userId);
That split is about query vocabulary and audience. It is not the same thing as managed versus stateless.
For example:
adminRepo()can stay blocking because it serves low-frequency admin readspublicRepo()can stay blocking if the endpoint does not need reactive execution yetmanagedReactive()is still the right fit for reactive create/update lifecycle workstatelessReactive()becomes interesting for public feed-style reads and projections
These are separate decisions:
- who is the operation for?
- is the path blocking or reactive?
- does the service need entity lifecycle semantics?
- is the result an entity or a projection?
Panache Next gives entry points for all of this, but the service semantics still decide which one is appropriate.
Where Helper Design Can Go Wrong
This distinction becomes painful when helper code tries to hide it.
For example, a single generic pagination helper can look attractive at first:
Paged.from(query);
But managed and stateless query families do not always have the same generic contracts, projection behavior, or count behavior. Trying to force them into one helper can make errors harder to understand.
Clearer helpers are often better:
Paged.fromReactive(managedQuery);
Paged.fromStatelessReactive(statelessQuery);
That is not duplication. It is a useful admission that the two paths have different semantics.
This repo does not need a paging helper yet, but the same rule applies to smaller abstractions: do not hide whether a service is relying on managed lifecycle behavior or stateless read behavior.
Practical Selection Rule
Use managedReactive() when:
- the service creates or updates entity state
- load-and-mutate is the natural workflow
- dirty checking is part of the design
- the returned object is still naturally an entity mapped to a DTO
Use statelessReactive() when:
- the path is read-heavy
- the result is a projection
- the service does not mutate returned entities
- persistence-context behavior adds no value
Keep blocking nested repositories when:
- the path is low-frequency
- the query vocabulary belongs near the entity
- reactive execution is not the current optimization target
That last point matters. The goal is not to turn every query reactive. The goal is to make each persistence path honest about what it needs.
Closing
Managed and stateless reactive access are not interchangeable.
managedReactive() is a good fit when the service wants entity lifecycle semantics. statelessReactive() is a good fit when the service wants explicit projection-oriented database access.
The current branch already has a managed reactive write path with PriceAlert creation. The next useful implementation step is to move the recent trade feed from managed entity loading to a stateless projection query.
That would make the branch show all three ideas clearly:
- blocking nested repositories for admin/public query vocabulary
- managed reactive access for lifecycle work
- stateless reactive access for projection-heavy reads