Occurrent 0.30.0 is released. The headline is Dynamic Consistency Boundary (DCB) support. The release also moves the write side of the API from Stream to List, gives every event a global position, makes @Subscription and the subscription DSL capability-neutral so they deliver both stream and DCB events, and ships a reactive Spring Boot starter. It requires Java 21.

This release changes several stable APIs, so read the backward-incompatible changes below before upgrading. Existing stream-based applications keep working once those changes are applied, and DCB is opt-in.

Highlights

Dynamic Consistency Boundary (DCB)

DCB lets a command enforce a consistency rule that spans more than one entity without forcing those entities into a single stream or aggregate. You describe the events a decision depends on as criteria over DCB tags and CloudEvent types, read them, and append new events on the condition that nothing matching those criteria has changed since the read. That gives you optimistic concurrency across a boundary you define per decision rather than per stream.

It is useful when a rule crosses entities. Enrolling a student in a course depends on both the course, for its capacity, and the student, for how many courses they are already in. Modeling that as one aggregate is awkward, and splitting it into two streams loses the cross-entity guarantee. With DCB the enrollment command reads both through one set of criteria and appends only if neither changed in the meantime.

DCB is a capability layered on the existing CloudEvent storage, not a new store or a new event format. A DCB event is a normal CloudEvent with dcbtags and the shared global position, so stream consumers and subscriptions still see it. It works across every event store (in-memory, native MongoDB, Spring MongoDB blocking and reactive), and comes with an application service, a query and subscription DSL, a @DcbSubscription annotation, an optional @DcbTag annotation with an AnnotationTagGenerator, and catch-up that replays by position. See the DCB documentation for the full walkthrough.

A global position on every event

Every event, stream and DCB alike, now carries a global, monotonic position, a single ordering axis across the whole store. It is on by default for a new store. Upgrading a store that already holds events without a position stays safe, because the MongoDB event store detects the un-positioned collection at startup and leaves position off for itself, rather than building the position index over your existing data, and logs how to turn it on and backfill. Catch-up that starts from the beginning or from a position now reconciles on position rather than wall-clock time, which closes a class of silent event-loss bugs during handover. A blocking subscription that starts from a specific point in time still uses time-based catch-up, since a timestamp has no position to map to. You can opt a stream-only store out with EventStoreConfig.withoutStreamPosition() (or occurrent.event-store.stream.position=false), and enable it explicitly over existing data with withStreamPosition() (or =true) once you have backfilled, but a store with both stream and DCB capabilities cannot opt out.

Capability-neutral subscriptions

@Subscription and the Subscriptions DSL have always driven stream subscriptions. In 0.30.0 they became capability-neutral: on a store with both stream and DCB capabilities they now deliver both stream-written and DCB-appended events, filtered by event type, with catch-up over the unified global position. On a stream-only store they behave exactly as before. @StreamSubscription and @DcbSubscription are the explicit capability-scoped forms for when you want only one.

Reactive Spring Boot starter

A new occurrent-mongodb-reactive-spring-boot-starter, enabled with @EnableOccurrentReactive, auto-configures the reactive event store, transaction manager, application service (stream and DCB), query DSLs, subscription model, checkpoint storage, and the reactive subscription DSLs. Reactive DCB now has full parity with the blocking stack, including live subscriptions and catch-up.

Backward-incompatible changes

This release raises the minimum to Java 21 and changes several stable APIs. Each change and its upgrade step is below. Most of the mechanical work is automated by an OpenRewrite recipe, so start with the upgrade guide for the recipe setup and the steps that still need a manual pass.

Change What changed How to upgrade
Module artifact names Every published artifact now has an occurrent- prefix (for example subscription-inmemory is now occurrent-subscription-inmemory). The org.occurrent groupId is unchanged. The two Spring Boot starters were renamed to Spring’s third-party convention, occurrent-mongodb-spring-boot-starter and occurrent-mongodb-reactive-spring-boot-starter. Run the OpenRewrite recipe. It rewrites the coordinates for Maven and Gradle. Or remap coordinates by hand using the table in the upgrade guide.
Java 21 The minimum JDK is now Java 21 (was 17). Stored data is unaffected. Build and run on Java 21.
Write API uses List EventStore.write(...) takes List<CloudEvent> instead of Stream<CloudEvent>, the application service domain function is Function<List<E>, List<E>> instead of Function<Stream<E>, Stream<E>>, its side effect is Consumer<List<E>>, and CloudEventConverter.toCloudEvents(...) takes and returns a List. Reads and queries stay lazy Stream. Pass a List (for example List.of(event)) to write. Change the domain function parameter and return from Stream<E> to List<E>. Reactive Flux/Mono I/O is unchanged.
Kotlin write extensions executeSequence and the blocking executeList are removed, sideEffectOnSequence is removed, and the write(streamId, Sequence<CloudEvent>) extensions are removed. Call execute { events: List<E> -> ... }, use sideEffectOnList, and call the Java write(streamId, List<CloudEvent>) with listOf(...).
Command composition CommandConversion and StreamCommandComposition are removed, along with the toStreamCommand / toListCommand / toSequenceCommand adapters. Compose with ListCommandComposition (or the Kotlin List-based andThen / composeCommands) and pass a Function<List<E>, List<E>> straight to execute. PartialFunctionApplication is unchanged.
PolicySideEffect renamed PolicySideEffect is now SideEffect, executePolicy is executeSideEffect, and andThenExecuteAnotherPolicy is andThenExecuteAnotherSideEffect (in application-service-blocking and application-service-reactor). Rename the references.
SubscriptionPosition renamed to Checkpoint The whole SubscriptionPosition family became Checkpoint (GlobalSubscriptionPosition to GlobalCheckpoint, SubscriptionPositionStorage to CheckpointStorage, PositionAwareSubscriptionModel to CheckpointAwareSubscriptionModel, StartAt.subscriptionPosition(...) to StartAt.checkpoint(...), and so on). There is no deprecated alias. Rename the references. The stored Mongo checkpoint field migrates itself on first save, so no manual data migration is needed.
OccurrentSubscriptionFilter renamed It is now StreamSubscriptionFilter, alongside AgnosticSubscriptionFilter and DcbSubscriptionFilter. Rename the references.
Subscriptions DSL split by capability The stream-only DSL is now StreamSubscriptions with builder streamSubscriptions(...). Subscriptions / subscriptions(...) was repurposed as the capability-neutral DSL that delivers both stream and DCB events on a store that has both. No change needed for a stream-only store. Use streamSubscriptions(...) if you want stream-only delivery on a store that also has DCB.
ExecuteFilter moved package org.occurrent.application.service.blocking.ExecuteFilter moved to org.occurrent.application.service.ExecuteFilter (shared application-service-common). Update the import.
EventMetadata moved package org.occurrent.dsl.subscription.blocking.EventMetadata moved to org.occurrent.dsl.subscription.EventMetadata, shared by the blocking and reactive subscription DSLs. Update the import.
Decider.compose Composing now requires at least two deciders and throws IllegalArgumentException for fewer. Pass two or more deciders.
MongoDB natural sort Combining a natural-sort step with other sort steps now throws IllegalArgumentException (Mongo 4.x silently reduced it to natural order). Do not combine natural sort with other sort steps.
Blocking catch-up fails loud StreamCatchupSubscriptionModel and DcbCatchupSubscriptionModel now throw IllegalStateException instead of silently falling back to a start position that could drop events. Configure a valid start position. The old fallback was unsafe.
Reactive durable subscription API ReactorDurableSubscriptionModel.subscribe(id, action) used to return a Mono<Void> that only started the subscription once you subscribed to it, and gave you no handle to manage it. It now returns an already-running Subscription through the shared Subscribable API, matching the blocking stack. Call subscribe(...) and use the returned Subscription, and drop the extra .subscribe() you used to call on the returned Mono.

Full details are in the changelog, and the documentation has been updated with the new features and examples.