Feature Description
Add a method to the EventStoreTransaction interface that allows users to override the AppendCondition constructed from sourcing operations before it is applied at commit time.
The proposed API:
void overrideAppendCondition(UnaryOperator<@Nullable AppendCondition> conditionOverride);
The override function receives the AppendCondition that the transaction would have used (constructed from all sourcing operations) and returns the AppendCondition that should actually be used. If no sourcing was performed, the argument is null. The function is not applied immediately -- it is stored and applied once, just before commit, to the final accumulated append condition.
Semantics
- Deferred application: The override function is stored and applied at commit time, after all sourcing operations have contributed their criteria to the default
AppendCondition. This prevents subsequent source() calls from unexpectedly overwriting a user-specified condition.
- Chaining: If
overrideAppendCondition is called multiple times, the override functions are composed (chained). Each subsequent call receives the result of the previous override applied to the sourcing-derived condition. This allows different parts of the application to independently narrow or adjust the append condition.
- Full control: The function operates on the entire
AppendCondition, giving access to both the EventCriteria and the ConsistencyMarker. Users can narrow criteria, replace them entirely, or adjust the marker.
- Logging: Any override must be logged at
DEBUG level, showing the condition before and after the override is applied. This is critical for diagnosing issues when users modify their append conditions.
Current Behaviour
The EventStoreTransaction builds the AppendCondition automatically from SourcingCondition criteria accumulated via source() calls:
- Each
source(SourcingCondition) call adds its EventCriteria to the append condition using orCriteria().
- The
ConsistencyMarker is tracked from the sourced event stream (using the lowest marker across multiple sourcings).
- At commit, the constructed
AppendCondition is passed to EventStorageEngine.appendEvents().
There is no way to modify this condition. The append condition always mirrors the sourcing criteria exactly.
This is a problem in two scenarios:
-
Appending without sourcing: A user wants to conditionally append events without sourcing first (e.g., ensuring an entity was never created by checking that no event with a specific tag exists since ORIGIN). Currently, there is no way to specify an AppendCondition when no sourcing has occurred.
-
Narrowing the append condition: A user has sourced multiple event types but only a subset of them can cause conflicts. The append condition should only include the conflict-relevant criteria, not all sourced criteria. Use cases include caching (consistency marker is already known), workflows (sourcing is done differently), and state convergence/divergence patterns where some event sourcing handlers only broaden state without introducing conflict potential.
Wanted Behaviour
The EventStoreTransaction interface gains a new method:
/**
* Registers a function to override the {@link AppendCondition} that this transaction would use
* at commit time.
* <p>
* The provided function receives the {@link AppendCondition} constructed from all
* {@link #source(SourcingCondition) sourcing} operations performed in this transaction and returns
* the {@link AppendCondition} that should actually be used when committing the staged events.
* If no sourcing has been performed, the function receives {@code null}.
* <p>
* The override function is <b>not applied immediately</b>. It is stored and applied once, just
* before commit, after all sourcing operations have contributed their criteria. This ensures that
* subsequent {@code source()} calls do not unexpectedly overwrite a user-specified condition.
* <p>
* If this method is invoked multiple times, the override functions are <b>chained</b>
* (composed). Each subsequent override receives the result of the previous one applied to the
* sourcing-derived condition.
*
* @param conditionOverride A {@link UnaryOperator} that transforms the default {@link AppendCondition}
* into the desired one. The input may be {@code null} if no sourcing was
* performed.
*/
void overrideAppendCondition(UnaryOperator<@Nullable AppendCondition> conditionOverride);
Examples
Enforcing unique name on creation (no sourcing needed):
A course should have a unique name. Without sourcing, we override the append condition to check that no CourseCreated event with the same name tag exists since ORIGIN:
EventStoreTransaction tx = eventStore.transaction(processingContext);
tx.overrideAppendCondition(ignored ->
AppendCondition.withCriteria(
EventCriteria.havingTags("courseName", courseName)
.ofType(CourseCreated.class)
)
);
tx.appendEvent(new CourseCreated(courseId, courseName));
// At commit: verifies no CourseCreated with tag "courseName"=courseName exists since ORIGIN
Narrowing criteria -- only conflict-relevant event types:
When handling a SubscribeStudentToCourse command, we source both course and student events to build state. However, only StudentSubscribedToCourse events can cause a conflict (e.g., duplicate subscription, no reamining places). StudentUnsubscribedFromCourse events only broaden state (free up places) and never conflict:
EventStoreTransaction tx = eventStore.transaction(processingContext);
tx.source(SourcingCondition.conditionFor(
EventCriteria.havingTags("courseId", courseId)
.ofType(StudentSubscribedToCourse.class, StudentUnsubscribedFromCourse.class)
));
// Only StudentSubscribedToCourse can cause conflicts
// StudentUnsubscribedFromCourse just makes more places available (broadens state)
tx.overrideAppendCondition(condition ->
condition.withCriteria(
EventCriteria.havingTags("courseId", courseId)
.ofType(StudentSubscribedToCourse.class)
)
);
tx.appendEvent(new StudentSubscribedToCourse(courseId, studentId));
Alternatives Considered
Two alternative API designs were discussed and rejected:
1. Override function as parameter when opening the transaction
EventStoreTransaction transaction(UnaryOperator<AppendCondition> conditionOverride,
ProcessingContext processingContext);
Rejected because: The transaction(ProcessingContext) method returns the existing transaction if one already exists for the given ProcessingContext. This means the method can be called multiple times, returning the same transaction instance. Adding the override function as a parameter creates ambiguity -- what happens when transaction(conditionOverrideA, pc) is called, then later transaction(conditionOverrideB, pc) returns the same transaction? The override would silently conflict. Placing the override on the transaction itself makes the mutation explicit and avoidable.
2. Override function as parameter on source()
MessageStream<? extends EventMessage> source(SourcingCondition condition,
UnaryOperator<AppendCondition> conditionOverride);
Rejected because:
- It does not address the use case of appending without sourcing, which was one of the two primary motivations.
- It couples the override to individual sourcing operations, but the
AppendCondition applies to the entire transaction, not per-sourcing-call. A transaction may have zero to many source() calls, and the append condition is accumulated across all of them. Allowing per-source overrides would require complex resolution logic when multiple sourcings provide conflicting overrides.
- The override function on the transaction is strictly more powerful -- the per-source variant can be built as a convenience layer on top of the transaction-level override if needed in the future.
Possible Workarounds
There is no clean workaround. Users who need a different append condition than what sourcing produces must either:
- Source unnecessarily just to set up the right criteria, adding a round-trip overhead that may not be needed.
- Accept the default (overly broad) append condition, which can cause unnecessary conflicts in high-concurrency scenarios.
Implementation Notes
- The initial implementation should be scoped to the
EventStoreTransaction interface and DefaultEventStoreTransaction. Higher-level integration with the entity/modelling module (e.g., declarative criteria resolvers for sourcing vs. appending) is a separate concern and should be done in a follow-up issues.
- Axon Server API does not prohibit append conditions different from sourcing conditions -- confirmed by Milan Savic.
AppendCondition currently has withMarker(ConsistencyMarker) and orCriteria(EventCriteria) as instance methods, but the withCriteria(EventCriteria) method is only available as a static factory (which always creates a new condition with ORIGIN marker). An instance-level withCriteria(EventCriteria) method should be added so that users can easily replace the criteria of a sourcing-derived AppendCondition while preserving its ConsistencyMarker. This makes the override function much more natural:
// With instance withCriteria — preserves marker from sourcing
tx.overrideAppendCondition(condition ->
condition.withCriteria(EventCriteria.havingTags("courseId", courseId)
.ofType(StudentSubscribedToCourse.class))
);
- Future consideration:
EventCriteria currently has no instance methods for removing specific tags or types (e.g., withoutTags(Tag...), withoutTypes(QualifiedName...)). This is intentional for now -- the override function combined with instance withCriteria on AppendCondition covers the known use cases, since users can reconstruct criteria from scratch while preserving the marker. Adding narrowing methods to EventCriteria would be complex due to its sealed hierarchy with 6 permits and ambiguous semantics for nested OrEventCriteria. If real usage patterns reveal a need, they can be added later.
Referenes:
Feature Description
Add a method to the
EventStoreTransactioninterface that allows users to override theAppendConditionconstructed from sourcing operations before it is applied at commit time.The proposed API:
The override function receives the
AppendConditionthat the transaction would have used (constructed from all sourcing operations) and returns theAppendConditionthat should actually be used. If no sourcing was performed, the argument isnull. The function is not applied immediately -- it is stored and applied once, just before commit, to the final accumulated append condition.Semantics
AppendCondition. This prevents subsequentsource()calls from unexpectedly overwriting a user-specified condition.overrideAppendConditionis called multiple times, the override functions are composed (chained). Each subsequent call receives the result of the previous override applied to the sourcing-derived condition. This allows different parts of the application to independently narrow or adjust the append condition.AppendCondition, giving access to both theEventCriteriaand theConsistencyMarker. Users can narrow criteria, replace them entirely, or adjust the marker.DEBUGlevel, showing the condition before and after the override is applied. This is critical for diagnosing issues when users modify their append conditions.Current Behaviour
The
EventStoreTransactionbuilds theAppendConditionautomatically fromSourcingConditioncriteria accumulated viasource()calls:source(SourcingCondition)call adds itsEventCriteriato the append condition usingorCriteria().ConsistencyMarkeris tracked from the sourced event stream (using the lowest marker across multiple sourcings).AppendConditionis passed toEventStorageEngine.appendEvents().There is no way to modify this condition. The append condition always mirrors the sourcing criteria exactly.
This is a problem in two scenarios:
Appending without sourcing: A user wants to conditionally append events without sourcing first (e.g., ensuring an entity was never created by checking that no event with a specific tag exists since
ORIGIN). Currently, there is no way to specify anAppendConditionwhen no sourcing has occurred.Narrowing the append condition: A user has sourced multiple event types but only a subset of them can cause conflicts. The append condition should only include the conflict-relevant criteria, not all sourced criteria. Use cases include caching (consistency marker is already known), workflows (sourcing is done differently), and state convergence/divergence patterns where some event sourcing handlers only broaden state without introducing conflict potential.
Wanted Behaviour
The
EventStoreTransactioninterface gains a new method:Examples
Enforcing unique name on creation (no sourcing needed):
A course should have a unique name. Without sourcing, we override the append condition to check that no
CourseCreatedevent with the same name tag exists sinceORIGIN:Narrowing criteria -- only conflict-relevant event types:
When handling a
SubscribeStudentToCoursecommand, we source both course and student events to build state. However, onlyStudentSubscribedToCourseevents can cause a conflict (e.g., duplicate subscription, no reamining places).StudentUnsubscribedFromCourseevents only broaden state (free up places) and never conflict:Alternatives Considered
Two alternative API designs were discussed and rejected:
1. Override function as parameter when opening the transaction
Rejected because: The
transaction(ProcessingContext)method returns the existing transaction if one already exists for the givenProcessingContext. This means the method can be called multiple times, returning the same transaction instance. Adding the override function as a parameter creates ambiguity -- what happens whentransaction(conditionOverrideA, pc)is called, then latertransaction(conditionOverrideB, pc)returns the same transaction? The override would silently conflict. Placing the override on the transaction itself makes the mutation explicit and avoidable.2. Override function as parameter on
source()Rejected because:
AppendConditionapplies to the entire transaction, not per-sourcing-call. A transaction may have zero to manysource()calls, and the append condition is accumulated across all of them. Allowing per-source overrides would require complex resolution logic when multiple sourcings provide conflicting overrides.Possible Workarounds
There is no clean workaround. Users who need a different append condition than what sourcing produces must either:
Implementation Notes
EventStoreTransactioninterface andDefaultEventStoreTransaction. Higher-level integration with the entity/modelling module (e.g., declarative criteria resolvers for sourcing vs. appending) is a separate concern and should be done in a follow-up issues.AppendConditioncurrently haswithMarker(ConsistencyMarker)andorCriteria(EventCriteria)as instance methods, but thewithCriteria(EventCriteria)method is only available as a static factory (which always creates a new condition withORIGINmarker). An instance-levelwithCriteria(EventCriteria)method should be added so that users can easily replace the criteria of a sourcing-derivedAppendConditionwhile preserving itsConsistencyMarker. This makes the override function much more natural:EventCriteriacurrently has no instance methods for removing specific tags or types (e.g.,withoutTags(Tag...),withoutTypes(QualifiedName...)). This is intentional for now -- the override function combined with instancewithCriteriaonAppendConditioncovers the known use cases, since users can reconstruct criteria from scratch while preserving the marker. Adding narrowing methods toEventCriteriawould be complex due to its sealed hierarchy with 6 permits and ambiguous semantics for nestedOrEventCriteria. If real usage patterns reveal a need, they can be added later.Referenes: