Skip to content

Views

Layer 1: Domain events

Occurrent has a higher-level Projection DSL for maintaining views/projections, described further down. You don’t have to use it, though. At its simplest a view is just a subscription in which you create and store the view as you find fit. And even that doesn’t have to be difficult! Here’s a trivial example of a view that maintains the number of ended games. It does so by increasing the “numberOfEndedGames” field in an (imaginary) database for each “GameEnded” event that is written to the event store:

// An imaginary database API
Database someDatabase = ...
// Subscribe to all "GameEnded" events by starting a subscription named "my-view"
// and increase "numberOfEndedGames" for each ended game.
subscriptionModel.subscribe("my-view", filter(type("GameEnded")), cloudEvent -> someDatabase.inc("numberOfEndedGames"));

Where filter is imported from org.occurrent.subscription.StreamSubscriptionFilter and type is imported from org.occurrent.filter.Filter.

While this is a trivial example it shouldn’t be difficult to create a view that is backed by a JPA entity in a relational database based on a subscription.

When the fold gets more involved, or you want to unit-test it in isolation and reuse it across an asynchronous subscription, a synchronous write, and an on-demand query, use Occurrent’s View DSL (org.occurrent:occurrent-view-dsl). It is the read-side counterpart of a decider: a decider applies events to decide new ones, a View applies events to state you read. It is blocking-only, and it is the primitive the higher-level Projection DSL builds on.

So there are three levels for a read model, and you should pick the lowest one that does the job. A plain subscription that writes to your store, as at the top of this section, when the fold is trivial. The View DSL, when you want a fold you can unit-test on its own and store wherever you like. The Projection DSL, when you want the fold, the event types it handles, the view-instance id, and the delivery mode declared together in one place.

A View<S, E> is a pure fold, an initial state and an evolve that applies one event to state. It has no I/O, no storage, and no subscription, so it unit-tests with plain equality assertions, the same way a decider or a saga does. Here is a view that applies a person’s name events to their current name:

record NameState(String userId, String name) {}
View<@Nullable NameState, DomainEvent> view = View.create((state, event) -> switch (event) {
case NameDefined e -> new NameState(e.userId(), e.name());
case NameWasChanged e -> new NameState(state.userId(), e.name());
default -> state;
});
// Applying a list of events gives the current state, which is all a unit test needs
@Nullable NameState current = view.evolve(List.of(nameDefined, nameWasChanged));

Neither example passes an initial state, so the state starts out as null and its type is nullable, @Nullable NameState in Java and NameState? in Kotlin. When the state has a natural starting value, pass it with View.create(initialState, ...) or view(initialState) { }.

A view can also handle the delivering event’s metadata, its stream id and version, global position, and CloudEvent extensions, through the metadata-carrying evolve(state, metadata, event) (View.create(initialState, (state, metadata, event) -> ...) in Java, view(initialState) { state, metadata, event -> ... } in Kotlin). The event-only form applies the event with empty metadata. That lets a view key on the stream id or the global position without carrying either in the event payload. Metadata support was added in 0.31.0.

A View on its own does not know where its state lives or which instance an event updates. A MaterializedView<E> binds a View to a ViewStateRepository and a function that derives the view-instance id from the event, so a single update(event) loads the current state, applies the event, and saves it back. ViewStateRepository<S, ID> is just findById and save, so you can back it with any store by passing a pair of functions:

// A repository over a plain map. Back it with JPA, Mongo, or anything else in production.
Map<String, NameState> store = new ConcurrentHashMap<>();
ViewStateRepository<NameState, String> repository = ViewStateRepository.create(store::get, store::put);
// Key each instance by user id and keep it current one event at a time
MaterializedView<DomainEvent> names = MaterializedView.create(DomainEvent::userId, view, repository);
names.update(nameDefined);
names.update(nameWasChanged);

To key on something only the delivery carries rather than the event body, derive the id from metadata with the (metadata, event) form, for example MaterializedView.create((metadata, event) -> metadata.getStreamId(), view, repository).

On the Spring MongoDB stack, View.materialized(...) hands you a Mongo-backed MaterializedView in one call, over the same MongoOperations the rest of Occurrent uses. It stores each instance as a document keyed by the id you derive, retries an optimistic-locking clash with a backoff, and ignores a duplicate-key race by default. Point the blocking subscription DSL’s updateView at it and the view catches up and then follows the live stream like any other subscription:

val view: View<NameState?, DomainEvent> = view { state, event ->
when (event) {
is NameDefined -> NameState(event.userId(), event.name)
is NameWasChanged -> state!!.copy(name = event.name)
}
}
// Mongo-backed, keyed by user id
val names: MaterializedView<DomainEvent> = view.materialized(mongoOperations) { it.userId() }
// Keep it up to date from a subscription named "names"
subscriptions.updateView("names", names)
// Read a stored instance back
val current: NameState? = view.currentState(mongoOperations, userId)

The id function can take the event’s metadata as well, so a Mongo-backed view can be keyed by the stream id rather than by something in the payload:

val names: MaterializedView<DomainEvent> = view.materialized(mongoOperations) { metadata, _ -> metadata.streamId }

One rule to know about, because getting it wrong used to produce a view that quietly stayed empty. The document you store must carry the same id the function resolves, as its @Id. Reads look the document up by the resolved id, while writes let Spring Data take the document id from the object you save, so if the two disagree every update reads nothing back, rebuilds from the initial state, and writes a fresh document. Occurrent now fails with a message naming both ids instead of letting that happen. Types that Spring Data converts for you are fine, so a hex String resolved against an ObjectId id, or an Int against a Long, are not mismatches.