Outbox and Inbox
The outbox/inbox pattern is a well-established way to integrate distributed systems reliably. Chronicle formalizes this pattern as first-class, kernel-managed event sequences.
The Outbox
Section titled “The Outbox”Every event store in Chronicle has a well-known outbox sequence (EventSequenceId.Outbox). Events appended to the outbox are visible to any other event store that has set up a subscription.
The outbox exists by convention — you do not need to create it. Append events to it the same way you append to any other event sequence.
The Inbox
Section titled “The Inbox”When event store A subscribes to event store B, Chronicle automatically manages an inbox sequence on A for events forwarded from B. The well-known inbox sequence is EventSequenceId.Inbox, and the per-source inbox identifier is derived from the source event store name using EventSequenceId.InboxPrefix:
using Cratis.Chronicle.EventSequences;
public static class SubscriptionsOutboxInboxId{ public static EventSequenceId Resolve() { var inboxId = new EventSequenceId($"{EventSequenceId.InboxPrefix}source-event-store"); // Resolves to: "inbox-source-event-store" return inboxId; }}import io.cratis.chronicle.eventSequences.EventSequenceId
object SubscriptionsOutboxInboxId { fun resolve(): EventSequenceId { val inboxId = EventSequenceId("inbox-source-event-store") // Resolves to: "inbox-source-event-store" return inboxId }}// EventSequenceId is a Kotlin value class, so its constructor is mangled for Java - the id is// just this string wherever a Java API asks for one.class SubscriptionsOutboxInboxId { static String resolve() { String inboxId = "inbox-source-event-store"; // Resolves to: "inbox-source-event-store" return inboxId; }}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.EventSequenceId.InboxPrefix is a small C#-only convenience constant — the other clients don’t have a dedicated helper for it, since the inbox sequence id itself ("inbox-<source-event-store>") is just a plain string wherever event sequence ids are used.
You never write to an inbox directly. Chronicle forwards events from the source outbox to the target inbox as they are appended.
Forwarding now preserves the source event’s append tags as well as its event-source and stream-route metadata. Tag-filtered observers on the inbox can select these events. Events forwarded by earlier releases that dropped tags remain untagged; upgrading does not backfill them.
Lifecycle
Section titled “Lifecycle”Subscriptions are kernel-managed and persistent. Once registered, a subscription:
- Survives client disconnections
- Is restarted automatically when the Kernel starts
- Is tracked per event store, not per client session
This means you can call Subscribe at application startup without worrying about duplicate registration — if the subscription already exists with the same identifier, no new event is appended.
Event Flow
Section titled “Event Flow”Source event store Target event store───────────────── ───────────────── Outbox Inbox ┌─────────┐ [subscription] ┌─────────┐ │ event A │ ─────────────────▶ │ event A │ │ event B │ ─────────────────▶ │ event B │ └─────────┘ └─────────┘The EventStoreSubscriptionObserverSubscriber grain on the Kernel side observes the source outbox and appends forwarded events to the corresponding target inbox.
Reactors, Projections, and Reducers on the Inbox
Section titled “Reactors, Projections, and Reducers on the Inbox”Client-side observers (reactors, projections, reducers) can target EventSequenceId.Inbox to process all events arriving from subscribed sources. The client SDK automatically routes inbox-targeted observers to the correct per-source inbox sequence.
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;
[EventType]public record SubscriptionsOutboxInboxOrderPlaced(Guid OrderId);
public class SubscriptionsOutboxInboxIncomingOrdersReactor : IReactor{ public Task OrderPlaced(SubscriptionsOutboxInboxOrderPlaced @event, EventContext context) { // Handles OrderPlaced events from any subscribed source event store return ProcessAsync(@event.OrderId); }
Task ProcessAsync(Guid orderId) => Task.CompletedTask;}import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reactor
@EventTypedata class SubscriptionsOutboxInboxOrderPlaced(val orderId: String)
@Reactorclass SubscriptionsOutboxInboxIncomingOrdersReactor { fun orderPlaced(event: SubscriptionsOutboxInboxOrderPlaced, context: EventContext) { // Handles OrderPlaced events from any subscribed source event store process(event.orderId) }
private fun process(orderId: String) {}}import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reactor;
@EventTyperecord SubscriptionsOutboxInboxOrderPlaced(String orderId) {}
@Reactorclass SubscriptionsOutboxInboxIncomingOrdersReactor { void orderPlaced(SubscriptionsOutboxInboxOrderPlaced event, EventContext context) { // Handles OrderPlaced events from any subscribed source event store process(event.orderId()); }
private void process(String orderId) {}}Elixir does not support this workflow yet.import { eventType, onceOnly, reactor } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class SubscriptionsOutboxInboxOrderPlaced { @field(String) readonly orderId: string;
constructor(orderId: string) { this.orderId = orderId; }}
@reactor()class SubscriptionsOutboxInboxIncomingOrdersReactor { // Method name must be the exact camelCase of the event's class name - // Chronicle discovers handlers by name, not by parameter type. @onceOnly() async subscriptionsOutboxInboxOrderPlaced(event: SubscriptionsOutboxInboxOrderPlaced): Promise<void> { // Handles OrderPlaced events from any subscribed source event store await this.process(event.orderId); }
private async process(orderId: string): Promise<void> {}}