Event Sourcing is hard π§ββοΈ
Implementing Event Sourcing in Typescript is painful. The power of this pattern is often counterbalanced by the complexity of its implementation and the difficulty of its maintainability π€―. Key features like event replays can be tough to implement π₯΅.
At Kumo, many of our projects used Event Sourcing, and we often stumbled upon the same issues again and again and ended up coding the same thing over and over. π€‘ It soon became obvious to us that developers needed new and better tools to do Event Sourcing on their Serverless projects.
β Event Sourcing is a pattern for storing data as a list of ordered events. If you are not familiar with this pattern, we invite you to read this Event Sourcing presentation.
We can do better
We thought that the best way to achieve our goal was to create an open-source library that would provide the following advantages:
- π Abstractions first: Castore has been designed with flexibility in mind. It gives you abstractions that are meant to be used anywhere: React apps, containers, Lambdas... you name it!
- βοΈ Full type safety: We love type inference and we know you will too! The lack of decently typed Event Sourcing library was one of the main reasons we created Castore.
π Best practices enforced: The Event Sourcing journey has many hidden pitfalls. We ran into them for you! Castore is opiniated. It comes with a collection of best practices and documented anti-patterns that we hope will help you out!
π ββοΈ We do NOT deploy resources: While some packages like
DynamoDBEventStorageAdapter
require compatible infrastructure, Castore is not responsible for deploying it.
Introducing Castore π¦«
As a result, after months of work β¨, we are happy to introduce Castore!
Castore is an open-source Typescript library! It provides a set of classes, methods, and adapters to offer the best typescript developer experience for implementing Event Sourcing and interacting with any storage solution you want to use. V1 of Castore is already live! π
Castore API βοΈ
1. EventStore πͺ
Castore provides you with an EventStore
class. This is the entry point of Castore. This class exposes several methods to interact with your events and squeeze data out of them. ποΈπποΈ
To instantiate this class, you will need to define different event types. An event type is nothing more than a type-safe object describing the metadata and payload of your event.
You can use the EventType
constructor π· to create basic events or use more sophisticated JsonSchemaEventType
or ZodEventType
to create events with runtime validation! π¨βπ«
You will also need to provide a reducer when creating the EventStore. The reducer is the function that will be applied to your ordered events to build their summarized view which we call aggregate.
import { EventType } from "@castore/core"
export const userCreatedEvent = new EventType<
// Typescript EventType
'USER_CREATED',
// Typescript EventDetails
{
aggregateId: string;
version: number;
type: 'USER_CREATED';
timestamp: string;
payload: { name: string; age: number };
}
>({
// EventType
type: 'USER_CREATED',
});
const userEventStore = new EventStore ({
eventStoreEvents: [userCreatedEvent],
reducer: (
userAggregate: UserAggregate,
event: UserEventsDetails,
): UserAggregate => {
const { version, aggregateId } = event;
switch (event.type) {
case 'USER_CREATED': {
const { name, age } = event.payload;
return {
aggregateId,
version: event.version,
name,
age,
status: 'CREATED',
};
}
...
}
}
...
});
2. Storing data π½
Now that you have defined your EventStore
and your types of Events, you want to store Events somewhere. We have created adapters to make your EventStore
compatible with different data storage solutions. For now, DynamoDBAdapter
and InMemoryAdapter
are the two StorageAdapters
available with Castore. But you can of course create your own if you want! π¨
const userEventStore = new EventStore({
eventStoreEvents: [...],
storageAdapter: new DynamoDbEventStorageAdapter({
tableName: "MyTable",
dynamoDbClient: new DynamoDBClient()
})
})
3. Interacting with the EventStore π§
To push and retrieve events from the event store, the EventStore
class exposes methods like pushEvent
or getAggregate
. These methods use the adapter to interact with the data storage entity and add or retrieve events.
Here is a quick example showing how an application would use these two methods:
const removeUser = async (userId: string) => {
// get the aggregate for that userId,
// which is a representation of our user's state
const { aggregate } = await userEventStore.getAggregate(userId);
// use the aggregate to check the user status
if (aggregate.status === 'REMOVED') {
throw new Error('User already removed');
}
// put the USER_REMOVED event in the event store π¦«
await userEventStore.pushEvent({
aggregateId: userId,
version: aggregate.version + 1,
type: 'USER_REMOVED',
timestamp: new Date().toIsoString(),
});
};
Go check out the repo to find out about other cool features of Castore including Commands
, Snapshots
, custom mock helpers... π«
Conclusion
Castore makes the Typescript developer experience so easy it should be illegal. You will be interacting with your event store like never before, optimization and best practices being abstracted away to let you focus on delivering business value as fast and seamlessly as possible. π°
We canβt wait to get your feedback β and add more features to Castore. The incoming features include events migration ποΈ, Projection classes π½οΈ... Feel free to contribute !
As a bonus, if you are a Serverless fanboy, here is a cool article to get a better grasp on serverless Event Sourcing architectures and their problematics written by one of our lovely colleagues at Theodo.
Written with β€οΈ by @julietteff @charlesgery @thomasaribart @valentinbeggi
Top comments (3)
Hello Tamas and thanks for the comment !
I agree that it adds complexity and costs. However:
That being said, there are also situations where Event Sourcing is not the right tool. For instance, events need to be actual business events, not UI or analytics related stuff, or saving the intermediate state of a long form.
Hope it answers your question :)
How does it relate to #aws? Is it something specific that you did to support it over other cloud providers?
Hi Piotr !
At Kumo, our preferred data storage solution for our events is DynamoDB. It's snappy to get data from it and events do not have complex access patterns β‘.
For this reason, we built a DynamoDBAdapter to Castore. It is the only DB Adapter so-far (except for local storage) and that's why we mentioned #AWS !
Castore itself is stack agnostic though π !