Occurrent 0.31.0 is released. Where 0.30.0 added Dynamic Consistency Boundary support, this release is about writing less wiring by hand. Sagas, read models, snapshots and write-path subscriptions each get a DSL, so what used to be spread across a subscription, a converter and a repository call is declared in one place. Each also gets an annotation, @Saga, @Projection, @Snapshot and @SynchronousSubscription, and those need the Spring Boot starter, since it is what registers them. The DSLs need no framework, so nothing here makes Spring a requirement. It builds on 0.30.0 and requires Java 21.
Most of the release is new modules, so there is nothing to migrate for those. Four existing APIs moved, all of them 0.30.0 coordinates or types, and an OpenRewrite recipe rewrites every reference. One further change alters how code you already run behaves rather than what it compiles against. Read the behavior changes and the backward-incompatible changes below before upgrading.
Highlights
A Saga DSL for event-driven process managers
A saga runs a process that spans several streams and takes time. It reacts to events, and to their absence when a timer fires, by issuing commands, and it keeps its own state for each running instance.
There is no I/O in a saga. A reaction returns what should happen (issue this command, start or cancel that timer) as data, and the runner carries it out, so a saga is tested like any other function. Rebuilding an instance from history calls only the fold, never a reaction, so replay can never re-issue a command. A timer carries a Duration or an Instant instead of reading a clock, which keeps a test deterministic, and timers are saved together with the saga’s state in one write, so a timer can never be recorded without the state that expects it.
Two DSLs describe the same saga and run on the same runner. The core DSL (Saga.builder(initialState) in Java, saga(initialState) { } in Kotlin) registers a fold and a reaction per event type. The flow DSL (FlowSaga.builder() in Java, saga { } in Kotlin) has steps, branches, joins and per-step timeouts, so a mostly linear process reads like the process itself.
Each event type says how it maps to an instance, with a correlateAll fallback, and that is checked when you build the saga rather than when an event arrives. @Saga declares one on the blocking Spring stack, registered through the same catch-up and checkpoint machinery @Projection uses. Across several application instances a lease makes sure only one of them fires a given timeout.
A saga is not a substitute for a Dynamic Consistency Boundary. When two rules have to hold in the same append, DCB is the right tool. Reach for a saga when the process really does span boundaries and take time. The new order-fulfillment example shows both DSLs, in memory and without Docker. See the Saga DSL documentation for the walkthrough. Resolves #124.
A Projection DSL and @Projection read models
A Projection puts a read model in one place: the fold that updates it, which instance an event belongs to, and the event types it handles. A DcbProjection adds a DCB read boundary. The event types you register are also the subscription filter, so you list them once instead of keeping a fold and a filter in sync. A handler, and the function that picks the instance, can also read the event’s metadata (stream id and version, global position, CloudEvent extensions), so a read model can key on stream id or fold on the global position.
On the Spring Boot starter, put @Projection on a factory method returning a Projection or DcbProjection and it becomes a managed subscription on both the blocking and reactor stacks, using the same catch-up and durable checkpoints @DcbSubscription and @StreamSubscription already use. Store the result through ViewStateRepository, MaterializedView or a Spring Data CrudRepository. mode picks ASYNC, which catches up and then follows live, or SYNCHRONOUS, which updates the read model in the write path so a read straight afterwards already sees it. The new projection-dsl example shows it in Java and Kotlin, for stream and DCB. See the Projection DSL documentation. Resolves #194.
First-class snapshots
A snapshot lets a command fold only the events written since a saved state instead of the whole history. It is a cache and never a source of truth. It carries the schema version you declare, so a changed state shape, or no snapshot at all, simply falls back to a full replay. It works from a Decider or from a plain View, on stream and on DCB, on both the blocking and reactor stacks.
@Snapshot declares and maintains one through the same machinery @Projection uses, and a SnapshotPolicy says when to take one, either on a count of events (everyNEvents) or on the state itself (whenState, and whenTerminal for closing the books). The snapshot is saved after the write has committed, and if saving it fails the command still succeeds, at the cost of a fuller replay next time. The new closing-the-books example shows both an every-N snapshot and a domain period close. See the snapshot documentation.
Synchronous subscriptions
A subscription can now run in the write path. Declare it with the new @SynchronousSubscription, or register a handler on a SynchronousSubscriptionModel through the Subscriptions DSL with no Spring at all. The application service calls the handler on the writing thread, before execute returns, so a read model is up to date the moment the command returns. The existing asynchronous subscriptions are a separate mechanism and are unchanged.
Transactions are opt-in. Configure a TransactionExecutor on the application service and the write and the handlers commit together, backed either by Spring or by a native MongoDB ClientSession. Available on the blocking and reactive, stream and DCB application services.
Projections fed from a broker
A projection no longer has to be fed by a MongoDB change stream. Hand a CloudEvent to PushSubscriptionModel.accept(...) and it reaches the registered handlers, so a RabbitMQ, Kafka, Spring event or HTTP listener can drive a projection in production. Occurrent adds no broker dependency of its own.
CatchupThenPushSubscriptionModel puts a one-time catch-up in front of the live feed. On first subscribe it replays the projection’s history from the event store, then hands over to the broker. @Projection(source = Source.PUSH) sets that up for you. If your source already gives you domain events rather than CloudEvents, Projections.domainEventFeed(...) folds them straight into the read model, with no conversion on the live path. ADR 62 covers the design, including why the broker stays responsible for resuming the live feed.
Reusable command dispatch
The command-dispatch code that used to live inside the Saga DSL is now its own occurrent-command-dispatch module, so anything that issues commands can use it. CommandDispatcher<C> is the interface a command producer calls, and StreamIdResolver<C> is a named function from a command to the stream it targets, the stream-side equivalent of the DCB TagGenerator. Mark the command member holding that id with the new @TargetStreamId and occurrent-command-dispatch-annotation reads it with reflection, the same way @DcbTag and AnnotationTagGenerator already work for DCB. The blocking Spring Boot starter contributes a default resolver bean that you can replace with your own.
Two factories save you the wiring. CommandDispatchers.decider(...) builds a dispatcher for a stream-keyed decider. DcbCommandDispatchers.decider(...), in its own occurrent-command-dispatch-dcb module, builds one for a DcbDecider, which needs no stream id because it already carries its own criteria and tag generator.
Spring Boot annotations no longer tied to MongoDB
All the Spring Boot annotation handling used to sit inside the two MongoDB starters, which meant a second event store would have had to copy it. @Subscription, @StreamSubscription, @DcbSubscription, @SynchronousSubscription, @Projection, @Snapshot and @Saga are now handled in three modules that name no database: occurrent-spring-boot-autoconfigure for what both stacks share, plus occurrent-blocking-spring-boot-autoconfigure and occurrent-reactor-spring-boot-autoconfigure. The MongoDB half stays in the starters, which keep @EnableOccurrent and the autoconfiguration, and now pass their storage defaults to the shared code through small interfaces rather than having the shared code reach for a collection itself.
For an application on the MongoDB starter this is one renamed artifact and one moved package, nothing more (see below). What it buys is that a JDBC starter (#410) can reuse the annotations instead of reimplementing them. The annotations themselves never needed Spring. They live in occurrent-annotations, which has no Spring dependency, so a Quarkus or Micronaut integration can reuse them and the DSLs and write only its own registration layer. The reasoning is in ADR 72.
Smaller additions
- The view, projection and saga DSLs can fold with event metadata (stream id and version, global position, CloudEvent extensions), the same
EventMetadataa subscriber already sees. - Java callers get one-call helpers for running a
Decideror aDcbDeciderthrough an application service, matching what the Kotlinexecute(command, decider)extensions already did. - Kotlin DCB criteria can name event classes as
type<OrderPlaced>()andtypes<OrderPlaced, OrderCancelled>(), with the base type inferred, instead of::class.java. - DCB reads can select matches from either end with composable
skipandlimitoptions.fromBeginning().backwards().limit(1)fetches the newest matching event in one round trip, while addingskip(4)fetches the fifth newest. This lets a gapless business sequence find its last entry without folding the whole history. - A subscription handler can take the stream id or version directly through the new
@StreamIdand@StreamVersionparameter annotations, without declaring anEventMetadataparameter. - The MongoDB change stream behind a subscription can be tuned for throughput and latency with
batchSize(int)andmaxAwaitTime(Duration). Both are opt-in and unset by default. Resolves #173. DcbDecider.criteriaFor(command)andcriteriaFor(commands)work out the DCB read boundary for one or more commands. An unrecognized command is rejected, and every command in one execute has to share a boundary, since they are appended together.- A new dcb-patterns example module collects the dcb.events patterns the other examples do not cover: a unique username with a retention period, idempotency, a price change grace period, a one-time opt-in token, and a gapless invoice number. Kotlin, no Spring, no Docker.
Behavior changes
One change in this release alters what already-working code does, and no compiler error will point at it.
A retry only works where it also owns the transaction, because only the code that started a transaction can start a fresh one. The two Spring MongoDB event stores now check whether a transaction is already open before they retry a write conflict. When one is, they run the write once and let the conflict reach whoever owns that transaction. They used to retry regardless, which could never have committed: MongoDB aborts the transaction at the first conflict, so every later attempt failed straight away on its first read, burning all the attempts and about five seconds before failing anyway.
The retry the store gives up is taken over by whoever does own the transaction. SpringTransactionExecutor and SpringReactiveTransactionExecutor retry around the transaction they open, which is what keeps a synchronous-subscription setup retrying, since there Occurrent opens the transaction itself.
If you wrap a command in your own @Transactional, you now have to retry at that boundary. Nothing in Occurrent retries inside a transaction it does not own. Catching the conflict and carrying on inside the same transaction does not work either, because a write that throws inside your transaction marks it rollback-only, so your commit then fails with UnexpectedRollbackException. With Spring Retry you get the right shape for free, since retry advice sits outside transaction advice and each attempt therefore runs a fresh transaction. Put DataIntegrityViolationException in the retry predicate, because that is what Spring turns a MongoDB write conflict into. See Retry and Transactions for the details, and ADR 74 for the reasoning.
Backward-incompatible changes
These four changes affect a 0.30.0 caller. Each one is either a renamed module or a moved package, none of them changes stored data, and the org.occurrent.UpgradeToOccurrent_0_31 OpenRewrite recipe rewrites every affected reference for you. Start with the upgrade guide for the recipe setup.
| Change | What changed | How to upgrade |
|---|---|---|
| Annotation enums moved out of the annotations | ResumeBehavior, StartupMode and StartPosition each had an identical copy inside several annotations. They are now single top-level types in org.occurrent.annotation. @DcbSubscription’s DcbStartPosition becomes the shared StartPosition. @StreamSubscription keeps its own StartPosition, because its BEGINNING_OF_TIME constant genuinely differs. |
Run the recipe. It rewrites a nested reference such as Subscription.ResumeBehavior.DEFAULT to ResumeBehavior.DEFAULT. |
| Checkpoint-storage modules renamed | The four checkpoint-storage modules are renamed from -position-storage to -checkpoint-storage, so the module name matches the CheckpointStorage type it ships. That finishes the SubscriptionPosition to Checkpoint rename 0.30.0 applied to the types. The groupId, packages and classes are unchanged, so only the module name moves. |
Run the recipe. It rewrites the coordinates for Maven and Gradle, or remap them by hand from the table in the upgrade guide. |
| Autoconfigure module renamed and its types moved | OccurrentProperties and the seven other public types in org.occurrent.springboot.mongo.common move to org.occurrent.springboot.common, and the module is renamed from occurrent-mongodb-spring-boot-autoconfigure to occurrent-spring-boot-autoconfigure, because neither the code nor the name is MongoDB-specific any more. Your property keys are untouched, since the prefix is hard-coded, so every occurrent.* key and its IDE completion still work. |
Run the recipe. It rewrites the coordinate for Maven and Gradle and updates the imports. Most applications depend on the starter rather than on this module directly, and the starter’s own name is unchanged. |
EventMetadata moved |
EventMetadata moves from org.occurrent.dsl.subscription to org.occurrent.cloudevents (module cloudevents-extension), now that the saga, projection and view DSLs all fold with it. It is also rewritten from a Kotlin data class to a plain Java class, which drops its Kotlin-only members (reified get, operator get, copy). Those were essentially unused, and the typed accessors are unchanged in name and behavior. |
Run the recipe. It updates the import and every reference. |
Full details are in the changelog, and the documentation has been updated with the new features and examples.