Tutorials

Building A NestJS Web Application With EventStoreDB

James Hickey  |  05 August 2021

Event sourcing can seem overwhelming at first. It's a paradigm shift from the classic RDBMS style of storing and querying data. And many think that event sourcing has to involve more advanced concepts like snapshots, separate databases, etc.

The fact is that you can start learning about event sourcing by using events to write and read without all those extras!

In this tutorial, you'll learn how to build a basic event-sourced NestJS web application and hook it up to an EventStoreDB instance to write and read your data.

NestJS is a TypeScript based web framework that's been gaining a ton of traction. It's what you might expect if Angular, TypeScript and ExpressJS were smashed together!

EventStoreDB is considered one of the go-to tools to build event-sourced systems.

For the full source code of this sample, check out this GitHub repo.

Creating Your NestJS Application

NestJS includes a CLI to easily scaffold controllers, application services, etc. Let's install it now and also create our NestJS web project (which I've cleverly named "app"):

npm i -g @nestjs/cli@8.x.x
nest new app
cd app

Test the default scaffolded application by running npm run start and navigating to http://localhost:3000/. You should see "Hello World!"

Installing EventStoreDB With Docker

Next, you'll want an easy way to run an instance of EventStoreDB. Docker gives a great way to do this.

Create a file in the root of your NestJS application called docker-compose.yml with the following contents:

version: '3.7'

services:
  eventstore.db:
    image: eventstore/eventstore:21.6.0-buster-slim
    environment:
      - EVENTSTORE_CLUSTER_SIZE=1
      - EVENTSTORE_RUN_PROJECTIONS=All
      - EVENTSTORE_START_STANDARD_PROJECTIONS=true
      - EVENTSTORE_EXT_TCP_PORT=1113
      - EVENTSTORE_HTTP_PORT=2113
      - EVENTSTORE_INSECURE=true
      - EVENTSTORE_ENABLE_EXTERNAL_TCP=true
      - EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP=true
    ports:
      - '1113:1113'
      - '2113:2113'
    volumes:
      - type: volume
        source: eventstore-volume-data
        target: /c/data/eventstore/data
      - type: volume
        source: eventstore-volume-logs
        target: /c/data/eventstore/logs
volumes:
  eventstore-volume-data:
  eventstore-volume-logs:

To run an instance of EventStoreDB open up a terminal and execute the following:

docker-compose up

Piece-of-cake!

You'll need a way to read/write to EventStoreDB from TypeScript. Luckily for you, there is a TypeScript client!

Install it with the following:

npm install --save @eventstore/db-client@2.x.x

Next, create a new file named src/event-store.ts with the following contents:

import { EventStoreDBClient, FORWARDS, START } from "@eventstore/db-client";

const client = EventStoreDBClient.connectionString(
  "esdb://localhost:2113?tls=false"
);

const connect = async () => {
  await client.readAll({
    direction: FORWARDS,
    fromPosition: START,
    maxCount: 1
  });
}

export {
  client, connect
};

Next, open up src/main.ts and replace with the following:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { connect as connectToEventStore } from './event-store';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await connectToEventStore();
  await app.listen(3000);
}
bootstrap();

This will make sure that your application connects to the EventStoreDB instance on application startup.

Building A Read-Model

Before we look at building out a UI, we first want to create a read-model for our index page to display. We're going to show a list of generic "purchases."

Create a file under src/views.ts and replace it with the following:

import { FORWARDS, START } from '@eventstore/db-client';
import { client as eventStore } from './event-store';

interface Purchase {
  purchaseId: string;
  name: string;
  amount: number;
  wasRefunded: boolean;
}

const getAllPurchases = async () => {
  const purchases: Purchase[] = [];
  const getPurchaseById = createGetPurchaseById(purchases);

  const events = eventStore.readAll({
    direction: FORWARDS,
    fromPosition: START,
    maxCount: 1000
  });

  for await (const { event } of events) {
    const data: any = event.data;

    switch(event?.type) {
      case "ProductPurchased":        
        purchases.push({
          purchaseId: data.purchaseId,
          amount: data.amount,
          name: data.name,
          wasRefunded: false
        });
        break;
      
      case "ProductRefunded":
        getPurchaseById(data.purchaseId).wasRefunded = true;
        break;
    }
  }

  return purchases;
}

const createGetPurchaseById = (purchases : Purchase[]) => 
  (purchaseId: string) => purchases.filter(p => p.purchaseId == purchaseId)[0];

export {
  getAllPurchases,
  Purchase
}

Since we want to display all of the purchases in the application, you're reading all the events in the event store (instead of from a specific stream) and checking against the two domain events we are concerned about: ProductPurchased and ProductRefunded.

Implementing A Web Controller Logic

For this next step, you're going to create a web controller that allows you to:

  1. View all purchases
  2. Create a new purchase
  3. Refund an existing purchase

This will show you how to create new purchases, view all purchases across multiple streams and then interact with an existing purchase. I find this is where people who struggle with understanding event sourcing start to "connect the dots."

First, you'll need to install the following npm package:

npm install --save uuid@8.x.x

Then, open up src/app.controller.ts and replace the entire contents with the following:


import { Body, Controller, Get, Post, Redirect } from '@nestjs/common';
import { getAllPurchases, Purchase } from './views';
import { v4 as uuid } from 'uuid';
import { jsonEvent } from '@eventstore/db-client';
import { client as eventStore } from './event-store';

interface CreatePurchaseRequest {
  amount: string;
  name: string;
}

interface RefundPurchaseRequest {
  purchaseId: string;
}

@Controller()
export class AppController {
  constructor() {}

  @Get()
  async getPurchases(): Promise<string> {
    const purchases = await getAllPurchases();

    let html = listAllPurchasesUL(purchases);
    html += renderCreatePurchaseForm();

    return html;
  }

  @Post()
  @Redirect()
  async create(@Body() req: CreatePurchaseRequest) {
    const purchaseId = uuid();
    const purchasedEvent = jsonEvent({
      type: 'ProductPurchased',
      data: {
        purchaseId: purchaseId,
        amount: req.amount,
        name: req.name,
      },
    });

    await eventStore.appendToStream(purchaseId, [purchasedEvent]);

    return {
      url: '/',
    };
  }

  @Post('/refund')
  @Redirect()
  async refund(@Body() req: RefundPurchaseRequest) {
    const refundEvent = jsonEvent({
      type: 'ProductRefunded',
      data: {
        purchaseId: req.purchaseId,
      },
    });

    await eventStore.appendToStream(req.purchaseId, [refundEvent]);

    return {
      url: '/',
    };
  }
}

function listAllPurchasesUL(purchases: Purchase[]) {
  let html = `
    <h1>Existing Purchases:</h1>
    <ul>`;
    for (const p of purchases) {
      html += `
        <li style="${p.wasRefunded ? 'color: red' : ''}">
            <div>Purchase Id: ${p.purchaseId}</div>
            <div>Amount: ${p.amount}</div>
            <div>Name: ${p.name}</div>
            ${p.wasRefunded ? '<div>Was refunded</div>' : ''}
            ${renderRefundButton(p)}    
            <hr />  
        </li>`;
    }
    html += `</ul>`;

    return html;
}

function renderRefundButton(p: Purchase): string {
  if (p.wasRefunded) return '';

  return `
      <form action="/refund" method="POST">
        <input type="hidden" name="purchaseId" value="${p.purchaseId}" />
        <button type="submit">Refund</button>
      </form> 
  `;
}

function renderCreatePurchaseForm() {
  return `
    <h1>Make A Purchase:</h1>
      <form action="/" method="POST">
        <label for="amount">Amount:</label>
        <input type="number" name="amount" />

        <label for="name">Name:</label>
        <input name="name" />

        <button type="submit">Create</button>
      </form>        
    `;
}

That's a bit of code, I know. But that's everything you need! If you run this via npm run start then you should be able to view purchases, create, and refund them.

You don't need a fancy HTML templating library to get started (although you can use one when building a real-world application).

Look at the CreatePurchaseRequest method, for example. Other than the missing input validation, checking for permissions, etc., the actual code to write a new event to EventStoreDB is simple:

const purchaseId = uuid();
const purchasedEvent = jsonEvent({
 type: 'ProductPurchased',
 data: {
  purchaseId: purchaseId,
  amount: req.amount,
  name: req.name,
 },
});

await eventStore.appendToStream(purchaseId, [purchasedEvent]);

Likewise, the code to refund an existing purchase is even simpler:

const refundEvent = jsonEvent({
 type: 'ProductRefunded',
 data: {
  purchaseId: req.purchaseId,
 },
});

await eventStore.appendToStream(req.purchaseId, [refundEvent]);

Conclusion

Using modern web tools like NestJS in tandem with EventStoreDB can help you build event-sourced applications very quickly. By simply replaying events from the event store in real-time, the logic for reading and writing is very straightforward!

When you grow to thousands or millions of events, then you can introduce tools like separate read-models, event subscriptions and snapshots. But you can always get started by just reading from the event store in real-time.

If you've ever wanted to build an event-sourced application, then using TypeScript and NestJS can be a great choice!


Photo of James Hickey

James Hickey James is a Microsoft MVP with a background building web and mobile applications in fintech and insurance industries. He's the author of "Refactoring TypeScript" and creator of open-source tools for .NET called Coravel. He lives in Canada, just ten minutes from the highest tides in the world.


Comment on this post