Ad-hoc projections

An ad-hoc projection builds a read model on demand from the event history, into a transient in-memory store, answers a query, and persists nothing. It is the pull/transient counterpart to the live projections described in the other sections: instead of a projection that is continuously kept current, an ad-hoc query materializes a read model only when you ask for it — optionally as of a point in time.

Two things make it worthwhile:

  • Point-in-time state — every query takes an until timestamp (default: now). Draining the log up to until reconstructs the read model as it was at that instant — something a live projection, which only ever holds the current state, cannot give you.

  • No permanent projection — you can query a read model you do not want to keep continuously materialized. Nothing is written to any store.

Ad-hoc materialization is explicit opt-in. It works both for event-sourced targets (drained from the log, with full point-in-time until support) and for projection-fed targets (a projection that consumes upstream read models), which are materialized from their upstreams' current state — see Projection-fed (cascade) targets.

Opting in

Ad-hoc capability is opt-in via the plain @AdHoc marker. There are two ways to use it.

(a) Make an existing persistent projection ad-hoc-capable — add @AdHoc alongside its storage annotation. Its normal persistent/live lifecycle is unchanged; it simply becomes materializable on demand.

@JpaProjection(name = "account-summary")
@AdHoc (1)
class AccountSummaryProjection {

    @ProjectionListener
    fun on(event: AccountOpened, summary: AccountSummary) {
        summary.balanceValue = event.initialBalance.value
        summary.eventCount = 1
    }

    @ProjectionListener
    fun on(event: MoneyDeposited, summary: AccountSummary) {
        summary.balanceValue = summary.balanceValue.add(event.amount.value)
        summary.eventCount++
    }
}
1 @AdHoc is a plain marker (it carries no @Projection), so it stacks cleanly on any storage flavor.

(b) Declare a storage-less, ad-hoc-only projection with @AdHocProjection (@Projection + @Component + @AdHoc). It has no persistent consumer, store, or repository — it exists only to be materialized on demand.

@AdHocProjection(name = "account-risk")
class AccountRiskProjection {

    @ProjectionListener
    fun on(event: MoneyWithdrawn, risk: AccountRisk) {
        risk.overdraftEvents++
    }
}

Because an ad-hoc-only projection produces no store, it must not declare side-effect listeners (a @ProjectionListener with no read-model parameter) — an ad-hoc query is a read and must never trigger side effects. Such a listener is rejected at startup.

A projection without @AdHoc is not ad-hoc-materializable: project<T, ID>() for its read-model type fails loud with a directed "add `@AdHoc`" message.

Querying

Inject the singleton AdHocProjector and call the reified project<T, ID>(). It returns a fresh, per-use AdHocReadModels<T, ID> container — each call replays the log into its own in-memory store, so concurrent callers never share state.

@Service
class CreditService(private val adHocProjector: AdHocProjector) {

    fun evaluate(accountId: AccountId): AccountSummary? {
        val accounts = adHocProjector.project<AccountSummary, AccountId>() (1)
        val current = accounts.findOne(accountId)                          (2)
        val asOfEoy = accounts.findOne(accountId, until = endOfYear)       (3)
        val wealthy = accounts.findAll { it.balanceValue > threshold }     (4)
        return current
    }
}
1 Resolves the read-model type to its owning @AdHoc projection. A fresh container is minted per call.
2 Key-scoped, as of now.
3 Key-scoped, point-in-time: only events with occurredAt <= until are applied.
4 Full scan with a predicate.

findOne/findAll come in an until-less form (defaulting to the framework clock’s now) and an explicit-until form. When a read-model type is produced by more than one @AdHoc projection, use the disambiguating overload project<T, ID>(projectionName = "…​").

Guarantees

Read-only

The drain runs in replay mode and never invokes side-effect listeners, so an ad-hoc query never publishes read models or fires @ProjectionListener side effects, even on a projection with publishCurrentState = true.

Independent of the persistent store

Materialization uses a throwaway in-memory store and metadata. Querying a @JpaProjection @AdHoc never touches its persisted rows.

Accumulation

Multiple events for the same id accumulate correctly (the transient store uses a real, fully working in-memory metadata store, not a no-op).

Replayable sources only

Every @AdHoc projection is validated at startup to listen only to replayable (event-sourced) sources — an ad-hoc query replays from the log, so a live-only event type cannot be materialized.

until is an analytical "state as of roughly this instant", not an exact consistency boundary. Global ordering is by event position; occurredAt is an application wall-clock that can diverge from position under clock skew or commit-vs-append timing.

Projection-fed (cascade) targets

A projection whose @ProjectionListener consumes an upstream read model — rather than a domain event — is projection-fed. Such a target can be materialized ad-hoc for its current state: the direct upstreams' current persistent rows are fed into the throwaway applier, exactly as the live cascade re-emits current state downstream, but into a fresh in-memory store.

// Upstream: event-sourced and re-emits its current state to downstream projections.
@JpaProjection(name = "account-summary", publishCurrentState = true) (1)
@AdHoc
class AccountSummaryProjection { /* on(AccountOpened, AccountSummary) ... */ }

// Downstream: projection-fed — consumes the AccountSummary read model and aggregates by branch.
@JpaProjection(name = "branch-report")
@ReadModelKey(type = AccountSummary::class, value = "branchId")
@AdHoc
class BranchReportProjection {
    @ProjectionListener
    fun on(summary: AccountSummary, report: BranchReport) {
        report.totalBalance = report.totalBalance.add(summary.balanceValue)
        report.accountCount++
    }
}

// Materializes BranchReport from AccountSummary's CURRENT persistent rows.
val report = adHocProjector.project<BranchReport, BranchId>().findOne(branchId)
1 A projection-fed target is materializable only if its upstream re-emits current state (publishCurrentState = true) — the same replayability rule the startup validation enforces.

Only direct upstreams are read (no deep recursion): each persistent upstream already reflects its own upstreams, kept current by the live cascade, so its current rows are correct by induction.

Semantics to keep in mind:

Current state (default)

A projection-fed target reflects the upstreams' current persistent state. It is therefore subject to the usual consumer lag — if the upstream cascade has not yet caught up, an ad-hoc read sees the not-yet-updated upstream rows. This is the inherent eventual-consistency tradeoff, not a defect. When a target has several direct upstreams, each is read as a best-effort snapshot, not a single joint instant across all of them — their rows may reflect slightly different points in time.

Historical until (fully event-sourced chains)

A point-in-time until in the past is supported on a projection-fed target when its entire upstream closure is reconstructable from event history: each upstream is recursively replayed as-of that instant into a throwaway store. If any node in the closure has a live-only input with no event history, a past until fails loud, naming that node. The until-less overloads (and until >= now) use current state.

Mid-rebuild is fail-loud

If a direct upstream is rebuilding (a generation replay is in progress), its current rows are a torn snapshot, so the ad-hoc read fails loud asking you to retry once the rebuild completes — unlike the live cascade, an ad-hoc read has no generation gate to shield it.

Calling ad-hoc from inside a projection

A persistent projection may inject AdHocProjector and, from inside a @ProjectionListener, ad-hoc-materialize another (@AdHoc-capable) projection to enrich its own read model — even while the calling projection is catching up.

// A persistent projection that enriches its own row from another projection's current state.
@JpaProjection(name = "account-report")
class AccountReportProjection(private val adHocProjector: AdHocProjector) { (1)

    @ProjectionListener
    fun on(event: MoneyDeposited, report: AccountReport) {
        val summary = adHocProjector
            .project<AccountSummary, AccountId>()
            .findOne(event.accountId) (2)
        report.observedBalance = summary?.balanceValue ?: BigDecimal.ZERO
    }
}
1 Constructor-inject the singleton AdHocProjector; no bean-initialization cycle results.
2 Key-scoped lookup of another @AdHoc projection, materialized on demand from within the handler.
Own connection

The nested materialization drains events in its own transaction (a fresh pooled connection), so it never reuses — nor blocks on — the catch-up transaction/cursor that is executing the handler. There is no read-your-writes hazard: the event log is committed before any projection runs, so a nested read always sees a consistent, already-persisted history.

Cost caveat

Each nested call performs a full drain (O(events)); inside a bulk catch-up that is O(n·m). Prefer a key-scoped findOne and a narrow until, and reach for nested ad-hoc sparingly rather than as a per-event join.

Cycle guard

A projection may not ad-hoc-materialize a chain that includes itself. Doing so fails loud with a clear cycle detected: a → b → a message — surfaced directly when you call it from your own code, or as a projection halt if it happens during catch-up.

Declarative upstream injection with @AdHocUpstream

For the common case — "look up one upstream read model keyed by a field of the incoming event" — the @AdHocUpstream parameter annotation replaces the explicit AdHocProjector-in-handler pattern above. The framework derives the key, materializes the @AdHoc-capable upstream, and injects the result; the projection no longer injects AdHocProjector at all.

@JpaProjection(name = "risk-report")
class RiskReportProjection {

    @ProjectionListener
    fun on(
        event: TransactionRecorded,
        report: RiskReport,
        @AdHocUpstream("accountId") summary: AccountSummary?, (1)
    ) {
        report.risk = score(summary) (2)
    }
}
1 Injects project<AccountSummary, _>().findOne(event.accountId). The parameter must be nullable.
2 A missing upstream row injects null — a normal outcome, not an error.
Key source

Provide exactly one (validated at startup):

  • value — a single property name read directly off the incoming event (or, in a chaining consumer, the upstream read model): @AdHocUpstream("accountId") resolves event.accountId.

  • expression — a SpEL expression over that value, exposed as #event, for navigation, operators, or construction: @AdHocUpstream(expression = "#event.accountId"), or @AdHocUpstream(expression = "new com.acme.AccountId(#event.rawId)") to build a wrapped-id key. SpEL reuses the projection dispatch path’s parser; bean references (@bean) are not supported (there is no BeanResolver), mirroring @IgnoreOnReplay. Property access, operators, and new/T(…​) construction are all available.

Nullable parameter

The parameter’s type must be nullable (T?) — enforced at startup. A missing upstream row injects null; so does a null derived key (a null property value or an expression evaluating to null) — no key means there is nothing to look up. If the named property is absent altogether — as opposed to present but null — that is a configuration error and fails loud.

Order-free

@AdHocUpstream is a trailing parameter, like a @MetadataValue or MessageMetadata parameter. Trailing parameters may appear in any order and intermixed — each is resolved independently by its own annotation/type.

@AdHoc upstream

The upstream’s producing projection must be @AdHoc-capable; a non-@AdHoc type fails loud at startup with the directed "add `@AdHoc`" message.

Ambient point-in-time

This is the declarative form’s distinctive value. During live catch-up the upstream resolves from current state. When the consuming projection is itself materialized ad-hoc as-of an instant T, the injected upstream resolves as-of that same instant T — a coherent point-in-time snapshot across the chain that the explicit findOne(id) (hardcoded to now) cannot give you. A projection-fed upstream resolves as-of T when its chain is fully event-sourced, and fails loud only when the closure has a live-only input — exactly as a direct projection-fed ad-hoc query does.

Performance characteristics

An ad-hoc query performs a full, source-agnostic drain each time — there is no result caching, so repeated queries re-materialize from scratch. A key-scoped findOne on a uniformly @AggregateId-keyed target uses the per-key event-store fast path (loading only that aggregate’s events); every other query drains the log. A historical until over a projection-fed chain replays each upstream in the closure once (sequentially), so its cost grows with the closure size.