Toolkit to help developers implement the event sourcing architecture
- Summary
- Instalation
- Example
- What does this toolkit have?
- EventEntity
- Repositories
- Interfaces
- My repository is not included, what do I do?
$ pnpm i @irontitan/paradox
$ npm i @irontitan/paradox
$ yarn add @irontitan/paradox
Event that will create the Person
class
import { Event } from '@irontitan/paradox'
import { Person } from './classes/Person'
import ObjectId from 'bson-objectid'
interface IPersonCreationParams {
id?: ObjectId
name: string
email: string
}
class PersonWasCreated extends Event<IPersonCreationParams> {
static readonly eventName: string = 'person-was-created'
user: string
constructor(data: IPersonCreationParams, user: string) {
super(PersonWasCreated.eventName, data)
this.user = user
}
static commit(state: Person, event: PersonWasCreated) {
state.id = event.data.id
state.name = event.data.name
state.email = event.data.email
state.updatedAt = event.timestamp
state.updatedBy = event.user
return state
}
}
Triggered when a Person's email changes
import { Event } from '@irontitan/paradox'
import { Person } from './classes/Person'
import ObjectId from 'bson-objectid'
interface IPersonEmailChangeParams {
newEmail: string
}
class PersonEmailChanged extends Event<IPersonEmailChangeParams> {
static readonly eventName: string = 'person-email-changed'
user: string
constructor(data: IPersonEmailChangeParams, user: string) {
super(PersonWasCreated.eventName, data)
this.user = user
}
static commit(state: Person, event: PersonEmailChanged) {
state.email = event.data.newEmail
state.updatedAt = event.timestamp
state.updatedBy = event.user
return state
}
}
Important
- The
commit
method is in the event class in this example, but it can be at any place in the code- The
eventName
property is required
The main Person
entity.
Since version 2.9.0, EventEntity's constructor receives, as a second parameter, the Entity class itself. This is used to update the state internally when adding new events. For now, this second parameter is optional. Not passing it, though, is considered deprecated and will stop being supported on the future
import ObjectId from 'bson-objectid'
import { EventEntity } from '@irontitan/paradox'
import { PersonWasCreated } from './events/PersonWasCreated'
import { PersonEmailChanged } from './events/PersonEmailChanged'
export class Person extends EventEntity<Person> {
name: string | null = null
email: string | null = null
updatedAt: Date | null = null
updatedBy: string | null = null
static readonly collection: string = 'people'
constructor() {
super({
[ PersonWasCreated.eventName ]: PersonWasCreated.commit
}, Person)
}
static create (email: string, name: string, user: string): Person { // Method to create a person
const id = new ObjectId()
const person = new Person()
person.pushNewEvents([ new PersonWasCreated({id, name, email}, user) ]) // Includes a new event on creation
return person // Returns new instance
}
changeEmail (newEmail: string, user: string) {
this.pushNewEvents([ new PersonEmailChanged({ newEmail }, user) ])
return this
}
get state() {
const currentState = this.reducer.reduce(new Person, [
...this.persistedEvents,
...this.pendingEvents
])
return {
id: currentState.id,
name: currentState.name,
email: currentState.email
}
}
}
import { Db, MongoClient } from 'mongodb'
import { MongodbEventRepository } from '@irontitan/paradox'
import { Person } from './classes/Person'
class PersonRepository extends MongodbEventRepository<Person> {
constructor(connection: Db) {
super(connection.collection(Person.collection), Person)
}
async search (filters: { name: string }, page: number = 1, size: number = 50) {
const query = filters.name
? { 'state.name': filters.name }
: { }
const { documents, count, range, total } = await this._runPaginatedQuery(query, page, size)
const entities = documents.map(({ events }) => new Person().setPersistedEvents(events))
return { entities, count, range, total }
}
}
(async function () {
const connection = (await MongoClient.connect('mongodb://mongodburl')).db('crowd')
const personRepository = new PersonRepository(connection)
const johnDoe = Person.create('[email protected]', 'jdoe') // Will create a new event in the class
await personRepository.save(johnDoe) // Will persist the data to the database
const allJanes = await personRepository.search({ name: 'jane' }, 1, 10) // Will return an object implementing the IPaginatedQueryResultinterface
// If you like, there's a possibility to update multiple classes at the same time
johnDoe.changeEmail({ newEmail: '[email protected]' }, 'jdoe')
const [ janeDoe ] = allJanes
janeDoe.changeEmail({ newEmail: '[email protected]' }, 'janedoe')
await personRepository.bulkUpdate([ johnDoe, janeDoe ]) // Updates both entities in the database using `bulkWrite`
})() // This IIFE is just to generate our async/await scope
EventEntity
: Pre-made event-based class. It contains all the implementations to create a fully functional event sourcing entityMongoDBEventRepository
: MongoDB event-based repository (If you use another database, feel free to help us by writing a PR and adding it to the list :D)- Typing helpers
- A bare export of the Tardis toolkit
EventEntity
TL;DR: Represents an business entity with event sourcing properties. Must always be extended. Must always contain a state
getter which returns the final state of the entity
Properties:
- public
persistedEvents
: Array of events that were persisted to the database - public
pendingEvents
: Array of events that have not yet been persisted to the database - protected
reducer
: A reducer instance as described in the Tardis documentation - get
state
: Returns the final state of the entity (must be implemented)
Methods:
- public
setPersistedEvents(events: Array<{id, name, data, timestamp}>)
: Sets thepersistedEvents
property with theevents
array - public
pushNewEvents(events: Array<{id, name, data, timestamp}>)
: Pushes a new event to thependingEvents
array - public
confirmEvents()
: Transfers all the content from thependingEvents
array to thepersistedEvents
array
MongodbEventRepository
TL;DR: Represents a database that is fully suited to use event-based classes
Properties:
- protected
_collection
: Collection name
Methods:
- public
save(entity: BusinessEntity, force: Boolean = false)
: Saves the current entity to the database by either pushing new events, or overriding the events array (Be VERY carefull with the last one) - public
bulkUpdate(entities: EventEntity[], session)
: Updates multiple entities at once - public
bulkInsert(entities: EventEntity[], session)
: Inserts multiple entities at once - public
findById(id: string | ObjectId)
: Finds an entity by the provided ID - public
withSession(session: ClientSession)
: Starts a MongoDB session and returns the available methods that can be used with the provided session - protected
_runPaginatedQuery(query: {[key: string]: any}, page: number, size: number, sort?: {[field: string]: 1|-1})
: Runs a query in the database and return the paginated results
An EventEntity
is a business class which posesses the implementation of all events this class can have. All event-based entity must extends EventEntity
class, since it is an abstract/generic class. Extending it will give your own class some cool functionalities out-of-the-box:
persistedEvents
: An array of events which were already persistted to the database. It follows the{id, name, data, timestamp}
formatpendingEvents
: An array of events which were not yet saved to the database
When created, the new entity will receive (as a parameter) an object, of which the keys must be the name of an event and its value must be the commit
function, which can be located anywhere, but, in our little example above, we created it as a static method inside the event entity itself. Since v2.9.0, it also receives the entity class itself, to be used for internal purposes.
This procedure is the same for all the events that entity might have, this is due to the fact that the EventEntity
, when instantiated, will create a Reducer instance in the property this.reducer
and it'll pass on all these known events to it so it can be possible to manage all events inside the same class, without the need to instantiate something new.
This class must also have a getter caled state
. This getter exists in the parent class (EventEntity
) as a "Non implemented method", which will throw an error if used as default. This way, it becomes necessary for the child class to overwrite the parent class' method, implementing in it the responsability to reduce the previous state to the current state and returning it.
Refer to the
Person.ts
class for more information
Besides state
, the EventEntity
class will disclose several other methods such:
setPersistedEvents
: Which will receive an array of events in the{id, name, data, timestamp}
format, fetched from the database, and it'll include these events into thepersistedEvents
array. It'll be often used when loading a class for the first time from the database.pushNewEvents
: Will receive an event array following the same{id, name, data, timestamp}
format, but instead of adding them to the persisted events array, it'll add the events to thependingEvents
array and thus, notifying that there are events which were not yet persisted to the database and are only available inside this instance.confirmEvents
: Will move all the items from thependingEvents
array to thepersistedEvents
array. This will confirm that all the events were successfuly saved into the database. This will be often used after we save the last state of the entity to the database.
All of the three methods above call the private method
updateState
, which sets all properties from the current state back to the instance of the entity class. Which means that, when an event changes the value of a property, you don't need to recalculate the state altogether once again, it'll be automatically updated and available throughthis.propertyName
Repositories are places where data resides, by default, we would not have to create an event-base class for them, but, in order to standardize all the events saved into the database, it was necessary to create such class.
Since different databases have different event sourcing implementations, for now, we only have the ones listed below.
Note that different repository classes might behave differently depending on who created the class, please refer to the PR section or fill in an issue if you're experiencing trouble.
Represents a paginated query:
interface IPaginatedQueryResult<TDocument> { // TDocument is the type that represents the data which will be returned from the database (it is used internally)
documents: TDocument[] // Documents in the current page
count: number // Total results in the page
range: {
from: number, // Index of the first result
to: number // Index of the last result
}
total: number // Query total
}
Represents the constructor of an entity
interface IEntityConstructor<Entity> {
new(events?: IEvent<any>[]): Entity
}
Since this lib is open source and generic enough to be used by multiple repositories, there's no way to know which repositories the users are going to be using. So we added a way for you to create your own.
In order to create a repository, your class must extend the EventRepository
class, which is fully abstract and is as follows:
export interface IEntityConstructor<Entity> {
new(events?: IEvent<any>[]): Entity
}
export abstract class EventRepository<TEntity extends IEventEntity> {
protected readonly _Entity: IEntityConstructor<TEntity>
constructor (Entity: IEntityConstructor<TEntity>) {
this._Entity = Entity
}
abstract async save (entity: TEntity): Promise<TEntity>
abstract async findById (id: any): Promise<TEntity | null>
abstract async runPaginatedQuery (query: { [key: string]: any }, page: number, size: number, sort: { [field: string]: 1 | -1 }): Promise<IPaginatedQueryResult<{ events: IEvent<TEntity>[] }>>
}
In order to maintain consistency between implementations, the following methods must be implemented:
save
: Should save the given entity to the database and return the entityfindById
: Should find an entity by its ID in the database. It is important to notice that, once found, the returned value should be a newly created instance of that entity (this is where you're going to use thesetPersistedEvents
method)runPaginatedQuery
: Should return a paginated query from the database
Besides these methods, any class that extends EventRepository
will inherit the _Entity
property, which refers to the entity constructor. This will be used when returning the newly created entity from the database during the findById
method and seting its persisted events on the newly instantiated class, like so:
async function findById (id) {
/* finds the data */
const instance = this._Entity() // Creates a new instance of <Entity>
return instance.setPersistedEvents(yourEvents) // Sets the returned data into the instance
}
Those are the required implementations, any additional functionalities you'd like to include in the repository can be added at will.
For further explanation and examples, refer to the MongodbEventRepository file in the
src
folder
If you'd like to add your repository to the list of included repositories, please fill in a PR and don't forget to stick to some conventions:
- All names are CamelCase
- Private methods start their names with
_
- Do not forget to add the documentation to this repository in the
docs/
folder (the file should be the same name as your class) - Do not forget to add your repository to the list in this README along with the link to its own docs
Thank you for your contribution :D