Skip to content

Getting started without Spring Boot

Plain Java · Modules: occurrent-eventstore-mongodb-native, occurrent-cloudevent-converter-jackson3, occurrent-application-service-blocking, occurrent-subscription-dsl-blocking

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.

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>

In Kotlin, also add tools.jackson.module:jackson-module-kotlin, so that Jackson can create your data classes.

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:

Terminal window
docker run -d --name mongo -p 27017:27017 mongo:8 --replSet rs0
docker exec mongo mongosh --eval 'rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })'

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));
}
}

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.

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));

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.

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();

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.

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"));

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.

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()));

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.

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()));

Everything is gone when the JVM stops.