Transactions and Unit of Work
A unit of work groups event appends in the client and commits them as one append batch. Use it when one logical operation must produce multiple events and the operation should have one commit point, one correlation id, and one success or failure outcome.
The transaction is still an event append operation. Chronicle validates constraints and concurrency when the unit of work commits, then persists the accepted events atomically for each committed batch.
When to Use a Unit of Work
Section titled “When to Use a Unit of Work”Use a unit of work when:
- One command or workflow emits more than one event.
- The events should share the same correlation id.
- The operation should not send partial appends before the workflow is ready.
- The appends should be committed or rejected together.
Do not use a unit of work to hide modeling problems. If two facts can happen independently, append them independently. If one fact depends on another, model that invariant with constraints, concurrency, or the command decision that creates the events.
Commit Transactional Appends
Section titled “Commit Transactional Appends”Create a unit of work, append through the transactional event sequence, and commit when every event has been staged.
using Cratis.Chronicle;using Cratis.Chronicle.Events;using Cratis.Execution;
public static class TransactionalOrderWorkflow{ public static async Task CommitOrder(IEventStore store) { var unitOfWork = store.UnitOfWorkManager.Begin(CorrelationId.New());
try { await store.EventLog.Transactional.Append( "order-123", new TransactionalOrderPlaced("order-123", 99.95m));
await store.EventLog.Transactional.Append( "inventory-widget", new TransactionalInventoryReserved("widget", 1));
await unitOfWork.Commit();
// Commit reports a rejected batch through the unit of work; it does not throw. if (!unitOfWork.IsSuccess) { throw new OrderWasNotCommitted(unitOfWork.GetConstraintViolations(), unitOfWork.GetAppendErrors()); } } catch { // Commit completes the unit of work even when the batch is rejected, and a completed // unit of work cannot be rolled back. Only roll back what was never committed. if (!unitOfWork.IsCompleted) { await unitOfWork.Rollback(); }
throw; } }}
[EventType]public record TransactionalOrderPlaced(string OrderId, decimal TotalAmount);
[EventType]public record TransactionalInventoryReserved(string Sku, int Quantity);
public class OrderWasNotCommitted(IEnumerable<object> violations, IEnumerable<object> errors) : Exception($"The order was not committed: {string.Join("; ", violations.Concat(errors))}");import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.events.EventType
@EventTypedata class TransactionalOrderPlaced(val orderId: String, val totalAmount: Double)@EventTypedata class TransactionalInventoryReserved(val sku: String, val quantity: Int)
suspend fun commitOrder(store: IEventStore) { val unitOfWork = store.unitOfWorkManager.begin()
try { store.eventLog.transactional.append( "order-123", TransactionalOrderPlaced("order-123", 99.95) )
store.eventLog.transactional.append( "inventory-widget", TransactionalInventoryReserved("widget", 1) )
// Consecutive events for the same event source and options append atomically; // the unit of work is not atomic across event sources. unitOfWork.commit() check(unitOfWork.isSuccess) { "Commit failed: ${unitOfWork.getConstraintViolations()}, ${unitOfWork.getConcurrencyViolations()}, ${unitOfWork.getAppendErrors()}" } } catch (exception: Exception) { if (!unitOfWork.isCompleted) unitOfWork.rollback() throw exception }}import io.cratis.chronicle.IEventStore;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.java.BlockingEventStore;
@EventTyperecord TransactionalOrderPlaced(String orderId, double totalAmount) {}@EventTyperecord TransactionalInventoryReserved(String sku, int quantity) {}
class TransactionalOrderWorkflow { void commitOrder(IEventStore store) { var eventStore = new BlockingEventStore(store);
// Rolls back on the way out unless it was committed, so a throw needs no catch. try (var unitOfWork = eventStore.beginUnitOfWork()) { eventStore.getTransactional().append( "order-123", new TransactionalOrderPlaced("order-123", 99.95));
eventStore.getTransactional().append( "inventory-widget", new TransactionalInventoryReserved("widget", 1));
// Consecutive events for the same event source and options append atomically; // the unit of work is not atomic across event sources. unitOfWork.commit(); if (!unitOfWork.unwrap().isSuccess()) { throw new IllegalStateException("Commit failed: " + unitOfWork.unwrap().getConstraintViolations() + unitOfWork.unwrap().getConcurrencyViolations() + unitOfWork.unwrap().getAppendErrors()); } } }}defmodule MyApp.Events.TransactionalOrderPlaced do use Chronicle.Events.EventType, id: "TransactionalOrderPlaced"
defstruct [:order_id, :total_amount]end
defmodule MyApp.Events.TransactionalInventoryReserved do use Chronicle.Events.EventType, id: "TransactionalInventoryReserved"
defstruct [:sku, :quantity]end
defmodule MyApp.TransactionalOrderWorkflow do alias Chronicle.Transactions.UnitOfWork alias MyApp.Events.{TransactionalInventoryReserved, TransactionalOrderPlaced}
def commit_order do unit_of_work = UnitOfWork.begin()
try do :ok = Chronicle.append("order-123", %TransactionalOrderPlaced{ order_id: "order-123", total_amount: 99.95 })
:ok = Chronicle.append("inventory-widget", %TransactionalInventoryReserved{ sku: "widget", quantity: 1 })
:ok = UnitOfWork.commit(unit_of_work) rescue exception -> UnitOfWork.rollback(unit_of_work) reraise exception, __STACKTRACE__ end endendimport { IEventStore, eventType } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class TransactionalOrderPlaced { @field(String) readonly orderId: string; @field(Number) readonly totalAmount: number;
constructor(orderId: string, totalAmount: number) { this.orderId = orderId; this.totalAmount = totalAmount; }}
@eventType()class TransactionalInventoryReserved { @field(String) readonly sku: string; @field(Number) readonly quantity: number;
constructor(sku: string, quantity: number) { this.sku = sku; this.quantity = quantity; }}
async function commitOrder(store: IEventStore): Promise<void> { const unitOfWork = store.unitOfWorkManager.begin();
try { await store.eventLog.transactional.append( 'order-123', new TransactionalOrderPlaced('order-123', 99.95) );
await store.eventLog.transactional.append( 'inventory-widget', new TransactionalInventoryReserved('widget', 1) );
await unitOfWork.commit(); } catch (error) { await unitOfWork.rollback(); throw error; }}Commit an ordered batch with exact concurrency
Section titled “Commit an ordered batch with exact concurrency”When a decision reads several streams, capture the exact revision it used and stage the resulting events with that scope. AddEvents materializes the events and scopes immediately, preserves cross-stream order, and still waits for the unit of work to commit before sending anything to Chronicle.
using Cratis.Chronicle;using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.EventSequences.Concurrency;using Cratis.Execution;
public static class TransactionalTransferWorkflow{ public static async Task<bool> TryCommitTransfer( IEventStore store, EventSequenceNumber expectedAuthorizationRevision) { using var unitOfWork = store.UnitOfWorkManager.Begin(CorrelationId.New()); EventSourceId authorizationScopeLabel = "authorization-history"; var authorizationScope = new ConcurrencyScope( expectedAuthorizationRevision, EventStreamType: "authorization");
unitOfWork.AddEvents( EventSequenceId.Log, [ new EventForEventSourceId( "account-from", new TransferDebited(100m)), new EventForEventSourceId( "account-to", new TransferCredited(100m)) ], [new(authorizationScopeLabel, authorizationScope)]);
await unitOfWork.Commit(); return unitOfWork.IsSuccess; }}
[EventType]public record TransferDebited(decimal Amount);
[EventType]public record TransferCredited(decimal Amount);Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.import { eventType, IEventStore } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class TransferDebited { @field(Number) readonly amount: number;
constructor(amount: number) { this.amount = amount; }}
@eventType()class TransferCredited { @field(Number) readonly amount: number;
constructor(amount: number) { this.amount = amount; }}
class TransactionalTransferWorkflow { constructor(private readonly store: IEventStore) {}
async tryCommitTransfer(expectedAuthorizationRevision: bigint): Promise<boolean> { // Sent immediately as one ordered, atomic batch; a TypeScript unit of work only scopes the event sources it appends to. const results = await this.store.eventLog.appendMany([ { eventSourceId: 'account-from', event: new TransferDebited(100) }, { eventSourceId: 'account-to', event: new TransferCredited(100) } ], { concurrencyScopes: { 'authorization-history': { sequenceNumber: expectedAuthorizationRevision, eventStreamType: 'authorization' } } }); return results.length === 2 && results.every(result => result.isSuccess); }}A concurrency-scope key can be an independent label when the scope does not narrow by event-source ID. It does not need to match either event target, so one decision-wide stream-, type-, or event-type-scoped revision can protect the entire ordered batch. If a scope does narrow by event-source ID, its key must be that event-source ID.
A scope list may repeat a label for different stream predicates, in one AddEvents call or across several calls. The unit retains every guard and commits them together; conflicting expected positions for the same ordered predicate are refused. This requires a kernel advertising concurrency-scope-conjunctions. See Several guards for one source.
Every event target and scope label must contain a non-whitespace value; blank labels cannot survive transport and are rejected during enrollment.
Chronicle resolves the configured concurrency strategy for an event target that has no supplied scope or carries ConcurrencyScope.NotSet. An independent non-target label has no event target from which to resolve a strategy, so Chronicle rejects NotSet and incomplete scopes for those labels. Give an independent label a concrete exact scope or ConcurrencyScope.None.
AddEvents snapshots the event list, scope collection, and every scope’s nested event-type filter before it binds or stages anything in the unit of work. When ordered enrollment first participates in a unit of work, Chronicle also snapshots effective scopes from preceding AddEvent calls; later AddEvent scopes are snapshotted as they are enrolled. Later mutations to caller-owned collections therefore cannot change the eventual commit, and a lazy collection failure is reported during enrollment.
Dispose the unit of work even when enrollment validation fails, and inspect IsSuccess after commit. A completed Commit can still report a constraint or concurrency rejection rather than a successful append.
Consecutive AddEvent calls keep their existing source-grouped order. Each AddEvents call creates an exact-order segment between those legacy segments. Commit flattens the segments in enrollment-call order and sends all of them through one atomic append.
Legacy-only AddEvent calls retain their historical event-sequence behavior. Once AddEvents participates, every event in the unit of work must name the same event sequence; a mismatch is rejected before the ordered batch is staged.
Contribute an enrollment scope (.NET)
Section titled “Contribute an enrollment scope (.NET)”Attach an IUnitOfWorkEnrollmentContributor with IUnitOfWork.AddEnrollmentContributor when a command must require a concurrency scope for the events it stages. Keep the returned IDisposable handle for that command invocation and dispose it when the command finishes. Each registration is independent, including nested commands sharing a unit of work. Alternate unit-of-work implementations that do not support contributors throw UnitOfWorkEnrollmentContributorsNotSupported when a contributor is attached.
Chronicle calls GetRequiredScope(UnitOfWorkEnrollment) before staging events, in registration order. The enrollment describes the event sequence, event source, caller-supplied scope (NotSet if omitted), and events for that source in this call. Return ConcurrencyScope.NotSet when no requirement applies. AddEvents consults each distinct event-target predicate, including targets without a supplied scope entry; scope-only entries are not consulted. Several supplied guards for one target produce separate enrollments, so each notification retains its own effective scope.
A required scope replaces NotSet, but cannot replace a conflicting explicit scope. Event-type filters compare as sets. Null filters and the All, Default, and unspecified source-type sentinels compare as equivalent non-narrowing filters. Conflicts throw ConflictingEnrollmentScope before anything from that enrollment call is staged. Required scopes remain on the unit after the contributor is removed: later legacy appends, ordered batches, and scope-only entries cannot drop or contradict the requirement. Once a requirement is enrolled, every event in the unit must target the same event sequence, including events staged before the requirement. A required scope cannot share its event source with an enrolled decision read.
To classify a ConflictingEnrollmentScope, inspect its get-only typed properties: EventSourceId, Effective and Required (ConcurrencyScope), and EventSequenceId (the attempted sequence, supplied by Chronicle’s unit of work; null for the original three-argument constructor). Its message is redacted by design: it names differing scope dimensions without including identifiers or scope values. You can log or surface the message without exposing those values; treat the typed properties as sensitive diagnostic data.
Chronicle calls Enrolled(enrollment, effectiveScope) only after staging succeeds. Use it to record provenance for the accepted enrollment; its default implementation does nothing. Contributors must not enroll events or reads, change registrations, commit, roll back, or dispose the unit during consultation or notification; such mutations throw UnitOfWorkMutatedDuringEnrollment. A consultation exception stages nothing. A notification exception propagates and marks the unit failed: subsequent commit throws UnitOfWorkEnrollmentNotificationFailed without appending anything. Roll back or dispose that unit.
Atomicity is per call to AddEvent, AddEventWithNamedTags, or AddEvents, not per transactional AppendMany invocation: transactional methods that loop single-event enrollments can stage earlier events before a later enrollment fails. The transaction owner must roll back the failed operation. Contributors are not consulted for decision reads or at commit, and units without contributors retain their existing scope behavior.
Staging after completion begins
Section titled “Staging after completion begins”Stage all events before committing or rolling back. Once commit begins (even while its append is pending), after a failed commit, after rollback, or after disposal, newly staged events cannot be included in that unit’s commit. The default UnitOfWorkLifecyclePolicy.Compatibility preserves existing AddEvent and AddEvents behavior for units without decision reads: late calls still stage their events, but Chronicle emits an error-level log for each attempt, with the unit’s correlation id and no event content. Units with decision reads continue to reject late staging.
Opt in to UnitOfWorkLifecyclePolicy.Strict through ChronicleOptions.UnitOfWorkLifecyclePolicy (also inherited by ChronicleClientOptions and ChronicleAspNetCoreOptions). In strict mode, both enrollment methods throw UnitOfWorkIsCompleted before inspecting or changing their input or the staged events. This applies to units created by the client’s UnitOfWorkManager, including those used by ASP.NET Core middleware. For units constructed directly, pass the policy to the UnitOfWork constructor; custom IUnitOfWork implementations are unaffected. Strict will become the default in the next major release.
While a commit is in flight, disposing the unit does nothing in either mode: it does not roll back, discard staged events, or call the completion callback early. The callback runs once when the pending commit finishes. This differs from the former Compatibility behavior; it prevents disposal from replacing an in-flight commit’s failure. In Compatibility mode, public Rollback() during a pending commit still clears staged events for units without a claimed and enrolled decision read, but cannot cancel events already submitted to Chronicle. A claimed unit with an enrolled decision read refuses public rollback during a pending commit and retains its staged events. In Strict mode, Rollback() refuses any pending commit without changing staged events. These pending-commit refusals throw UnitOfWorkIsCompleting; see decision-consistent reads for ownership rules.
In ASP.NET Core
Section titled “In ASP.NET Core”AddCratisChronicle registers middleware that begins a unit of work for every request and commits it after the endpoint returns. Three things follow:
- Stage with
Transactional.Append. A plaineventLog.Appendis written immediately and takes no part in the unit of work. It is not covered by decision reads enrolled in the unit. - Commit early to report the outcome. The middleware commits after the response is written, so a constraint or concurrency rejection cannot change that response, and nothing tells the caller. Call
IUnitOfWorkCompletionFeature.CommitAsync()before you respond and checkIsSuccessandGetDecisionConflicts(). The middleware does not commit a completed unit again. - Check the result. A failed commit does not throw.
Scenarios shows an endpoint with decision reads, and Decision reads lists the ownership rules.
Roll Back
Section titled “Roll Back”Rollback before commit begins discards the events staged in the unit of work. Nothing is sent to Chronicle for those staged events.