Getting started without Spring Boot
This page builds a small word guessing game with MongoDB and no framework. You create the event store, the converter that turns your domain events into CloudEvents, the ApplicationService that writes events, and a subscription that reacts to them. With Spring Boot, the Spring Boot starter wires all of this for you, see Getting started with Spring Boot.
Everything on this page is at layer 1. Your code works with your own domain events, and Occurrent stores them as CloudEvents.
1. Add the dependencies
Section titled “1. Add the dependencies”You need the event store, a converter, the application service, and three modules for subscriptions: one that reads MongoDB change streams, one that stores how far each subscription has come, and the subscription DSL.
<dependency> <groupId>org.occurrent</groupId> <artifactId>occurrent-eventstore-mongodb-native</artifactId> <version>0.34.0</version></dependency><dependency> <groupId>org.occurrent</groupId> <artifactId>occurrent-cloudevent-converter-jackson3</artifactId> <version>0.34.0</version></dependency><dependency> <groupId>org.occurrent</groupId> <artifactId>occurrent-application-service-blocking</artifactId> <version>0.34.0</version></dependency><dependency> <groupId>org.occurrent</groupId> <artifactId>occurrent-subscription-mongodb-native-blocking</artifactId> <version>0.34.0</version></dependency><dependency> <groupId>org.occurrent</groupId> <artifactId>occurrent-subscription-mongodb-native-blocking-checkpoint-storage</artifactId> <version>0.34.0</version></dependency><dependency> <groupId>org.occurrent</groupId> <artifactId>occurrent-subscription-durable-blocking</artifactId> <version>0.34.0</version></dependency><dependency> <groupId>org.occurrent</groupId> <artifactId>occurrent-subscription-dsl-blocking</artifactId> <version>0.34.0</version></dependency>implementation 'org.occurrent:occurrent-eventstore-mongodb-native:0.34.0'implementation 'org.occurrent:occurrent-cloudevent-converter-jackson3:0.34.0'implementation 'org.occurrent:occurrent-application-service-blocking:0.34.0'implementation 'org.occurrent:occurrent-subscription-mongodb-native-blocking:0.34.0'implementation 'org.occurrent:occurrent-subscription-mongodb-native-blocking-checkpoint-storage:0.34.0'implementation 'org.occurrent:occurrent-subscription-durable-blocking:0.34.0'implementation 'org.occurrent:occurrent-subscription-dsl-blocking:0.34.0'In Kotlin, also add tools.jackson.module:jackson-module-kotlin, so that Jackson can create your data classes.
2. Start MongoDB
Section titled “2. Start MongoDB”Subscriptions read MongoDB change streams, so MongoDB has to run as a replica set. A single node replica set is fine for development. With Docker:
docker run -d --name mongo -p 27017:27017 mongo:8 --replSet rs0docker exec mongo mongosh --eval 'rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })'3. Write the domain model
Section titled “3. Write the domain model”Your domain model has no dependency on Occurrent. The events are plain records, and the behavior is a function from the events that have happened so far to the new events:
public sealed interface GameEvent permits GameStarted, GuessMade {}
public record GameStarted(String wordToGuess) implements GameEvent {}
public record GuessMade(String word) implements GameEvent {}
public class Game {
public static List<GameEvent> start(List<GameEvent> history, String wordToGuess) { if (!history.isEmpty()) { throw new IllegalStateException("The game has already started"); } return List.of(new GameStarted(wordToGuess)); }
public static List<GameEvent> guess(List<GameEvent> history, String word) { if (history.isEmpty()) { throw new IllegalStateException("The game hasn't started"); } return List.of(new GuessMade(word)); }}sealed interface GameEvent
data class GameStarted(val wordToGuess: String) : GameEvent
data class GuessMade(val word: String) : GameEvent
object Game {
fun start(history: List<GameEvent>, wordToGuess: String): List<GameEvent> { check(history.isEmpty()) { "The game has already started" } return listOf(GameStarted(wordToGuess)) }
fun guess(history: List<GameEvent>, word: String): List<GameEvent> { check(history.isNotEmpty()) { "The game hasn't started" } return listOf(GuessMade(word)) }}Put all the events in the same package. The converter in step 5 relies on that to find the class of an event from its name.
4. Create the event store
Section titled “4. Create the event store”The MongoDB event store takes a MongoClient, the database, the collection to store events in, and how to store the time of each event:
MongoClient mongoClient = MongoClients.create("mongodb://localhost:27017");MongoDatabase database = mongoClient.getDatabase("game");TimeRepresentation timeRepresentation = TimeRepresentation.RFC_3339_STRING;
MongoEventStore eventStore = new MongoEventStore(mongoClient, database, database.getCollection("events"), new EventStoreConfig(timeRepresentation));val mongoClient = MongoClients.create("mongodb://localhost:27017")val database = mongoClient.getDatabase("game")val timeRepresentation = TimeRepresentation.RFC_3339_STRING
val eventStore = MongoEventStore(mongoClient, database, database.getCollection("events"), EventStoreConfig(timeRepresentation))RFC_3339_STRING stores the time as a string, with all its precision. The subscription in step 7 reads the same collection, so give it the same TimeRepresentation. Choosing an EventStore compares MongoDB with the other stores.
5. Create the converter
Section titled “5. Create the converter”A CloudEventConverter maps your domain events to CloudEvents and back. The Jackson converter is a good default. It needs a Jackson ObjectMapper and a source, a URI that says which part of your system the events come from:
CloudEventConverter<GameEvent> cloudEventConverter = new JacksonCloudEventConverter.Builder<GameEvent>(JsonMapper.builder().build(), URI.create("urn:mycompany:mygame:game")) .typeMapper(ReflectionCloudEventTypeMapper.simple(GameEvent.class)) .build();val cloudEventConverter = JacksonCloudEventConverter.Builder<GameEvent>(jacksonObjectMapper(), URI.create("urn:mycompany:mygame:game")) .typeMapper(ReflectionCloudEventTypeMapper.simple(GameEvent::class.java)) .build()The type mapper decides the CloudEvent type of each event. ReflectionCloudEventTypeMapper.simple uses the class name without the package, so a GuessMade event gets the type GuessMade. Without a type mapper the converter uses the fully qualified class name. The type ends up in every stored event, so decide on it before you go to production. CloudEvent conversion describes the other choices.
6. Write events
Section titled “6. Write events”Create a GenericApplicationService and call your domain functions through it. It reads the events of the stream, runs your function, and appends the new events under optimistic concurrency:
ApplicationService<GameEvent> applicationService = new GenericApplicationService<>(eventStore, cloudEventConverter);
UUID gameId = UUID.randomUUID();applicationService.execute(gameId, history -> Game.start(history, "occurrent"));applicationService.execute(gameId, history -> Game.guess(history, "eventsourcing"));val applicationService: ApplicationService<GameEvent> = GenericApplicationService(eventStore, cloudEventConverter)
val gameId = UUID.randomUUID()applicationService.execute(gameId) { history -> Game.start(history, "occurrent") }applicationService.execute(gameId) { history -> Game.guess(history, "eventsourcing") }Each game is its own stream, with the game id as stream id. If another thread appends to the same game between the read and the write, the application service retries the call with the new history.
7. React to events
Section titled “7. React to events”A subscription is made of three parts. NativeMongoSubscriptionModel reads new events from the change stream of the events collection. NativeMongoCheckpointStorage keeps the position each subscription has reached in a collection of its own. DurableSubscriptionModel puts the two together, so a subscription continues where it left off after a restart. On top of that, Subscriptions gives you your domain events instead of CloudEvents:
NativeMongoSubscriptionModel changeStreamSubscriptionModel = new NativeMongoSubscriptionModel(database, "events", timeRepresentation, Executors.newCachedThreadPool());NativeMongoCheckpointStorage checkpointStorage = new NativeMongoCheckpointStorage(database, "checkpoints");DurableSubscriptionModel subscriptionModel = new DurableSubscriptionModel(changeStreamSubscriptionModel, checkpointStorage);
Subscriptions<GameEvent> subscriptions = new Subscriptions<>(subscriptionModel, cloudEventConverter);subscriptions.subscribe("logGuesses", GuessMade.class, e -> System.out.printf("Someone guessed %s%n", e.word()));val changeStreamSubscriptionModel = NativeMongoSubscriptionModel(database, "events", timeRepresentation, Executors.newCachedThreadPool())val checkpointStorage = NativeMongoCheckpointStorage(database, "checkpoints")val subscriptionModel = DurableSubscriptionModel(changeStreamSubscriptionModel, checkpointStorage)
subscriptions(subscriptionModel, cloudEventConverter) { subscribe<GuessMade>("logGuesses") { e -> println("Someone guessed ${e.word}") }}Start the subscriptions before you write events. The first time a subscription starts, it begins at the newest event, and it only receives events written after that. To also process the events that are already stored, use a catch-up subscription.
The subscription id, logGuesses here, is the key its position is stored under, so keep it the same between releases. Call subscriptionModel.shutdown() when your application stops.
Every instance of your application that runs this code receives every event. To have only one instance receive each event, use competing consumers.
Trying it without MongoDB
Section titled “Trying it without MongoDB”For tests and experiments, the in-memory event store needs no database. Add occurrent-eventstore-inmemory and occurrent-subscription-inmemory, and replace steps 4 and 7 with this:
InMemorySubscriptionModel subscriptionModel = new InMemorySubscriptionModel();InMemoryEventStore eventStore = new InMemoryEventStore(subscriptionModel);
Subscriptions<GameEvent> subscriptions = new Subscriptions<>(subscriptionModel, cloudEventConverter);subscriptions.subscribe("logGuesses", GuessMade.class, e -> System.out.printf("Someone guessed %s%n", e.word()));val subscriptionModel = InMemorySubscriptionModel()val eventStore = InMemoryEventStore(subscriptionModel)
subscriptions(subscriptionModel, cloudEventConverter) { subscribe<GuessMade>("logGuesses") { e -> println("Someone guessed ${e.word}") }}Everything is gone when the JVM stops.
Where to go next
Section titled “Where to go next”- Projections maintain a read model from the events, and sagas run a process that reacts to events over time.
- DomainEventQueries query the stored events directly.
- Blocking subscription models lists the subscription models for each store, and blocking subscriptions lists the other places to store checkpoints.
- Testing shows how to test projections and sagas.
- You can always drop down to layer 0 and use the event store and subscriptions with CloudEvents directly. It’s worth reading about CloudEvents and their specification either way, because that is how your events are stored.
- There’s also a reactive Spring Boot starter, if you use Spring Boot with Project Reactor.
