Managed vs Stateless in Panache Reactive: What Actually Changes

The previous article separated repository entry points by operation mode:

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

Managed vs stateless matrix

The Difference That Matters

managedReactive() gives the service entity lifecycle semantics.

That means the service can:

statelessReactive() is different. It is better treated as explicit, operation-oriented database access.

That usually fits:

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:

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:

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:

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:

These are separate decisions:

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:

Use statelessReactive() when:

Keep blocking nested repositories when:

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:

Continue Reading