From e0bdc950a0cdd5e22ebd86eabc1e42acedbf9742 Mon Sep 17 00:00:00 2001
From: Laura Devriendt
<94172218+laura-devriendt-lemon@users.noreply.github.com>
Date: Tue, 28 Jul 2026 10:05:04 +0200
Subject: [PATCH 1/2] [#4791] Extract snapshot sourcing composition into its
own enhancer
---
.../modules/tuning/pages/snapshotting.adoc | 28 +++
.../EventSourcingConfigurationDefaults.java | 21 +--
...SnapshotSourcingConfigurationEnhancer.java | 90 +++++++++
.../SnapshotCapableEventStorageEngine.java | 30 +++
...shotSourcingConfigurationEnhancerTest.java | 171 ++++++++++++++++++
...SnapshotCapableEventStorageEngineTest.java | 62 +++++++
6 files changed, 386 insertions(+), 16 deletions(-)
create mode 100644 eventsourcing/src/main/java/org/axonframework/eventsourcing/configuration/SnapshotSourcingConfigurationEnhancer.java
create mode 100644 eventsourcing/src/test/java/org/axonframework/eventsourcing/configuration/SnapshotSourcingConfigurationEnhancerTest.java
diff --git a/docs/reference-guide/modules/tuning/pages/snapshotting.adoc b/docs/reference-guide/modules/tuning/pages/snapshotting.adoc
index c9dd777c88..99801e160c 100644
--- a/docs/reference-guide/modules/tuning/pages/snapshotting.adoc
+++ b/docs/reference-guide/modules/tuning/pages/snapshotting.adoc
@@ -250,3 +250,31 @@ Only the events that occurred after that position are applied to bring the entit
If a snapshot cannot be used, the full event stream is replayed and a new snapshot may be created after sourcing completes, depending on the configured `SnapshotPolicy`.
This new snapshot replaces the previous one according to the current storage behavior.
+
+=== Snapshot sourcing composition
+
+An `EventStorageEngine` reads snapshots in one of two ways.
+An engine that supports the snapshot sourcing strategy natively serves the snapshot and the events following it in a single call.
+Any other engine is decorated with a `SnapshotCapableEventStorageEngine`, which loads the snapshot from the configured `SnapshotStore` first and then sources the events that follow it.
+
+That decoration is applied by the `SnapshotSourcingConfigurationEnhancer`, which the event sourcing defaults register for you.
+The engine is left as is when no `SnapshotStore` is configured, and when the engine is the configured `SnapshotStore` itself.
+
+A storage topology that composes snapshot sourcing itself disables that enhancer and takes over the responsibility:
+
+[source,java]
+----
+configurer.componentRegistry(
+ registry -> registry.disableEnhancer(SnapshotSourcingConfigurationEnhancer.class)
+);
+----
+
+An engine routing sourcing to one engine per shard, tenant, or region is the case this exists for.
+The target is only known once a message is inspected, so decorating the routing engine would read snapshots before then, and the engines it routes to would never receive the snapshot sourcing strategy.
+Such a topology composes each of its engines with that engine's own snapshot store instead, using `SnapshotCapableEventStorageEngine.decorate(engine, snapshotStore)`.
+
+[NOTE]
+====
+Disabling the enhancer without composing snapshot sourcing elsewhere leaves snapshots being written while never being read.
+Sourcing then replays an entity's full event history, which is correct but forfeits the optimization.
+====
diff --git a/eventsourcing/src/main/java/org/axonframework/eventsourcing/configuration/EventSourcingConfigurationDefaults.java b/eventsourcing/src/main/java/org/axonframework/eventsourcing/configuration/EventSourcingConfigurationDefaults.java
index 5bac6ec8dd..01b0b7bd3a 100644
--- a/eventsourcing/src/main/java/org/axonframework/eventsourcing/configuration/EventSourcingConfigurationDefaults.java
+++ b/eventsourcing/src/main/java/org/axonframework/eventsourcing/configuration/EventSourcingConfigurationDefaults.java
@@ -23,11 +23,9 @@
import org.axonframework.eventsourcing.eventstore.EventStorageEngine;
import org.axonframework.eventsourcing.eventstore.EventStore;
import org.axonframework.eventsourcing.eventstore.InterceptingEventStore;
-import org.axonframework.eventsourcing.eventstore.SnapshotCapableEventStorageEngine;
import org.axonframework.eventsourcing.eventstore.StorageEngineBackedEventStore;
import org.axonframework.eventsourcing.eventstore.TagResolver;
import org.axonframework.eventsourcing.eventstore.inmemory.InMemoryEventStorageEngine;
-import org.axonframework.eventsourcing.snapshot.store.SnapshotStore;
import org.axonframework.messaging.core.MessageDispatchInterceptor;
import org.axonframework.messaging.core.configuration.MessagingConfigurationDefaults;
import org.axonframework.messaging.core.interception.DispatchInterceptorRegistry;
@@ -52,13 +50,13 @@
*
* Furthermore, this enhancer will decorate the:
*
- * - The {@link EventStorageEngine} in a {@link SnapshotCapableEventStorageEngine} if a
- * {@link SnapshotStore} is present and it is not the same instance as the engine. When the engine and the
- * snapshot store are the same instance, the engine handles snapshots natively (potentially in a single
- * round-trip) and wrapping it would degrade performance.
* - The {@link EventStore} in a {@link InterceptingEventStore} if there are any
* {@link MessageDispatchInterceptor MessageDispatchInterceptors} present in the {@link DispatchInterceptorRegistry}.
*
+ * It also registers the {@link SnapshotSourcingConfigurationEnhancer}, which decorates the
+ * {@link EventStorageEngine} to support the {@link org.axonframework.eventsourcing.eventstore.SourcingStrategy.Snapshot
+ * snapshot sourcing strategy}. Registering it here rather than through the {@link java.util.ServiceLoader} means
+ * applications registering {@code this} enhancer manually receive it as well.
*
* @author Steven van Beelen
* @author John Hendrikx
@@ -85,16 +83,7 @@ public void enhance(ComponentRegistry registry) {
.registerIfNotPresent(EventStorageEngine.class,
EventSourcingConfigurationDefaults::defaultEventStorageEngine)
.registerIfNotPresent(EventStore.class, EventSourcingConfigurationDefaults::simpleEventStore);
- registry.registerDecorator(
- EventStorageEngine.class,
- SnapshotCapableEventStorageEngine.DECORATION_ORDER,
- (config, name, engine) -> {
- SnapshotStore snapshotStore = config.getOptionalComponent(SnapshotStore.class).orElse(null);
- return snapshotStore == null || snapshotStore == engine
- ? engine
- : new SnapshotCapableEventStorageEngine(engine, snapshotStore);
- }
- );
+ registry.registerEnhancer(new SnapshotSourcingConfigurationEnhancer());
registry.registerDecorator(
EventStore.class,
InterceptingEventStore.DECORATION_ORDER,
diff --git a/eventsourcing/src/main/java/org/axonframework/eventsourcing/configuration/SnapshotSourcingConfigurationEnhancer.java b/eventsourcing/src/main/java/org/axonframework/eventsourcing/configuration/SnapshotSourcingConfigurationEnhancer.java
new file mode 100644
index 0000000000..58e376f696
--- /dev/null
+++ b/eventsourcing/src/main/java/org/axonframework/eventsourcing/configuration/SnapshotSourcingConfigurationEnhancer.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2010-2026. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.axonframework.eventsourcing.configuration;
+
+import org.axonframework.common.annotation.RegistrationScope;
+import org.axonframework.common.configuration.ComponentRegistry;
+import org.axonframework.common.configuration.ConfigurationEnhancer;
+import org.axonframework.eventsourcing.eventstore.EventStorageEngine;
+import org.axonframework.eventsourcing.eventstore.SnapshotCapableEventStorageEngine;
+import org.axonframework.eventsourcing.eventstore.SourcingStrategy;
+import org.axonframework.eventsourcing.snapshot.store.SnapshotStore;
+
+/**
+ * A {@link ConfigurationEnhancer} making the {@link EventStorageEngine} support the
+ * {@link SourcingStrategy.Snapshot snapshot sourcing strategy}, by decorating it with the configured
+ * {@link SnapshotStore} through {@link SnapshotCapableEventStorageEngine#decorate(EventStorageEngine, SnapshotStore)}.
+ *
+ * The engine is left as is when no {@code SnapshotStore} is configured, and when it is the configured
+ * {@code SnapshotStore} itself. The latter resolves the snapshot within its own {@code source} call, in a single round
+ * trip, which decorating would undo.
+ *
+ * Registered by {@link EventSourcingConfigurationDefaults}, so applications registering those defaults manually receive
+ * it as well. It is deliberately not contributed through the {@link java.util.ServiceLoader}, unlike its sibling
+ * enhancers, to keep that single registration channel. Registered for the {@link RegistrationScope.Scope#CURRENT
+ * current} registry only, since the defaults registering it are copied into every module registry and would otherwise
+ * register it there a second time.
+ *
+ * A storage topology composing snapshot sourcing itself disables this enhancer, taking over that responsibility:
+ *
{@code
+ * configurer.componentRegistry(
+ * registry -> registry.disableEnhancer(SnapshotSourcingConfigurationEnhancer.class)
+ * );
+ * }
+ * An engine routing {@code source} to one engine per shard, tenant, or region is the case this exists for: the shard is
+ * only known once a message is inspected, so decorating the routing engine would resolve snapshots before then, and the
+ * engines it routes to would never receive the snapshot sourcing strategy. Such a topology composes each of its engines
+ * with that engine's own snapshot store instead, through the same
+ * {@link SnapshotCapableEventStorageEngine#decorate(EventStorageEngine, SnapshotStore) decorate} operation.
+ *
+ * Disabling this enhancer without composing snapshot sourcing elsewhere leaves snapshots being written while never
+ * being read: sourcing then replays an entity's full event history.
+ *
+ * @author Laura Devriendt
+ * @since 5.3.0
+ */
+@RegistrationScope(scope = RegistrationScope.Scope.CURRENT)
+public class SnapshotSourcingConfigurationEnhancer implements ConfigurationEnhancer {
+
+ /**
+ * The order of {@code this} enhancer compared to others, equal to 10 positions after
+ * {@link EventSourcingConfigurationDefaults} (thus,
+ * {@link EventSourcingConfigurationDefaults#ENHANCER_ORDER} + 10).
+ *
+ * What this order governs is the window in which {@code this} enhancer can still be disabled: a disable is only
+ * recorded while the enhancer has not been invoked yet, and is otherwise reported as having no effect. Running late
+ * therefore leaves every earlier-ordered enhancer free to take over snapshot composition.
+ */
+ public static final int ENHANCER_ORDER = EventSourcingConfigurationDefaults.ENHANCER_ORDER + 10;
+
+ @Override
+ public int order() {
+ return ENHANCER_ORDER;
+ }
+
+ @Override
+ public void enhance(ComponentRegistry registry) {
+ registry.registerDecorator(
+ EventStorageEngine.class,
+ SnapshotCapableEventStorageEngine.DECORATION_ORDER,
+ (config, name, engine) -> config
+ .getOptionalComponent(SnapshotStore.class)
+ .map(snapshotStore -> SnapshotCapableEventStorageEngine.decorate(engine, snapshotStore))
+ .orElse(engine)
+ );
+ }
+}
diff --git a/eventsourcing/src/main/java/org/axonframework/eventsourcing/eventstore/SnapshotCapableEventStorageEngine.java b/eventsourcing/src/main/java/org/axonframework/eventsourcing/eventstore/SnapshotCapableEventStorageEngine.java
index cb1be75387..e80942de56 100644
--- a/eventsourcing/src/main/java/org/axonframework/eventsourcing/eventstore/SnapshotCapableEventStorageEngine.java
+++ b/eventsourcing/src/main/java/org/axonframework/eventsourcing/eventstore/SnapshotCapableEventStorageEngine.java
@@ -77,6 +77,36 @@ public SnapshotCapableEventStorageEngine(EventStorageEngine delegate, SnapshotSt
this.snapshotStore = Objects.requireNonNull(snapshotStore, "The snapshotStore parameter cannot be null.");
}
+ /**
+ * Returns an {@link EventStorageEngine} that supports the {@link SourcingStrategy.Snapshot} sourcing strategy,
+ * given the {@code engine} to source events from and the {@code snapshotStore} holding its snapshots.
+ *
+ * The given {@code engine} is returned as is when it is the given {@code snapshotStore} itself. Such an engine
+ * resolves the snapshot within its own {@link #source(SourcingCondition, ProcessingContext) source} call, serving
+ * the snapshot and the events following it in a single round trip. Decorating it would resolve the snapshot
+ * separately and pass an {@link SourcingStrategy.Absolute absolute strategy} inward, costing that optimization.
+ *
+ * An {@code engine} that is already decorated is returned as is too, so composing twice is harmless. It keeps
+ * resolving snapshots from the store it was decorated with, and the given {@code snapshotStore} is ignored for it.
+ * Decorating again would put the given store in front of that one instead of adding anything.
+ *
+ * Any other {@code engine} is decorated, resolving the snapshot from the {@code snapshotStore} before sourcing the
+ * events that follow it.
+ *
+ * @param engine the engine to source events from
+ * @param snapshotStore the store holding the snapshots of the given {@code engine}
+ * @return an event storage engine supporting the snapshot sourcing strategy
+ * @throws NullPointerException if the given {@code engine} or {@code snapshotStore} is {@code null}
+ * @since 5.3.0
+ */
+ public static EventStorageEngine decorate(EventStorageEngine engine, SnapshotStore snapshotStore) {
+ Objects.requireNonNull(engine, "The engine parameter cannot be null.");
+ Objects.requireNonNull(snapshotStore, "The snapshotStore parameter cannot be null.");
+ return engine == snapshotStore || engine instanceof SnapshotCapableEventStorageEngine
+ ? engine
+ : new SnapshotCapableEventStorageEngine(engine, snapshotStore);
+ }
+
@Override
public MessageStream source(SourcingCondition condition, @Nullable ProcessingContext context) {
if (condition.strategy() instanceof SourcingStrategy.Snapshot s) {
diff --git a/eventsourcing/src/test/java/org/axonframework/eventsourcing/configuration/SnapshotSourcingConfigurationEnhancerTest.java b/eventsourcing/src/test/java/org/axonframework/eventsourcing/configuration/SnapshotSourcingConfigurationEnhancerTest.java
new file mode 100644
index 0000000000..7a75571ca7
--- /dev/null
+++ b/eventsourcing/src/test/java/org/axonframework/eventsourcing/configuration/SnapshotSourcingConfigurationEnhancerTest.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright (c) 2010-2026. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.axonframework.eventsourcing.configuration;
+
+import org.axonframework.common.configuration.ApplicationConfigurer;
+import org.axonframework.common.configuration.BaseModule;
+import org.axonframework.common.configuration.ComponentRegistry;
+import org.axonframework.common.configuration.Configuration;
+import org.axonframework.common.configuration.ConfigurationEnhancer;
+import org.axonframework.eventsourcing.eventstore.EventStorageEngine;
+import org.axonframework.eventsourcing.eventstore.SnapshotCapableEventStorageEngine;
+import org.axonframework.eventsourcing.eventstore.inmemory.InMemoryEventStorageEngine;
+import org.axonframework.eventsourcing.snapshot.inmemory.InMemorySnapshotStore;
+import org.axonframework.eventsourcing.snapshot.store.SnapshotStore;
+import org.axonframework.messaging.core.configuration.MessagingConfigurer;
+import org.jspecify.annotations.NonNull;
+import org.junit.jupiter.api.*;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Test class validating the {@link SnapshotSourcingConfigurationEnhancer}.
+ *
+ * @author Laura Devriendt
+ */
+class SnapshotSourcingConfigurationEnhancerTest {
+
+ private final SnapshotSourcingConfigurationEnhancer testSubject = new SnapshotSourcingConfigurationEnhancer();
+
+ @Test
+ void orderEqualsEnhancerOrderConstant() {
+ assertThat(testSubject.order()).isEqualTo(SnapshotSourcingConfigurationEnhancer.ENHANCER_ORDER);
+ }
+
+ // The defaults register this enhancer while they are themselves being invoked, and an enhancer registered that way
+ // is invoked in its order relative to the enhancers that have not run yet. A lower order would place it before
+ // enhancers that already ran.
+ @Test
+ void orderExceedsTheEventSourcingDefaults() {
+ assertThat(SnapshotSourcingConfigurationEnhancer.ENHANCER_ORDER)
+ .isGreaterThan(EventSourcingConfigurationDefaults.ENHANCER_ORDER);
+ }
+
+ @Test
+ void decoratesTheEventStorageEngineWhenASnapshotStoreIsPresent() {
+ ApplicationConfigurer configurer = EventSourcingConfigurer.create();
+ configurer.componentRegistry(cr -> cr.registerComponent(SnapshotStore.class, c -> new InMemorySnapshotStore()));
+
+ Configuration resultConfig = configurer.build();
+
+ assertThat(resultConfig.getComponent(EventStorageEngine.class))
+ .isInstanceOf(SnapshotCapableEventStorageEngine.class);
+ }
+
+ // Registering the event sourcing defaults by hand must yield the same decoration as EventSourcingConfigurer does,
+ // since those defaults are what register this enhancer.
+ @Test
+ void decoratesTheEventStorageEngineWhenTheEventSourcingDefaultsAreRegisteredManually() {
+ ApplicationConfigurer configurer = MessagingConfigurer.create();
+ configurer.componentRegistry(cr -> cr.registerEnhancer(new EventSourcingConfigurationDefaults())
+ .registerComponent(SnapshotStore.class,
+ c -> new InMemorySnapshotStore()));
+
+ Configuration resultConfig = configurer.build();
+
+ assertThat(resultConfig.getComponent(EventStorageEngine.class))
+ .isInstanceOf(SnapshotCapableEventStorageEngine.class);
+ }
+
+ // The supported opt-out for a storage topology composing snapshot sourcing itself.
+ @Test
+ void leavesTheEventStorageEngineUndecoratedWhenDisabled() {
+ ApplicationConfigurer configurer = EventSourcingConfigurer.create();
+ InMemoryEventStorageEngine composedEngine = new InMemoryEventStorageEngine();
+ configurer.componentRegistry(
+ cr -> cr.registerComponent(SnapshotStore.class, c -> new InMemorySnapshotStore())
+ .registerComponent(EventStorageEngine.class, c -> composedEngine)
+ .disableEnhancer(SnapshotSourcingConfigurationEnhancer.class)
+ );
+
+ Configuration resultConfig = configurer.build();
+
+ assertThat(resultConfig.getComponent(EventStorageEngine.class)).isSameAs(composedEngine);
+ }
+
+ // A storage topology switches this off from its own enhancer, which is the cross-repo opt-out. That only works while
+ // this enhancer has not run yet, so the disabling enhancer has to be ordered before it.
+ @Test
+ void leavesTheEventStorageEngineUndecoratedWhenDisabledByAnEarlierEnhancer() {
+ ApplicationConfigurer configurer = EventSourcingConfigurer.create();
+ InMemoryEventStorageEngine composedEngine = new InMemoryEventStorageEngine();
+ configurer.componentRegistry(
+ cr -> cr.registerComponent(SnapshotStore.class, config -> new InMemorySnapshotStore())
+ .registerComponent(EventStorageEngine.class, config -> composedEngine)
+ .registerEnhancer(new ComposesSnapshotSourcingItself())
+ );
+
+ Configuration resultConfig = configurer.build();
+
+ assertThat(resultConfig.getComponent(EventStorageEngine.class)).isSameAs(composedEngine);
+ }
+
+ // A module registry receives the parent's decorator definitions by copy, so the snapshot decorator is applied a
+ // second time to a module registering its own engine and snapshot store. The redundant decoration is not observable
+ // through snapshot loads, since the outer decoration rewrites the condition to an absolute one before delegating and
+ // the inner one then passes it through, so the nesting itself is what has to be asserted.
+ @Test
+ void decoratesAModulesOwnEventStorageEngineExactlyOnce() {
+ ApplicationConfigurer configurer = EventSourcingConfigurer.create();
+ configurer.componentRegistry(parentRegistry -> parentRegistry.registerModule(
+ new EventSourcingModule("snapshot-module").componentRegistry(
+ moduleRegistry -> moduleRegistry
+ .registerComponent(SnapshotStore.class, config -> new InMemorySnapshotStore())
+ .registerComponent(EventStorageEngine.class,
+ config -> new InMemoryEventStorageEngine())
+ )
+ ));
+
+ Configuration moduleConfig = configurer.build().getModuleConfiguration("snapshot-module").orElseThrow();
+
+ EventStorageEngine engine = moduleConfig.getComponent(EventStorageEngine.class);
+ assertThat(engine).isInstanceOf(SnapshotCapableEventStorageEngine.class);
+ assertThat(engine).extracting("delegate").isNotInstanceOf(SnapshotCapableEventStorageEngine.class);
+ }
+
+ @Test
+ void leavesTheEventStorageEngineUndecoratedWhenNoSnapshotStoreIsPresent() {
+ ApplicationConfigurer configurer = EventSourcingConfigurer.create();
+
+ Configuration resultConfig = configurer.build();
+
+ assertThat(resultConfig.getComponent(EventStorageEngine.class))
+ .isNotInstanceOf(SnapshotCapableEventStorageEngine.class);
+ }
+
+ private static class EventSourcingModule extends BaseModule {
+
+ private EventSourcingModule(String name) {
+ super(name);
+ }
+ }
+
+ // Stands in for a storage topology composing snapshot sourcing per shard, tenant, or region. Ordered before the
+ // enhancer it disables, since a disable is only recorded while that enhancer has not run yet.
+ private static class ComposesSnapshotSourcingItself implements ConfigurationEnhancer {
+
+ @Override
+ public void enhance(@NonNull ComponentRegistry registry) {
+ registry.disableEnhancer(SnapshotSourcingConfigurationEnhancer.class);
+ }
+
+ @Override
+ public int order() {
+ return SnapshotSourcingConfigurationEnhancer.ENHANCER_ORDER - 1;
+ }
+ }
+}
diff --git a/eventsourcing/src/test/java/org/axonframework/eventsourcing/eventstore/SnapshotCapableEventStorageEngineTest.java b/eventsourcing/src/test/java/org/axonframework/eventsourcing/eventstore/SnapshotCapableEventStorageEngineTest.java
index 5a9a70d4f1..48571b8784 100644
--- a/eventsourcing/src/test/java/org/axonframework/eventsourcing/eventstore/SnapshotCapableEventStorageEngineTest.java
+++ b/eventsourcing/src/test/java/org/axonframework/eventsourcing/eventstore/SnapshotCapableEventStorageEngineTest.java
@@ -38,6 +38,7 @@
import java.util.concurrent.CompletableFuture;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.axonframework.messaging.eventhandling.EventTestUtils.createEvent;
/**
@@ -166,6 +167,67 @@ private static List sourceMessages(MessageStream extends EventMe
.join();
}
+ @Nested
+ class Decorate {
+
+ @Test
+ void decoratesAnEngineThatIsNotTheSnapshotStore() {
+ EventStorageEngine result = SnapshotCapableEventStorageEngine.decorate(delegate, snapshotStore);
+
+ assertThat(result).isInstanceOf(SnapshotCapableEventStorageEngine.class);
+ }
+
+ // Such an engine resolves the snapshot within its own source call, so decorating costs it a round trip.
+ @Test
+ void returnsAnEngineThatIsTheSnapshotStoreAsIs() {
+ SnapshotResolvingEngine snapshotResolvingEngine = new SnapshotResolvingEngine();
+
+ EventStorageEngine result =
+ SnapshotCapableEventStorageEngine.decorate(snapshotResolvingEngine, snapshotResolvingEngine);
+
+ assertThat(result).isSameAs(snapshotResolvingEngine);
+ }
+
+ // A module registry receives the copied decorator definition and re-runs the enhancer that registers it, so the
+ // same engine is composed twice. The second composition must not add a second snapshot load.
+ @Test
+ void returnsAnAlreadyDecoratedEngineAsIs() {
+ EventStorageEngine once = SnapshotCapableEventStorageEngine.decorate(delegate, snapshotStore);
+
+ EventStorageEngine twice = SnapshotCapableEventStorageEngine.decorate(once, new InMemorySnapshotStore());
+
+ assertThat(twice).isSameAs(once);
+ }
+
+ @Test
+ void rejectsANullEngine() {
+ assertThatThrownBy(() -> SnapshotCapableEventStorageEngine.decorate(null, snapshotStore))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("The engine parameter cannot be null.");
+ }
+
+ @Test
+ void rejectsANullSnapshotStore() {
+ assertThatThrownBy(() -> SnapshotCapableEventStorageEngine.decorate(delegate, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("The snapshotStore parameter cannot be null.");
+ }
+ }
+
+ // An engine that is its own snapshot store, as PostgresqlEventStorageEngine is.
+ private static class SnapshotResolvingEngine extends InMemoryEventStorageEngine implements SnapshotStore {
+
+ @Override
+ public CompletableFuture store(QualifiedName qn, Object id, Snapshot s, ProcessingContext context) {
+ return CompletableFuture.completedFuture(null);
+ }
+
+ @Override
+ public CompletableFuture load(QualifiedName qn, Object id, ProcessingContext context) {
+ return CompletableFuture.completedFuture(null);
+ }
+ }
+
private static SnapshotStore failingSnapshotStore() {
return new SnapshotStore() {
@Override
From c3b2c6c827e508399979a7c30b053cf604a2ad2d Mon Sep 17 00:00:00 2001
From: Laura Devriendt
<94172218+laura-devriendt-lemon@users.noreply.github.com>
Date: Tue, 28 Jul 2026 11:00:43 +0200
Subject: [PATCH 2/2] [#4791] Process review comments
---
.../reference-guide/modules/tuning/pages/snapshotting.adoc | 7 ++++---
.../eventstore/SnapshotCapableEventStorageEngine.java | 2 +-
2 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/docs/reference-guide/modules/tuning/pages/snapshotting.adoc b/docs/reference-guide/modules/tuning/pages/snapshotting.adoc
index 99801e160c..93563cbe04 100644
--- a/docs/reference-guide/modules/tuning/pages/snapshotting.adoc
+++ b/docs/reference-guide/modules/tuning/pages/snapshotting.adoc
@@ -253,9 +253,10 @@ This new snapshot replaces the previous one according to the current storage beh
=== Snapshot sourcing composition
-An `EventStorageEngine` reads snapshots in one of two ways.
-An engine that supports the snapshot sourcing strategy natively serves the snapshot and the events following it in a single call.
-Any other engine is decorated with a `SnapshotCapableEventStorageEngine`, which loads the snapshot from the configured `SnapshotStore` first and then sources the events that follow it.
+An `EventStorageEngine` reads snapshots in one of two ways:
+
+* An engine that supports the snapshot sourcing strategy natively serves the snapshot and the events following it in a single call.
+* Any other engine is decorated with a `SnapshotCapableEventStorageEngine`, which loads the snapshot from the configured `SnapshotStore` first and then sources the events that follow it.
That decoration is applied by the `SnapshotSourcingConfigurationEnhancer`, which the event sourcing defaults register for you.
The engine is left as is when no `SnapshotStore` is configured, and when the engine is the configured `SnapshotStore` itself.
diff --git a/eventsourcing/src/main/java/org/axonframework/eventsourcing/eventstore/SnapshotCapableEventStorageEngine.java b/eventsourcing/src/main/java/org/axonframework/eventsourcing/eventstore/SnapshotCapableEventStorageEngine.java
index e80942de56..e67475db35 100644
--- a/eventsourcing/src/main/java/org/axonframework/eventsourcing/eventstore/SnapshotCapableEventStorageEngine.java
+++ b/eventsourcing/src/main/java/org/axonframework/eventsourcing/eventstore/SnapshotCapableEventStorageEngine.java
@@ -84,7 +84,7 @@ public SnapshotCapableEventStorageEngine(EventStorageEngine delegate, SnapshotSt
* The given {@code engine} is returned as is when it is the given {@code snapshotStore} itself. Such an engine
* resolves the snapshot within its own {@link #source(SourcingCondition, ProcessingContext) source} call, serving
* the snapshot and the events following it in a single round trip. Decorating it would resolve the snapshot
- * separately and pass an {@link SourcingStrategy.Absolute absolute strategy} inward, costing that optimization.
+ * separately and pass an {@link SourcingStrategy.Absolute absolute strategy} inward, disabling that optimization.
*
* An {@code engine} that is already decorated is returned as is too, so composing twice is harmless. It keeps
* resolving snapshots from the store it was decorated with, and the given {@code snapshotStore} is ignored for it.