Projections
A Projection is deriving state by replaying a sequence of relevant Events. In other words, it's reading and transforming Events into a model built for a specific need.
The result is commonly used for persistent Read Models. In Event Sourcing, however, projections are also used to build the Decision Model needed to enforce consistency constraints.
This website typically refers to this latter kind of projection since DCB primarily focuses on ensuring the consistency of the Event Store during write operations.
This article explains how to write projections that can be queried by a DCB-capable Event Store, and how to compose those projections in a way that keeps them simple and reusable.
What is a Projection
In 2013 Greg Young posted the following minimal definition of a projection:
In TypeScript the equivalent Type definition would be:
Implementation
Note
To use a common theme, we refer to Events from the course subscription example:
- new courses can be added (
CourseDefined
) - courses can be archived (
CourseArchived
) - courses can be renamed (
CourseRenamed
)
We use JavaScript in the examples below, but the main ideas are applicable to all programming languages
A typical DCB projection might look something like this:
{
initialState: null,
handlers: {
CourseDefined: (state, event) => event.data.title,
CourseRenamed: (state, event) => event.data.newTitle,
},
tags: [`course:${courseId}`],
}
Let's see how we got here...
Basic functionality
To start simple, we can implement Events as an array of strings:
const events = [
"CourseDefined",
"CourseDefined",
"CourseRenamed",
"CourseArchived",
"CourseDefined"
]
In order to find out how many active courses there are in total, the following simple projection could be defined and we can use JavaScripts reduce
function to aggregate all Events creating a single state, starting with the initialState
:
// ...
const projection = (state, event) => {
switch (event) {
case 'CourseDefined':
return state + 1;
case 'CourseArchived':
return state - 1;
default:
return state;
}
}
const initialState = 0
const numberOfActiveCourses = events.reduce(projection, initialState)
console.log({numberOfActiveCourses})
Query only relevant Events
In the above example, the reducer iterates over all Events even though it only changes the state for CourseDefined
and CourseArchived
events. This is not an issue for this simple example. But in reality, those events are not stored in memory, and there can be many of them. So obviously, they should be filtered before they are read from the Event Store.
As previously mentioned, in the context of DCB, projections are typically used to reconstruct the minimal model required to validate the constraints the system needs to enforce — usually in response to a command issued by the user.
Given that the system should ensure a performant response to user input, it becomes clear how paramount it is to minimize the time and effort needed to rebuild the Decision Model. The most effective approach, then, is to limit the reconstruction to the absolute minimum, by loading only the Events that are relevant to validating the received command.
Filter Events by Type
The Event Type is the main criteria for filtering Events before reading them from an Event Store.
By defining the Event handlers more declaratively, the handled Event Types can be determined from the projection definition itself:
const projection = {
initialState: 0,
handlers: {
CourseDefined: (state, event) => state + 1,
CourseArchived: (state, event) => state - 1,
}
}
// filter events (should exclude "CourseRenamed" events)
console.log(
events.filter((event) =>
event in projection.handlers
)
)
Filter Events by Tags
Decision Models are usually only concerned about single entities. E.g. in order to determine whether a course with a specific id exists, it's not appropriate to read all CourseDefined
events but only those related to the course in question.
This could be done with a projection like this:
const projection = {
initialState: false,
handlers: {
CourseDefined: (state, event) => event.data.courseId === courseId ? true : state,
CourseArchived: (state, event) => event.data.courseId === courseId ? false : state,
}
}
CourseDefined
Events would have to be loaded still.
A traditional Event Store usually allows to partition Events into Event Streams (sometimes called subjects).
In DCB there is no concept of multiple streams, Events are stored in a single global sequence. Instead, with DCB Events can be associated with entities (or other domain concepts) using Tags. And a compliant Event Store allows to filter Events by their Tags, in addition to their Type.
To demonstrate that, we add Data and Tags to the example Events:
const events = [
{
type: "CourseDefined",
data: { id: "c1", title: "Course 1", capacity: 10 },
tags: ["course:c1"],
},
{
type: "CourseDefined",
data: { id: "c2", title: "Course 2", capacity: 20 },
tags: ["course:c2"],
},
{
type: "CourseRenamed",
data: { id: "c1", newTitle: "Course 1 renamed" },
tags: ["course:c1"],
},
{
type: "CourseArchived",
data: { id: "c2" },
tags: ["course:c2"],
},
]
...and extend the projection by some tagFilter
:
const projection = {
initialState: false,
handlers: {
CourseDefined: (state, event) => true,
CourseArchived: (state, event) => false,
},
tagFilter: [`course:c1`]
}
// filter events (should only include "CourseDefined" and "CourseArchived" events with a tag of "course:c1")
console.log(
events.filter((event) =>
event.type in projection.handlers &&
projection.tagFilter.every((tag) => event.tags.includes(tag))
)
)
In the above example, the projection is hard-coded to filter events tagged course:c1
. In a real application, the tagFilter
is the most dynamic part of the projection as it depends on the specific use case, i.e. the affected entity instance(s). So it makes sense to create some kind of factory that allows to pass in the relevant dynamic information (the course id in this case):
const CourseExistsProjection = (courseId) => ({
initialState: false,
handlers: {
// ...
},
tagFilter: [`course:${courseId}`]
})
Library
We have built a small library that provides functions to create projections and test them.
The createProjection
function accepts a projection object like we defined above:
const CourseExistsProjection = (courseId) =>
createProjection({
initialState: false,
handlers: {
CourseDefined: (state, event) => true,
CourseArchived: (state, event) => false,
},
tagFilter: [`course:${courseId}`],
})
The resulting object can be used to easily filter events and to build the projection state:
const projection = CourseExistsProjection("c1")
console.log("query:", projection.query.items)
console.log("initialState:", projection.initialState)
const state = events
.filter((event) => projection.query.matchesEvent(event))
.reduce(
(state, event) => projection.apply(state, event),
projection.initialState
)
console.log("projected state:", state)
Similarly a projection for the current title
of a course would look like this:
const CourseTitleProjection = (courseId) =>
createProjection({
initialState: null,
handlers: {
CourseDefined: (state, event) => event.data.title,
CourseRenamed: (state, event) => event.data.newTitle,
},
tagFilter: [`course:${courseId}`],
})
The library is not a requirement
This library is not required in order to use DCB, but we'll use it in this article and in some of the other examples in order to keep them simple.
The createProjection
function returns an object with the following properties:
type Projection<S> = {
get initialState(): S
apply(state: S, event: SequencedEvent): S
get query(): Query
}
The Query
can be used to "manually" filter events, like in the example above. In a productive application it will be translated to a query that the corresponding Event Store can execute.
Composing projections
As mentioned above, these in-memory projections can be used to build Decision Models that can be used to enforce hard constraints.
So far, the example projections in this article were only concerned about a very specific question, e.g. whether a given course exists. Usually, there are multiple hard constraints involved though. For example: In the course subscription example in order to change a courses capacity, we have to ensure that...
- ...the course exists
- ...and that the specified new capacity is different from the current capacity
It is tempting to write a slightly more sophisticated projection that can answer both questions, like:
const CourseProjection = (courseId) =>
createProjection({
initialState: { courseExists: false, courseCapacity: 0 },
handlers: {
CourseDefined: (state, event) => ({
courseExists: true,
courseCapacity: event.data.capacity,
}),
CourseCapacityChanged: (state, event) => ({
...state,
courseCapacity: event.data.newCapacity,
}),
},
tagFilter: [`course:${courseId}`],
})
const courseProjection = CourseProjection("c1")
const state = events
.filter((event) => courseProjection.query.matchesEvent(event))
.reduce(courseProjection.apply, courseProjection.initialState)
console.log(state)
But that has some drawbacks, namely:
- It increases complexity of the projection code and makes it harder to reason about
- It makes the projection more "greedy", i.e. if it was used to make a decision based on parts of the state it would consume more events than required and increase the consistency boundary needlessly (see article about Aggregates for more details)
Instead, the composeProjections
function allows to combine multiple smaller projections into one, depending on the use case:
const compositeProjection = composeProjections({
courseExists: CourseExistsProjection("c1"),
courseTitle: CourseTitleProjection("c1"),
})
console.log("initial state:", compositeProjection.initialState)
const state = events
.filter((event) => compositeProjection.query.matchesEvent(event))
.reduce(compositeProjection.apply, compositeProjection.initialState)
console.log("projected state:", state)
As you can see, the state of the composite projection is an object with a key for every projection of the composition. Likewise, the resulting query will match only Events that are relevant for at least one of the composed projections.
How to use this with DCB
With DCB, composite projections are especially useful, when building a Decision Model to enforce strong consistency.
The buildDecisionModel
function allows multiple projections to be composed on-the-fly, allowing to enforce dynamic consistency boundaries that inspired the name DCB:
const eventStore = new InMemoryDcbEventStore()
const { state, appendCondition } = buildDecisionModel(eventStore, {
courseExists: CourseExistsProjection("c1"),
courseTitle: CourseTitleProjection("c1"),
})
console.log("initial state:", state)
console.log("append condition:", appendCondition)
Conclusion
Projections play a fundamental role in DCB and Event Sourcing as a whole. The ability to combine multiple simple projections into more complex ones tailored to specific use cases unlocks a range of possibilities that can influence application design.