Skip to content

Tools for event sourcing applications

License

Notifications You must be signed in to change notification settings

irontitan/paradox

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

87 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation



Toolkit to help developers implement the event sourcing architecture

Build Status GitHub license Javascript code Style Github All Releases GitHub package version Codacy Badge Known Vulnerabilities

Summary

Instalation

$ pnpm i @irontitan/paradox
$ npm i @irontitan/paradox
$ yarn add @irontitan/paradox

Example

PersonWasCreated.ts event

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

PersonEmailChanged.ts event

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

Person.ts class

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

Putting it all together

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('johndoe@doe.com', '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: 'johndoe@company.com' }, 'jdoe')
  const [ janeDoe ] = allJanes
  janeDoe.changeEmail({ newEmail: 'janedoe@doe.com' }, 'janedoe')

  await personRepository.bulkUpdate([ johnDoe, janeDoe ]) // Updates both entities in the database using `bulkWrite`
})() // This IIFE is just to generate our async/await scope

What does this toolkit have?

  • EventEntity: Pre-made event-based class. It contains all the implementations to create a fully functional event sourcing entity
  • MongoDBEventRepository: 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

API Summary

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 the persistedEvents property with the events array
  • public pushNewEvents(events: Array<{id, name, data, timestamp}>): Pushes a new event to the pendingEvents array
  • public confirmEvents(): Transfers all the content from the pendingEvents array to the persistedEvents 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

EventEntity

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} format
  • pendingEvents: 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 the persistedEvents 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 the pendingEvents 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 the pendingEvents array to the persistedEvents 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 through this.propertyName

Repositories

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.

Interfaces

IPaginatedQueryResult

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
}

IEntityConstructor

Represents the constructor of an entity

interface IEntityConstructor<Entity> {
  new(events?: IEvent<any>[]): Entity
}

My repository is not included, what do I do?

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 entity
  • findById: 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 the setPersistedEvents 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

Adding your repository to the list

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