Quick intro
In this tutorial, we will create an application that can add and remove items from a shopping cart. Along the way, you will experience the fundamental mechanics and basic building blocks of Emmett.
Create a Node.js project
Before installing and using Emmett, we need to set up a Node.js project and add Typescript support. To do so, first create a project directory and switch to it:
mkdir emmett-quick-intro
cd emmett-quick-intro
npm init
pnpm init
yarn init
bun init
For now, just accept the defaults.
NOTE
For the sake of brevity, we will focus on using npm
as a package manager in this tutorial.
The output should look similar to this:
package name: (emmett-quick-intro)
version: (1.0.0)
description:
entry point: (index.js) dist/index.js
test command:
git repository:
keywords:
author:
license: (ISC)
About to write to /home/tobias/projekte/emmett-quick-intro/package.json:
{
"name": "emmett-quick-intro",
"version": "1.0.0",
"description": "",
"main": "dist/index.js",
"scripts": {
"test": ""
},
"author": "",
"license": "ISC"
}
Is this OK? (yes)
Add TypeScript support
Now install TypeScript and tsx
as dev dependencies for running our code:
npm install typescript @types/node tsx --save-dev
added 3 packages, and audited 4 packages in 1s
found 0 vulnerabilities
Now, we initialize TypeScript using:
npx tsc --init --target ESNEXT --outDir dist --module nodenext --moduleResolution nodenext
Created a new tsconfig.json with:
target: es2016
module: commonjs
strict: true
esModuleInterop: true
skipLibCheck: true
forceConsistentCasingInFileNames: true
You can learn more at https://aka.ms/tsconfig
Running your application
Add the start
target to your package.json
:
{
"name": "emmett-quick-intro",
"version": "1.0.0",
"type": "module",
"description": "",
"scripts": {
"start": "tsx ./index.ts"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@types/node": "^22.13.10",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
},
"dependencies": {
"@event-driven-io/emmett": "^0.34.0"
}
}
Let us add an empty index.ts
file as entrypoint for our application:
touch index.ts
To run your application, use the following command:
npm run start
$ npm run build:ts
> emmett-quick-intro@1.0.0 start
> tsx ./index.ts
Add Emmett as a dependency
npm add @event-driven-io/emmett
pnpm add @event-driven-io/emmett
yarn add @event-driven-io/emmett
bun add @event-driven-io/emmett
Writing a simple shopping cart application
Let us write a simple shopping cart application using Emmett's four basic building blocks: Commands, command handlers, events, and the event store.
Defining the shopping cart
First, open the index.ts
file that we created previously. Then let us define a type for how our shopping cart should look like:
type ShoppingCart = {
items: Set<string>;
};
Create the initial state
We cannot start from just anywhere. That is why we need a function that returns the initial state of our shopping cart:
export function initialState(): ShoppingCart {
return {
items: new Set(),
};
}
This creates a new empty shopping cart
Commands
Commands are instructions to the application to perform a particular operation, like Add item to shopping cart!
Our application needs to perform the following operations:
- Add item: Add an item by its name
- Remove item: Remove an item by its name
Let us first create a command to add an item in index.ts
and another to remove one:
// Command<type, payload>
export type AddItem = Command<
// Type
'AddItem',
// Payload
{
name: string;
}
>;
// Command<type, payload>
export type RemoveItem = Command<
// Type
'RemoveItem',
// Payload
{
name: string;
}
>;
// Union type of all possible commands (for later)
export type ShoppingCartCommand = AddItem | RemoveItem;
This declares a command whose type is AddItem
(or RemoveItem
) and whose payload has a name
property of type string
. The name
property specifies the item's name that should be added to or removed from the cart.
Last but not least, we define a type ShoppingCartCommand
that accommodates all commands. This way, we can easily extend the list of possible commands using union types without having to change the code elsewhere.
NOTE
The command is only an intention to perform the operation, e.g. add an item to the cart. Depending on the outcome, this request may or may not be successful.
Events
Events record that something happened in the past, e.g. item was added to shopping cart
. They are immutable facts that cannot be changed anymore.
Before we can put things together, we need to define our item added event. Also, we define a ShoppingCartEvent
type to accommodate all events:
// Event<type, payload>
export type ItemAdded = Event<
'ItemAdded',
{
name: string;
}
>;
export type ItemRemoved = Event<
'ItemRemoved',
{
name: string;
}
>;
// Union type of all possible shopping cart events (for later)
export type ShoppingCartEvent = ItemAdded | ItemRemoved;
We use this type to record that our item has been added to (or removed from) the shopping cart.
Recording events
Now we need to put the commands and events together using command handler functions:
type ShoppingCart = {
items: Set<string>;
};
This function is responsible for deciding the command's outcome using business rules (e.g., items may not be added more than once). Currently, there are none, so we simply pass the name using the data.name
property of the command
.
export function addItem(
command: AddItem,
state: ShoppingCart,
): ShoppingCartEvent {
return {
type: 'ItemAdded',
data: {
name: command.data.name,
},
};
}
export function removeItem(
command: RemoveItem,
state: ShoppingCart,
): ItemRemoved {
if (!state.items.has(command.data.name))
throw new IllegalStateError(
`Item ${command.data.name} does not exist in the shopping cart`,
);
return {
type: 'ItemRemoved',
data: {
name: command.data.name,
},
};
}
You can group all commands into a unified function that is easily extensible when you add more commands:
export function decide(
command: ShoppingCartCommand,
state: ShoppingCart,
): ShoppingCartEvent {
const { type } = command;
switch (type) {
case 'AddItem':
return addItem(command, state);
case 'RemoveItem':
return removeItem(command, state);
default: {
const _notExistingCommandType: never = type;
throw new EmmettError(`Unknown command`);
}
}
}
This calls the correct command handler function based on the command's type
and returns an event (or an array of events) that will be recorded and stored in the event store further below.
Calculating the next shopping cart
Finally, we need to evolve our shopping cart. Given the shopping cart's current state (items), we add or remove items based on the name
in the event. The pattern is pretty similar to the decider pattern, except that we take a shopping cart and an event to compute the next state:
export function evolve(
state: ShoppingCart,
event: ShoppingCartEvent,
): ShoppingCart {
const { type, data } = event;
switch (type) {
case 'ItemAdded': {
const nextState = {
...state,
items: new Set([...state.items, data.name]),
};
// Print the event data and the contents of the changed shopping cart
console.log(
`${type}(name: ${data.name}) // ShoppingCart(items: ${Array.from(nextState.items.values()).join(', ')})`,
);
return nextState;
}
case 'ItemRemoved': {
const items = new Set(state.items);
items.delete(data.name);
const nextState = {
...state,
items,
};
// Print the event data and the contents of the changed shopping cart
console.log(
`${type}(name: ${data.name}) // ShoppingCart(items: ${Array.from(nextState.items.values()).join(', ')})`,
);
return nextState;
}
default: {
const _notExistingEventType: never = type;
throw new EmmettError(`Unknown event`);
}
}
}
Finally, we can declare the command handler itself:
export const handle = CommandHandler({ evolve, initialState });
Putting it all together with an event store
The event store is logically a key-value database that records a series of events that happened in our application. It logically groups events in streams. An event stream is an ordered sequence of events and a representation of a specific process or entity. Event stream id equals the entity id (e.g. shopping cart id). To get the current state from events, we need to:
- read all that has been recorded so far.
- take the initial state and apply it one by one to get the current state at the time.
Yes, the state we'll use in business logic to validate our business rules.
Starting from the initial state, it folds all events using the evolve
function (basically left reducing the event stream).
For example:
// Inital state: ShoppingCart (items: [])
ItemAdded(name: Pizza) // ShoppingCart(items: Pizza)
ItemAdded(name: Ice Cream) // ShoppingCart(items: Pizza, Ice Cream)
ItemRemoved(name: Ice Cream) // ShoppingCart(items: Pizza)
These events are stored inside the EventStore
. Emmett provides several implementations of event stores. For simplicity, we use the in-memory event store:
import { getInMemoryEventStore } from '@event-driven-io/emmett';
const eventStore = getInMemoryEventStore();
Next let us define a few example commands:
const addPizza: AddItem = {
type: 'AddItem',
data: {
name: 'Pizza',
},
};
const addIceCream: AddItem = {
type: 'AddItem',
data: {
name: 'Ice Cream',
},
};
const removePizza: RemoveItem = {
type: 'RemoveItem',
data: {
name: 'Pizza',
},
};
const removeIceCream: RemoveItem = {
type: 'RemoveItem',
data: {
name: 'Ice Cream',
},
};
We can pass these to the handle
function that we previously defined and use our decider for "routing" the commands to the correct methods:
// Use a constant cart id for this example.
// In real life applications, this should be a real cart id.
const shoppingCartId = '1';
// 2. Handle command
await handle(eventStore, shoppingCartId, (state) => decide(addPizza, state));
console.log('---');
await handle(eventStore, shoppingCartId, (state) => decide(addIceCream, state));
console.log('---');
await handle(eventStore, shoppingCartId, (state) =>
decide(removeIceCream, state),
);
This results in the following output:
ItemAdded(name: Pizza) // ShoppingCart(items: Pizza)
---
ItemAdded(name: Pizza) // ShoppingCart(items: Pizza)
ItemAdded(name: Ice Cream) // ShoppingCart(items: Pizza, Ice Cream)
---
ItemAdded(name: Pizza) // ShoppingCart(items: Pizza)
ItemAdded(name: Ice Cream) // ShoppingCart(items: Pizza, Ice Cream)
ItemRemoved(name: Ice Cream) // ShoppingCart(items: Pizza)
Why does it look that way? Initially, there is only one event in the stream, then two, three, and four. Whenever an event is appended to the stream, the whole stream is re-read to calculate the next state.
One key aspect is that this behaviour allows us to change the code to build the current state of the shopping cart (e.g., just count the number of items in the cart) without any negative side effects.
Business perspective
In the final round, the ice cream was added and then removed again. In a traditional state-sourced database, it is impossible to see from the cart state that the ice cream has been in the cart.
In event-sourced applications, we keep a record of all relevant events to the business. In this case, we might want to change the code to send a newsletter with a 50 % discount on ice cream to people who removed it from the cart to increase ice cream sales.
Moreover, we can implement such a feature retroactively by replaying all events from the event store for all events recorded before our feature existed. That means we can defer these kinds of decisions to when they become necessary from a business perspective without severe drawbacks.
Summary
This tutorial has shown you the fundamental building blocks of Emmett. However, there is more to it, as we skipped essential topics like testing, persistence for event stores and API integrations to make the data available to the outside world.
Next steps
Check the Getting Started guide to learn more about building a real web API with PostgreSQL storage.
You can also watch a full introduction video on how to build applications with Emmett.
Full implementation
Here is the full source code for reference:
/* eslint-disable @typescript-eslint/no-unused-vars */
import {
CommandHandler,
EmmettError,
IllegalStateError,
type Command,
type Event,
} from '@event-driven-io/emmett';
// #region state-definition
type ShoppingCart = {
items: Set<string>;
};
// #endregion state-definition
// #region initial-state
export function initialState(): ShoppingCart {
return {
items: new Set(),
};
}
// #endregion initial-state
// #region commands
// Command<type, payload>
export type AddItem = Command<
// Type
'AddItem',
// Payload
{
name: string;
}
>;
// Command<type, payload>
export type RemoveItem = Command<
// Type
'RemoveItem',
// Payload
{
name: string;
}
>;
// Union type of all possible commands (for later)
export type ShoppingCartCommand = AddItem | RemoveItem;
// #endregion commands
// #region events
// Event<type, payload>
export type ItemAdded = Event<
'ItemAdded',
{
name: string;
}
>;
export type ItemRemoved = Event<
'ItemRemoved',
{
name: string;
}
>;
// Union type of all possible shopping cart events (for later)
export type ShoppingCartEvent = ItemAdded | ItemRemoved;
// #endregion events
// #region command-handler-functions
export function addItem(
command: AddItem,
state: ShoppingCart,
): ShoppingCartEvent {
return {
type: 'ItemAdded',
data: {
name: command.data.name,
},
};
}
export function removeItem(
command: RemoveItem,
state: ShoppingCart,
): ItemRemoved {
if (!state.items.has(command.data.name))
throw new IllegalStateError(
`Item ${command.data.name} does not exist in the shopping cart`,
);
return {
type: 'ItemRemoved',
data: {
name: command.data.name,
},
};
}
// #endregion command-handler-functions
// #region decider
export function decide(
command: ShoppingCartCommand,
state: ShoppingCart,
): ShoppingCartEvent {
const { type } = command;
switch (type) {
case 'AddItem':
return addItem(command, state);
case 'RemoveItem':
return removeItem(command, state);
default: {
const _notExistingCommandType: never = type;
throw new EmmettError(`Unknown command`);
}
}
}
// #endregion decider
// #region evolve
export function evolve(
state: ShoppingCart,
event: ShoppingCartEvent,
): ShoppingCart {
const { type, data } = event;
switch (type) {
case 'ItemAdded': {
const nextState = {
...state,
items: new Set([...state.items, data.name]),
};
// Print the event data and the contents of the changed shopping cart
console.log(
`${type}(name: ${data.name}) // ShoppingCart(items: ${Array.from(nextState.items.values()).join(', ')})`,
);
return nextState;
}
case 'ItemRemoved': {
const items = new Set(state.items);
items.delete(data.name);
const nextState = {
...state,
items,
};
// Print the event data and the contents of the changed shopping cart
console.log(
`${type}(name: ${data.name}) // ShoppingCart(items: ${Array.from(nextState.items.values()).join(', ')})`,
);
return nextState;
}
default: {
const _notExistingEventType: never = type;
throw new EmmettError(`Unknown event`);
}
}
}
//#endregion evolve
//#region handle
export const handle = CommandHandler({ evolve, initialState });
//#endregion handle
//#region event-store
import { getInMemoryEventStore } from '@event-driven-io/emmett';
const eventStore = getInMemoryEventStore();
//#endregion event-store
//#region example-commands
const addPizza: AddItem = {
type: 'AddItem',
data: {
name: 'Pizza',
},
};
const addIceCream: AddItem = {
type: 'AddItem',
data: {
name: 'Ice Cream',
},
};
const removePizza: RemoveItem = {
type: 'RemoveItem',
data: {
name: 'Pizza',
},
};
const removeIceCream: RemoveItem = {
type: 'RemoveItem',
data: {
name: 'Ice Cream',
},
};
//#endregion example-commands
//#region handle-example-commands
// Use a constant cart id for this example.
// In real life applications, this should be a real cart id.
const shoppingCartId = '1';
// 2. Handle command
await handle(eventStore, shoppingCartId, (state) => decide(addPizza, state));
console.log('---');
await handle(eventStore, shoppingCartId, (state) => decide(addIceCream, state));
console.log('---');
await handle(eventStore, shoppingCartId, (state) =>
decide(removeIceCream, state),
);
//#endregion handle-example-commands