Skip to content

Choosing an event store

There are currently two different datastores to choose from, MongoDB and In-Memory.

Uses MongoDB, version 4.2 or above, as the underlying datastore for the CloudEvents. All implementations use transactions to guarantee consistent writes (see WriteCondition). Each EventStore will automatically create a few indexes on startup to allow for fast consistent writes, optimistic concurrency control and to avoid duplicated events. These indexes can also be used in queries against the EventStore (see EventStoreQueries).

There are three different MongoDB EventStore implementations to choose from:

All MongoDB EventStore implementations try to stay as close as possible to the CloudEvents specification even in the persistence layer. Occurrent, by default, automatically adds a custom “Occurrent extension” to each cloud event that is written to an EventStore. The Occurrent CloudEvent Extension consists of these attributes:

Attribute NameTypeDescription
streamid        String        An id that uniquely identifies a particular event stream. It’s used to determine which events belong to which stream.
streamversionLongThe id of the stream version for a particular event. It’s used for optimistic concurrency control.

A json schema describing a complete Occurrent CloudEvent, as it will be persisted to a MongoDB collection, can be found here (a “raw” cloud event json schema can be found here for comparison).

Note that MongoDB will automatically add an _id field (which is not used by Occurrent). The reason why the CloudEvent id attribute is not stored as _id in MongoDB is that the id of a CloudEvent is not globally unique! The combination of id and source is a globally unique CloudEvent. Note also that _id will not be included when the CloudEvent is read from an EventStore.

Here’s an example of what you can expect to see in the “events” collection when storing events in an EventStore backed by MongoDB (given that TimeRepresentation is set to DATE):

{
"_id" : ObjectId("5f4112a348b8da5305e41f57"),
"specversion" : "1.0",
"id" : "bdb8481f-9e8e-443b-80a4-5ef787f0f227",
"source" : "urn:occurrent:domain:numberguessinggame",
"type" : "NumberGuessingGameWasStarted",
"subject" : "a1fc6ba1-7cd4-45cf-8dcc-b357fe23956d",
"time" : ISODate("2020-08-22T14:42:11.712Z"),
"data" : {
"secretNumberToGuess" : 8,
"startedBy" : "003ab97b-df79-4bf1-8c0c-08a5dd3701cf",
"maxNumberOfGuesses" : 5
},
"streamid" : "a1fc6ba1-7cd4-45cf-8dcc-b357fe23956d",
"streamversion" : NumberLong(1)
}
{
"_id" : ObjectId("5f4112a548b8da5305e41f58"),
"specversion" : "1.0",
"id" : "c1bfc3a5-1716-43ae-88a6-297189b1b5c7",
"source" : "urn:occurrent:domain:numberguessinggame",
"type" : "PlayerGuessedANumberThatWasTooSmall",
"subject" : "a1fc6ba1-7cd4-45cf-8dcc-b357fe23956d",
"time" : ISODate("2020-08-22T14:42:13.336Z"),
"data" : {
"guessedNumber" : 1,
"playerId" : "003ab97b-df79-4bf1-8c0c-08a5dd3701cf"
},
"streamid" : "a1fc6ba1-7cd4-45cf-8dcc-b357fe23956d",
"streamversion" : NumberLong(2)
}

The CloudEvents specification says that the time attribute, if present, must adhere to the RFC 3339 specification. To accommodate this in MongoDB, the time attribute must be persisted as a String. That by itself is not a problem. It only becomes one when you make time-based queries on the events persisted to a MongoDB-backed EventStore (using the EventStoreQueries interface), because MongoDB then compares those strings character by character rather than as points in time. What we would like to do is to persist the time attribute as a Date in MongoDB, but MongoDB internally represents a Date with only millisecond resolution (see here) and then the CloudEvent cannot be compliant with the RFC 3339 specification in all circumstances.

Occurrent makes the string comparison behave for the common case by writing every time in one canonical shape, always with seconds and always with nine fractional digits, for example 2026-07-28T12:00:00.000000000Z. Because every stored value then has the same width, the character-by-character comparison follows chronological order. It has two limits, and both are covered under time queries with RFC_3339_STRING below. Rationale in ADR 79.

Because of the reasons described above, users of a MongoDB-backed EventStore implementation, must decide how the time attribute is to be represented in MongoDB when instantiating an EventStore implementation. This is done by passing a value from the org.occurrent.mongodb.timerepresentation.TimeRepresentation enum to an EventStoreConfig object that is then passed to the EventStore implementation. TimeRepresentation has these values:

ValueDescription
RFC_3339_STRING    Persist the time attribute as an RFC 3339 string. This string is able to represent both nanoseconds and a timezone so this is recommended for apps that need to store this information or if you are uncertain of whether this is required in the future. Time-based queries work, with the two caveats described in time queries with RFC_3339_STRING.
DATEPersist the time attribute as a MongoDB Date. The benefit of using this approach is that you can do range queries etc on the “time” field on the cloud event. This can be really useful for certain types on analytics or projections (such as show the 10 latest number of started games) without writing any custom code.

Note that if you choose to go with RFC_3339_STRING you always have the option of adding a custom attribute, named for example “date”, in which you represent the “time” attribute as a Date when writing the events to an EventStore. This way you have the ability to use the “time” attribute to reconstruct the CloudEvent time attribute exactly as well as the ability to do custom time-queries on the “date” attribute.

Time-based queries through the EventStoreQueries interface work under RFC_3339_STRING, because Occurrent stores every time in the one canonical shape described above and renders a query’s instant the same way. An exact match finds the event written at that instant, and a range condition orders correctly.

There are two limits.

Ordering relies on every stored value sharing the same UTC offset. 2026-07-28T14:00:00.000000000+02:00 and 2026-07-28T12:00:00.000000000Z are the same instant, but as strings they do not sort the same way, so a collection whose events carry mixed offsets can order them wrongly. Occurrent deliberately does not normalise the offset away, because keeping the timezone is the reason to pick RFC_3339_STRING in the first place. If your events span several offsets and you need range queries over them, use DATE, or keep a separate custom attribute as described above.

Events written by an older version of Occurrent were stored in a shorter shape, one that omitted trailing zeroes. An exact filter, Filter.time(instant) built from eq, in, or ne, still matches such an event, because Occurrent checks it against both the canonical shape and the legacy shape a value could have been written with. A range filter (lt, gt, lte, gte) is not extended this way, since a legacy and a canonical value do not reliably sort against each other, so a range boundary that lands on a pre-upgrade event can still miss it. Newly written events are unaffected either way.

If the range-filter gap matters to you, rewrite the time field of existing documents into one fixed shape once. Run this against your event collection, with the server on MongoDB 4.2 or later so $dateToString is available in an aggregation pipeline update, and take a backup first:

db.events.updateMany(
{
$and: [
{ time: { $type: "string" } },
{ time: { $not: /\.\d{4,}/ } }
]
},
[
{
$set: {
time: {
$concat: [
{
$dateToString: {
date: { $dateFromString: { dateString: "$time" } },
format: "%Y-%m-%dT%H:%M:%S.%L"
}
},
"000000Z"
]
}
}
}
]
)

The match stage skips a value whose fractional part already has more than three digits. $dateFromString parses into a BSON date, which only holds milliseconds, so touching a value that already carries more precision than that would throw the extra digits away. That protects two kinds of document from the same mistake. One is an event whose original timestamp genuinely had sub-millisecond precision. The other is an event that 0.32.0 already wrote in the canonical nine-digit shape, which this script must not touch a second time either.

For an event the script does rewrite, it pads the three digits $dateToString produces out to nine with zeroes, so the result has the canonical fixed width and sorts correctly against the values 0.32.0 writes for new events. Those zeroes are filler, not real digits, so a rewritten value equals the canonical shape a filter checks only when the original event had no sub-millisecond precision to lose in the first place.

An event the script skips keeps the shape it already had. An equality filter still matches it exactly as it did before you ran anything, through the legacy shape described above, because its stored value never changed. What stays open for it is the range-query gap. If you want that fixed too, rewrite it in application code instead, reading it with OffsetDateTime.parse and writing it back with nine real fractional digits, since only that route can supply digits $dateFromString would otherwise discard.

Important: There’s yet another option! If you don’t need nanotime precision (i.e you’re fine with millisecond precision) and you’re OK with always representing the “time” attribute in UTC, then you can use TimeRepresentation.DATE without loss of precision! This is why, if DATE is configured for the EventStore, Occurrent will refuse to store a CloudEvent that specifies nanotime and is not defined in UTC (so that there won’t be any surprises). I.e. using DATE and then doing this will throw an IllegalArgumentException:

var cloudEvent = new CloudEventBuilder().time(OffsetDateTime.now()). .. .build();
// Will throw exception since OffsetDateTime.now() will include nanoseconds by default in Java 9+
eventStore.write(List.of(cloudEvent));

Instead, you need to remove nano seconds do like this explicitly:

// Remove millis and make sure to use UTC as timezone
var now = OffsetDateTime.now().truncatedTo(ChronoUnit.MILLIS).withOffsetSameInstant(ZoneOffset.UTC);
var cloudEvent = new CloudEventBuilder().time(now). .. .build();
// Now you can write the cloud event

For more thoughts on this, refer to the architecture decision record on time representation in MongoDB.