|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +toc: true |
| 4 | +title: The Savepoint Pattern |
| 5 | +description: Efficient projection initialization using savepoint events |
| 6 | +date: 2026-02-14 01:00:00 |
| 7 | +categories: [Eventstore Documentation,Eventstore Application Scenarios] |
| 8 | +tags: [projection,projector,read model,savepoint,initQuery] |
| 9 | +--- |
| 10 | + |
| 11 | +# The Savepoint Pattern |
| 12 | + |
| 13 | +This guide covers the savepoint pattern — a technique for avoiding full event replays when initializing projections, using the `initQuery()` capability introduced in EventStore 0.6.3. |
| 14 | + |
| 15 | +## The Problem: Full Replays |
| 16 | + |
| 17 | +Projections build read models by processing events sequentially. When a projection is initialized from scratch, it replays every event from the beginning of the stream: |
| 18 | + |
| 19 | +```java |
| 20 | +StockLevelProjection projection = new StockLevelProjection("WIDGET-42"); |
| 21 | +Projector.from(stream).towards(projection).build().run(); |
| 22 | +// Processes ALL events from the beginning of time |
| 23 | +``` |
| 24 | + |
| 25 | +For streams with thousands or millions of events, this can be slow. Every time a new projection instance is created — after a restart, on a new server, or simply when handling a request — the entire history must be replayed. |
| 26 | + |
| 27 | +Bookmarking solves this for long-lived projections that persist their position. But not every projection needs or wants that infrastructure. Some projections are short-lived, created on-the-fly to answer a specific question and then discarded. For these, replaying the full stream on every instantiation is wasteful. |
| 28 | + |
| 29 | +## Savepoints: A Domain-Driven Solution |
| 30 | + |
| 31 | +A **savepoint** is a regular domain event that summarizes the state of a projection at a point in time. It is not a framework construct — it is a pure business event, appended by your application whenever you decide it makes sense. |
| 32 | + |
| 33 | +Consider a stock keeping scenario with these events: |
| 34 | + |
| 35 | +```java |
| 36 | +sealed interface StockEvent { |
| 37 | + |
| 38 | + record StockAdded(String product, int quantity) implements StockEvent { } |
| 39 | + |
| 40 | + record StockPicked(String product, int quantity) implements StockEvent { } |
| 41 | + |
| 42 | + /** |
| 43 | + * Savepoint event that summarizes the stock count at a certain moment in time. |
| 44 | + * This is a pure domain event — no special framework support needed. |
| 45 | + */ |
| 46 | + record StockCounted(String product, int counted) implements StockEvent { } |
| 47 | + |
| 48 | +} |
| 49 | +``` |
| 50 | + |
| 51 | +`StockAdded` and `StockPicked` are individual stock movements. `StockCounted` is the savepoint — it captures the stock level at a moment in time. Think of it as a physical stock count in a warehouse: someone counts what is on the shelf and records the number. |
| 52 | + |
| 53 | +With a savepoint in the stream, a new projection does not need to replay every movement from the beginning. It can jump to the most recent savepoint, initialize from there, and then process only the movements that came after. |
| 54 | + |
| 55 | +## Using initQuery |
| 56 | + |
| 57 | +The `Projection` interface provides an optional `initQuery()` method that enables this pattern: |
| 58 | + |
| 59 | +```java |
| 60 | +static class StockLevelProjection implements Projection<StockEvent> { |
| 61 | + |
| 62 | + private final String product; |
| 63 | + private int level = 0; |
| 64 | + |
| 65 | + public StockLevelProjection(String product) { |
| 66 | + this.product = product; |
| 67 | + } |
| 68 | + |
| 69 | + @Override |
| 70 | + public EventQuery initQuery() { |
| 71 | + // Find the last stock count (savepoint) for this product |
| 72 | + return EventQuery.forEvents( |
| 73 | + EventTypesFilter.of(StockCounted.class), |
| 74 | + Tags.of("product", product) |
| 75 | + ).backwards().limit(1); |
| 76 | + } |
| 77 | + |
| 78 | + @Override |
| 79 | + public EventQuery eventQuery() { |
| 80 | + // Only process movements — savepoints are handled exclusively by initQuery |
| 81 | + return EventQuery.forEvents( |
| 82 | + EventTypesFilter.of(StockAdded.class, StockPicked.class), |
| 83 | + Tags.of("product", product) |
| 84 | + ); |
| 85 | + } |
| 86 | + |
| 87 | + @Override |
| 88 | + public void when(Event<StockEvent> event) { |
| 89 | + switch (event.data()) { |
| 90 | + case StockCounted c -> level = c.counted(); |
| 91 | + case StockAdded a -> level += a.quantity(); |
| 92 | + case StockPicked p -> level -= p.quantity(); |
| 93 | + } |
| 94 | + } |
| 95 | + |
| 96 | + public int level() { |
| 97 | + return level; |
| 98 | + } |
| 99 | + |
| 100 | +} |
| 101 | +``` |
| 102 | + |
| 103 | +The key elements: |
| 104 | + |
| 105 | +- **`initQuery()`** returns a backward query with limit 1 — it finds the most recent `StockCounted` event for this product |
| 106 | +- **`eventQuery()`** returns the movements (`StockAdded`, `StockPicked`) — but **not** `StockCounted` |
| 107 | +- **`when()`** handles all three event types, since it receives events from both queries |
| 108 | + |
| 109 | +## How the Projector Executes It |
| 110 | + |
| 111 | +When the Projector runs a projection that has an `initQuery()`, the execution proceeds in two phases: |
| 112 | + |
| 113 | +1. **Phase 1 — Initialization**: The `initQuery()` is executed. If a savepoint is found, it is passed to the `when()` handler to initialize state. The savepoint's event reference becomes the cursor position. |
| 114 | + |
| 115 | +2. **Phase 2 — Delta processing**: The `eventQuery()` is executed starting from the cursor position set by the initQuery. Only events after the savepoint are processed. |
| 116 | + |
| 117 | +On subsequent runs of the same Projector instance, the initQuery is skipped — the projector already has a cursor position and continues from there. |
| 118 | + |
| 119 | +## A Full Example |
| 120 | + |
| 121 | +Here is a complete walkthrough showing the savepoint pattern in action: |
| 122 | + |
| 123 | +```java |
| 124 | +EventStore eventstore = InMemoryEventStorage.newBuilder().buildStore(); |
| 125 | + |
| 126 | +EventStreamId streamId = EventStreamId.forContext("warehouse"); |
| 127 | +EventStream<StockEvent> stream = eventstore.getEventStream(streamId, StockEvent.class); |
| 128 | + |
| 129 | +String product = "WIDGET-42"; |
| 130 | +Tags tags = Tags.of("product", product); |
| 131 | + |
| 132 | +// Simulate stock movements |
| 133 | +stream.append(AppendCriteria.none(), Event.of(new StockAdded(product, 100), tags)); |
| 134 | +stream.append(AppendCriteria.none(), Event.of(new StockPicked(product, 10), tags)); |
| 135 | +stream.append(AppendCriteria.none(), Event.of(new StockPicked(product, 5), tags)); |
| 136 | +stream.append(AppendCriteria.none(), Event.of(new StockAdded(product, 50), tags)); |
| 137 | +stream.append(AppendCriteria.none(), Event.of(new StockPicked(product, 20), tags)); |
| 138 | +``` |
| 139 | + |
| 140 | +### First run — no savepoint exists |
| 141 | + |
| 142 | +```java |
| 143 | +StockLevelProjection projection = new StockLevelProjection(product); |
| 144 | +ProjectorMetrics metrics = Projector.from(stream).towards(projection).build().run(); |
| 145 | + |
| 146 | +System.out.println("Stock level: " + projection.level()); // 115 |
| 147 | +System.out.println("Events handled: " + metrics.eventsHandled()); // 5 |
| 148 | +``` |
| 149 | + |
| 150 | +No savepoint exists yet, so the `initQuery()` returns nothing and the `eventQuery()` replays all five movements from the beginning. The pattern degrades gracefully — no special handling needed. |
| 151 | + |
| 152 | +### Append a savepoint |
| 153 | + |
| 154 | +```java |
| 155 | +stream.append(AppendCriteria.none(), Event.of(new StockCounted(product, 115), tags)); |
| 156 | +``` |
| 157 | + |
| 158 | +This records the current stock level as a domain event. It is the application's choice when to do this — after a batch of operations, on a schedule, or triggered by a manual stock count. |
| 159 | + |
| 160 | +### Second run — savepoint found |
| 161 | + |
| 162 | +```java |
| 163 | +// More movements after the savepoint |
| 164 | +stream.append(AppendCriteria.none(), Event.of(new StockAdded(product, 30), tags)); |
| 165 | +stream.append(AppendCriteria.none(), Event.of(new StockPicked(product, 12), tags)); |
| 166 | + |
| 167 | +StockLevelProjection projection2 = new StockLevelProjection(product); |
| 168 | +ProjectorMetrics metrics2 = Projector.from(stream).towards(projection2).build().run(); |
| 169 | + |
| 170 | +System.out.println("Stock level: " + projection2.level()); // 133 |
| 171 | +System.out.println("Events handled: " + metrics2.eventsHandled()); // 3 |
| 172 | +System.out.println("Queries done: " + metrics2.queriesDone()); // 2 |
| 173 | +``` |
| 174 | + |
| 175 | +This time the `initQuery()` finds the `StockCounted(115)` savepoint. The projection initializes with `level = 115`, then processes only the two movements after it. Three events handled total (1 savepoint + 2 movements), two queries done (1 initQuery + 1 eventQuery) — instead of replaying all eight events from the beginning. |
| 176 | + |
| 177 | +### Fixing a bad savepoint |
| 178 | + |
| 179 | +```java |
| 180 | +stream.append(AppendCriteria.none(), Event.of(new StockCounted(product, 133), tags)); |
| 181 | +stream.append(AppendCriteria.none(), Event.of(new StockPicked(product, 3), tags)); |
| 182 | + |
| 183 | +StockLevelProjection projection3 = new StockLevelProjection(product); |
| 184 | +ProjectorMetrics metrics3 = Projector.from(stream).towards(projection3).build().run(); |
| 185 | + |
| 186 | +System.out.println("Stock level: " + projection3.level()); // 130 |
| 187 | +System.out.println("Events handled: " + metrics3.eventsHandled()); // 2 |
| 188 | +``` |
| 189 | + |
| 190 | +The `initQuery()` always finds the most recent savepoint. If a previous savepoint was incorrect, just append a corrected one — the next projection run picks it up automatically. |
| 191 | + |
| 192 | +## Design Decisions |
| 193 | + |
| 194 | +### Keep initQuery and eventQuery separate |
| 195 | + |
| 196 | +The `StockCounted` event type appears **only** in the `initQuery()`, not in the `eventQuery()`. This is intentional: |
| 197 | + |
| 198 | +- The main query never processes savepoint events, which protects against buggy historical savepoints |
| 199 | +- There is no risk of double-processing a savepoint that happens to fall within the eventQuery range |
| 200 | +- Each query has a clear responsibility: initQuery initializes, eventQuery processes deltas |
| 201 | + |
| 202 | +### Savepoints are pure domain events |
| 203 | + |
| 204 | +Savepoints are ordinary events appended to the stream with `stream.append()`. There is no special framework API or savepoint-specific storage. This means: |
| 205 | + |
| 206 | +- You decide when savepoints are created |
| 207 | +- You decide what data they contain |
| 208 | +- They are visible in queries like any other event |
| 209 | +- They participate in the normal event history |
| 210 | + |
| 211 | +### Graceful degradation |
| 212 | + |
| 213 | +When no savepoint exists — for example, on the very first run before any savepoint has been appended — the `initQuery()` returns nothing and the main `eventQuery()` replays from the beginning. No special configuration or fallback logic is needed. |
| 214 | + |
| 215 | +## Savepoints vs Bookmarking |
| 216 | + |
| 217 | +Both savepoints and bookmarking address the problem of avoiding full replays, but they serve different purposes: |
| 218 | + |
| 219 | +| | Savepoint Pattern | Bookmarking | |
| 220 | +|---|---|---| |
| 221 | +| **State storage** | Domain events in the stream | Separate bookmark record | |
| 222 | +| **First run** | Jumps to last savepoint | Replays from beginning | |
| 223 | +| **Created by** | Application logic | Projector framework | |
| 224 | +| **Use case** | On-demand projections, live models | Long-lived persistent projections | |
| 225 | +| **Requires persistence** | No (savepoints are in the stream) | Yes (bookmark storage) | |
| 226 | + |
| 227 | +> When bookmarking is enabled on the Projector, the `initQuery()` is ignored. Bookmarked projections track their own position and must process every event to stay consistent. If both are configured, a warning is logged at build time. |
| 228 | +{: .prompt-warning } |
| 229 | + |
| 230 | +The savepoint pattern is particularly well-suited for projections that are created on-the-fly — for example, to answer a specific query in an API request. Instead of maintaining a long-lived bookmarked projection, you instantiate a projection, let it catch up from the latest savepoint, and discard it when you are done. |
| 231 | + |
| 232 | +## When to Create Savepoints |
| 233 | + |
| 234 | +The choice of when to append savepoint events is entirely up to the application. Some common strategies: |
| 235 | + |
| 236 | +- **Periodic**: Append a savepoint every N events or on a time schedule |
| 237 | +- **On demand**: Append a savepoint after significant operations (e.g., after a physical stock count) |
| 238 | +- **Threshold-based**: Append a savepoint when the delta since the last savepoint exceeds a threshold |
| 239 | + |
| 240 | +Since savepoints are domain events, they can also carry business meaning. A `StockCounted` event is not just a technical optimization — it represents a real business activity (a stock count). This makes the savepoint pattern a natural fit for many domains. |
| 241 | + |
| 242 | +> The number of events between savepoints determines the tradeoff: more frequent savepoints mean faster initialization but more events in the stream. Find the right balance for your use case. |
| 243 | +{: .prompt-info } |
0 commit comments