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
untiltimestamp (default: now). Draining the log up tountilreconstructs 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
@ProjectionListenerside effects, even on a projection withpublishCurrentState = true. - Independent of the persistent store
-
Materialization uses a throwaway in-memory store and metadata. Querying a
@JpaProjection @AdHocnever 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
@AdHocprojection 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.
|
|
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
untilin 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 pastuntilfails loud, naming that node. Theuntil-less overloads (anduntil >= 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.
|
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.
|
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.