Skip to content

EventStoreTransaction - allow overriding AppendCondition calculated from sourcing #4291

@MateuszNaKodach

Description

@MateuszNaKodach

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:

  1. Each source(SourcingCondition) call adds its EventCriteria to the append condition using orCriteria().
  2. The ConsistencyMarker is tracked from the sourced event stream (using the lowest marker across multiple sourcings).
  3. 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:

  1. 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.

  2. 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:

Metadata

Metadata

Labels

Priority 1: MustHighest priority. A release cannot be made if this issue isn’t resolved.
No fields configured for Feature.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions