This the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Tutorials

Tutorials for the Dolittle platform

1 - Getting started

Get started with the Dolittle platform

Welcome to the tutorial for Dolittle, where you learn how to write a Microservice that keeps track of foods prepared by the chefs.

After this tutorial you will have:

Use the tabs to switch between the C# and TypeScript code examples. Full tutorial code available on GitHub for C# and TypeScript.

For a deeper dive into our Runtime, check our overview.

Setup

This tutorial expects you to have a basic understanding of C#, .NET and Docker.

Prerequisites:

Setup a .NET Core console project:

$ dotnet new console
$ dotnet add package Dolittle.SDK 

This tutorial expects you to have a basic understanding of TypeScript, npm and Docker.

Prerequisites:

Setup a TypeScript NodeJS project using your favorite package manager. For this tutorial we use npm.

$ npm init
$ npm -D install typescript ts-node
$ npm install @dolittle/sdk
$ npx tsc --init --experimentalDecorators

Create an EventType

First we’ll create an EventType that represents that a dish has been prepared. Events represents changes in the system, a “fact that has happened”. As the event “has happened”, it’s immutable by definition, and we should name it in the past tense accordingly.

An EventType is a class that defines the properties of the event. It acts as a wrapper for the type of the event.

// DishPrepared.cs
using Dolittle.SDK.Events;

namespace Kitchen 
{
    [EventType("1844473f-d714-4327-8b7f-5b3c2bdfc26a")]
    public class DishPrepared
    {
        public DishPrepared (string dish, string chef)
        {
            Dish = dish;
            Chef = chef;
        }
        public string Dish { get; }
        public string Chef { get; }
    }
}

The GUID given in the [EventType()] attribute is the EventTypeId, which is used to identify this EventType type in the Runtime.

// DishPrepared.ts
import { eventType } from '@dolittle/sdk.events';

@eventType('1844473f-d714-4327-8b7f-5b3c2bdfc26a')
export class DishPrepared {
    constructor(readonly Dish: string, readonly Chef: string) {}
}

The GUID given in the @eventType() decorator is the EventTypeId, which is used to identify this EventType in the Runtime.

Create an EventHandler

Now we need something that can react to dishes that have been prepared. Let’s create an EventHandler which prints the prepared dishes to the console.

// DishHandler.cs
using System;
using Dolittle.SDK.Events;
using Dolittle.SDK.Events.Handling;

namespace Kitchen
{
    [EventHandler("f2d366cf-c00a-4479-acc4-851e04b6fbba")]
    public class DishHandler
    {
        public void Handle(DishPrepared @event, EventContext eventContext)
        {
            Console.WriteLine($"{@event.Chef} has prepared {@event.Dish}. Yummm!");
        }
    }
}

When an event is committed, the Handle() method will be called for all the EventHandlers that handle that EventType.

The [EventHandler()] attribute identifies this event handler in the Runtime, and is used to keep track of which event it last processed, and retrying the handling of an event if the handler fails (throws an exception).

// DishHandler.ts
import { EventContext } from '@dolittle/sdk.events';
import { eventHandler, handles } from '@dolittle/sdk.events.handling';
import { DishPrepared } from './DishPrepared';

@eventHandler('f2d366cf-c00a-4479-acc4-851e04b6fbba')
export class DishHandler {

    @handles(DishPrepared)
    dishPrepared(event: DishPrepared, eventContext: EventContext) {
        console.log(`${event.Chef} has prepared ${event.Dish}. Yummm!`);
    }
}

When an event is committed, the method decorated with the @handles(EventType) for that specific EventType will be called.

The @eventHandler() decorator identifies this event handler in the Runtime, and is used to keep track of which event it last processed, and retrying the handling of an event if the handler fails (throws an exception).

Connect the client and commit an event

Let’s build a client that connects to the Runtime for a Microservice with the id "f39b1f61-d360-4675-b859-53c05c87c0e6". This sample Microservice is pre-configured in the development Docker image.

While configuring the client we register the EventTypes and EventHandlers so that the Runtime knows about them. Then we can prepare a delicious taco and commit it to the EventStore for the specified tenant.

// Program.cs
using Dolittle.SDK;
using Dolittle.SDK.Tenancy;

namespace Kitchen
{
    class Program
    {
        public static void Main()
        {
            var client = Client
                .ForMicroservice("f39b1f61-d360-4675-b859-53c05c87c0e6")
                .WithEventTypes(eventTypes =>
                    eventTypes.Register<DishPrepared>())
                .WithEventHandlers(builder =>
                    builder.RegisterEventHandler<DishHandler>())
                .Build();

            var preparedTaco = new DishPrepared("Bean Blaster Taco", "Mr. Taco");

            client.EventStore
                .ForTenant(TenantId.Development)
                .Commit(eventsBuilder =>
                    eventsBuilder
                        .CreateEvent(preparedTaco)
                        .FromEventSource("bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9"));

            // Blocks until the EventHandlers are finished, i.e. forever
            client.Start().Wait();
        }
    }
}

The GUID given in FromEventSource() is the EventSourceId, which is used to identify where the events come from.

// index.ts
import { Client } from '@dolittle/sdk';
import { TenantId } from '@dolittle/sdk.execution';
import { DishPrepared } from './DishPrepared';
import { DishHandler } from './DishHandler';

const client = Client
    .forMicroservice('f39b1f61-d360-4675-b859-53c05c87c0e6')
    .withEventTypes(eventTypes =>
        eventTypes.register(DishPrepared))
    .withEventHandlers(builder =>
        builder.register(DishHandler))
    .build();

const preparedTaco = new DishPrepared('Bean Blaster Taco', 'Mr. Taco');

client.eventStore
    .forTenant(TenantId.development)
    .commit(preparedTaco, 'bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9');

The GUID given in the commit() call is the EventSourceId, which is used to identify where the events come from.

Start the Dolittle environment

Start the Dolittle environment with all the necessary dependencies with the following command:

$ docker run -p 50053:50053 -p 27017:27017 dolittle/runtime:latest-development

This will start a container with the Dolittle Development Runtime on port 50053 and a MongoDB server on port 27017. The Runtime handles committing the events and the event handlers while the MongoDB is used for persistence.

Run your microservice

Run your code, and get a delicious serving of taco:

$ dotnet run
Mr. Taco has prepared Bean Blaster Taco. Yummm!

$ npx ts-node index.ts
Mr. Taco has prepared Bean Blaster Taco. Yummm!

What’s next

2 - Aggregates

Get started with Aggregates

Welcome to the tutorial for Dolittle, where you learn how to write a Microservice that keeps track of foods prepared by the chefs.

After this tutorial you will have:

  • a running Dolittle environment with a Runtime and a MongoDB,
  • a Microservice that commits and handles Events and
  • a stateful aggregate root that applies events and is controlled by an invariant

Use the tabs to switch between the C# and TypeScript code examples. Full tutorial code available on GitHub for C# and TypeScript.

Pre requisites

This tutorial builds directly upon and that you have gone through our getting started guide; done the setup, created the EventType, EventHandler and connected the client

Create an AggregateRoot

An aggregate root is a class that upholds the rules (invariants) for the aggregates of that aggregate root. It encapsulates the domain objects, enforces business rules, and ensures that the aggregate can’t be put into an invalid state. The aggregate root usually exposes methods that creates and applies an event.

There are essentially two types of aggregate roots, stateless and stateful. The aggregate root in this example is stateful because it tracks a value called _counter that is used to control the invariant that no more than two dishes can be prepared. Stateful aggregate roots have On() methods that takes in a single parameter, an event type. Each time an event of that type is applied to this aggregate root the On method will be called. It is important that the On methods only updates the internal state of the aggregate root!

// Kitchen.cs

using System;
using Dolittle.SDK.Aggregates;
using Dolittle.SDK.Events;

namespace Kitchen
{
    [AggregateRoot("01ad9a9f-711f-47a8-8549-43320f782a1e")]
    public class Kitchen : AggregateRoot
    {
        int _counter;

        public Kitchen(EventSourceId eventSource)
            : base(eventSource)
        {
        }

        public void PrepareDish(string dish, string chef)
        {
            if (_counter >= 2) throw new Exception("Cannot prepare more than 2 dishes");
            Apply(new DishPrepared(dish, chef));
            Console.WriteLine($"Kitchen Aggregate {EventSourceId} has applied {_counter} {typeof(DishPrepared)} events");
        }

        void On(DishPrepared @event)
            => _counter++;
    }
}

The GUID given in the [AggregateRoot()] attribute is the AggregateRootId, which is used to identify this AggregateRoot in the Runtime.

// Kitchen.ts
import { aggregateRoot, AggregateRoot, on } from '@dolittle/sdk.aggregates';
import { EventSourceId } from '@dolittle/sdk.events';
import { DishPrepared } from './DishPrepared';

@aggregateRoot('01ad9a9f-711f-47a8-8549-43320f782a1e')
export class Kitchen extends AggregateRoot {
    private _counter: number = 0;

    constructor(eventSourceId: EventSourceId) {
        super(eventSourceId);
    }

    prepareDish(dish: string, chef: string) {
        if (this._counter >= 2) throw new Error("Cannot prepare more than 2 dishes");
        this.apply(new DishPrepared(dish, chef));
        console.log(`Kitchen Aggregate ${this.eventSourceId} has applied ${this._counter} ${DishPrepared.name} events`);
    }


    @on(DishPrepared)
    onDishPrepared(event: DishPrepared) {
        this._counter++;
    }
}

The GUID given in the @aggregateRoot() decorator is the AggregateRootId, which is used to identify this AggregateRoot in the Runtime.

Apply the event through an aggregate of the Kitchen aggregate root

Let’s expand upon the client built in the getting started guide. But instead of committing the event to the event store directly we perform an action on the aggregate that eventually applies and commits the event.

// Program.cs
using Dolittle.SDK;
using Dolittle.SDK.Tenancy;

namespace Kitchen
{
    class Program
    {
        public static void Main()
        {
            var client = Client
                .ForMicroservice("f39b1f61-d360-4675-b859-53c05c87c0e6")
                .WithEventTypes(eventTypes =>
                    eventTypes.Register<DishPrepared>())
                .WithEventHandlers(builder =>
                    builder.RegisterEventHandler<DishHandler>())
                .Build();

            client
                .AggregateOf<Kitchen>("bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9", _ => _.ForTenant(TenantId.Development))
                .Perform(kitchen => kitchen.PrepareDish("Bean Blaster Taco", "Mr. Taco"));

            // Blocks until the EventHandlers are finished, i.e. forever
            client.Start().Wait();
        }
    }
}

The GUID given in AggregateOf<Kitchen>() is the EventSourceId, which is used to identify the aggregate of the aggregate root to perform the action on.

// index.ts
import { Client } from '@dolittle/sdk';
import { TenantId } from '@dolittle/sdk.execution';
import { DishPrepared } from './DishPrepared';
import { DishHandler } from './DishHandler';
import { Kitchen } from './Kitchen';

(async () => {
    const client = Client
        .forMicroservice('f39b1f61-d360-4675-b859-53c05c87c0e6')
        .withEventTypes(eventTypes =>
            eventTypes.register(DishPrepared))
        .withEventHandlers(builder =>
            builder.register(DishHandler))
        .build();

    await client
        .aggregateOf(Kitchen, 'bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9', _ => _.forTenant(TenantId.development))
        .perform(kitchen => kitchen.prepareDish('Bean Blaster Taco', 'Mr. Taco'));

    console.log('Done');
})();

The GUID given in the aggregateOf() call is the EventSourceId, which is used to identify the aggregate of the aggregate root to perform the action on.

Start the Dolittle environment

Start the Dolittle environment with all the necessary dependencies with the following command:

$ docker run -p 50053:50053 -p 27017:27017 dolittle/runtime:latest-development

This will start a container with the Dolittle Development Runtime on port 50053 and a MongoDB server on port 27017. The Runtime handles committing the events and the event handlers while the MongoDB is used for persistence.

Run your microservice

Run your code, and get a delicious serving of taco:

$ dotnet run
Mr. Taco has prepared Bean Blaster Taco. Yummm!

$ npx ts-node index.ts
Mr. Taco has prepared Bean Blaster Taco. Yummm!

What’s next

3 - Projections

Get started with Projections

Welcome to the tutorial for Projections in Dolittle, where you learn how to write a Microservice that keeps track of food prepared by the chefs. After this tutorial you will have:

Use the tabs to switch between the C# and TypeScript code examples. Full tutorial code available on GitHub for C# and TypeScript.

Setup

This tutorial builds directly upon the getting started guide and the files from the it.

Prerequisites:

Before getting started, your directory should look something like this:

└── Projections/
    ├── DishHandler.cs
    ├── DishPrepared.cs
    ├── Program.cs
    └── Projections.csproj

Prerequisites:

Before getting started, your directory should look something like this:

└── projections/
    ├── .eslintrc
    ├── DishHandler.ts
    ├── DishPrepared.ts
    ├── index.ts
    ├── package.json
    └── tsconfig.json

Start the Dolittle environment

Start the Dolittle environment with all the necessary dependencies (if you didn’t have it running already) with the following command:

$ docker run -p 50053:50053 -p 27017:27017 dolittle/runtime:latest-development

This will start a container with the Dolittle Development Runtime on port 50053 and a MongoDB server on port 27017. The Runtime handles committing the events and the projections, while the MongoDB is used for persistence.

Create a DishCounter Projection

First, we’ll create a Projection that keeps track of the dishes and how many times the chefs have prepared them. Projections are a special type of event handler that mutate a read model based on incoming events.

// DishCounter.cs
using Dolittle.SDK.Projections;

namespace Kitchen
{
    [Projection("98f9db66-b6ca-4e5f-9fc3-638626c9ecfa")]
    public class DishCounter
    {
        public int NumberOfTimesPrepared = 0;

        [KeyFromProperty("Dish")]
        public void On(DishPrepared @event, ProjectionContext context)
        {
            NumberOfTimesPrepared ++;
        }
    }
}

The [Projection()] attribute identifies this Projection in the Runtime, and is used to keep track of the events that it processes, and retrying the handling of an event if the handler fails (throws an exception). If the Projection is changed somehow (eg. a new On() method or the EventType changes), it will replay all of its events.

When an event is committed, the On() method is called for all the Projections that handle that EventType. The attribute [KeyFromEventProperty()] defines what property on the event will be used as the read model’s key (or id). You can choose the [KeyFromEventSource], [KeyFromPartitionId] or a property from the event with [KeyFromEventProperty(@event => @event.Property)].

// DishCounter.ts
import { ProjectionContext, projection, on } from '@dolittle/sdk.projections';
import { DishPrepared } from './DishPrepared';

@projection('98f9db66-b6ca-4e5f-9fc3-638626c9ecfa')
export class DishCounter {
    numberOfTimesPrepared: number = 0;

    @on(DishPrepared, _ => _.keyFromProperty('Dish'))
    dishPrepared(event: DishPrepared, projectionContext: ProjectionContext) {
        this.numberOfTimesPrepared ++;
    }
}

The @projection() decorator identifies this Projection in the Runtime, and is used to keep track of the events that it processes, and retrying the handling of an event if the handler fails (throws an exception). If the Projection is changed somehow (eg. a new @on() decorator or the EventType changes), it will replay all of it’s events.

When an event is committed, the method decoratored with @on() will be called for all the Projections that handle that EventType. The second parameter in the @on decorator is a callback function, that defines what property on the event will be used as the read model’s key (or id). You can choose either _ => _.keyFromEventSource(), _ => _.keyFromPartitionId() or a property from the event with _ => _.keyFromProperty('propertyName').

Register and get the DishCounter Projection

Let’s register the projection, commit new DishPrepared events and get the projection to see how it reacted.

// Program.cs
using System;
using System.Threading.Tasks;
using Dolittle.SDK;
using Dolittle.SDK.Tenancy;

namespace Kitchen
{
    class Program
    {
        public async static Task Main()
        {
            var client = Client
                .ForMicroservice("f39b1f61-d360-4675-b859-53c05c87c0e6")
                .WithEventTypes(eventTypes =>
                    eventTypes.Register<DishPrepared>())
                .WithEventHandlers(builder =>
                    builder.RegisterEventHandler<DishHandler>())
                .WithProjections(builder => 
                    builder.RegisterProjection<DishCounter>())
                .Build();

            var started = client.Start();

            var eventStore = client.EventStore.ForTenant(TenantId.Development);

            await eventStore.Commit(_ =>
                _.CreateEvent(new DishPrepared("Bean Blaster Taco", "Mr. Taco"))
                .FromEventSource("bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9"))
                .ConfigureAwait(false);
            await eventStore.Commit(_ =>
                _.CreateEvent(new DishPrepared("Bean Blaster Taco", "Mrs. Tex Mex"))
                .FromEventSource("bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9"))
                .ConfigureAwait(false);
            await eventStore.Commit(_ =>
                _.CreateEvent(new DishPrepared("Avocado Artillery Tortilla", "Mr. Taco"))
                .FromEventSource("bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9"))
                .ConfigureAwait(false);
            await eventStore.Commit(_ =>
                _.CreateEvent(new DishPrepared("Chili Canon Wrap", "Mrs. Tex Mex"))
                .FromEventSource("bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9"))
                .ConfigureAwait(false);

            await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);

            var dishes = await client.Projections
                .ForTenant(TenantId.Development)
                .GetAll<DishCounter>().ConfigureAwait(false);

            foreach (var (dish, state) in dishes) {
                 Console.WriteLine($"The kitchen has prepared {dish} {state.State.NumberOfTimesPrepared} times");
            }

            // Blocks until the EventHandlers are finished, i.e. forever
            await started.ConfigureAwait(false);
        }
    }
}

The GetAll<DishCounter>() method returns all Projections for that particular type. The returned object is a dictionary of each projections' key and that projections' current state.

The GUID given in FromEventSource() is the EventSourceId, which is used to identify where the events come from.

// index.ts
import { Client } from '@dolittle/sdk';
import { TenantId } from '@dolittle/sdk.execution';
import { DishPrepared } from './DishPrepared';
import { DishHandler } from './DishHandler';
import { DishCounter } from './DishCounter';

const client = Client
    .forMicroservice('f39b1f61-d360-4675-b859-53c05c87c0e6')
    .withEventTypes(eventTypes =>
        eventTypes.register(DishPrepared))
    .withEventHandlers(builder =>
        builder.register(DishHandler))
    .withProjections(builder =>
        builder.register(DishCounter))
    .build();

(async () => {
    const eventStore = client.eventStore.forTenant(TenantId.development);

    await eventStore.commit(new DishPrepared('Bean Blaster Taco', 'Mr. Taco'), 'bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9');
    await eventStore.commit(new DishPrepared('Bean Blaster Taco', 'Mrs. Tex Mex'), 'bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9');
    await eventStore.commit(new DishPrepared('Avocado Artillery Tortilla', 'Mr. Taco'), 'bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9');
    await eventStore.commit(new DishPrepared('Chili Canon Wrap', 'Mrs. Tex Mex'), 'bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9');

    setTimeout(async () => {
        for (const [dish, { state: counter }] of await client.projections.forTenant(TenantId.development).getAll(DishCounter)) {
            console.log(`The kitchen has prepared ${dish} ${counter.numberOfTimesPrepared} times`);
        }
    }, 1000);
})();

The getAll(DishCounter) method returns all Projections for that particular type. The returned object is a map of each projections' key and that projections' current state.

The GUID given in commit(event, 'event-source-id') is the EventSourceId, which is used to identify where the events come from.

Run your microservice

Run your code, and see the different dishes:

$ dotnet run
Mr. Taco has prepared Bean Blaster Taco. Yummm!
Mrs. Tex Mex has prepared Bean Blaster Taco. Yummm!
Mr. Taco has prepared Avocado Artillery Tortilla. Yummm!
Mrs. Tex Mex has prepared Chili Canon Wrap. Yummm!
The kitchen has prepared Bean Blaster Taco 6 times
The kitchen has prepared Avocado Artillery Tortilla 2 times
The kitchen has prepared Chili Canon Wrap 2 times

$ npx ts-node index.ts
Mr. Taco has prepared Bean Blaster Taco. Yummm!
Mrs. Tex Mex has prepared Bean Blaster Taco. Yummm!
Mr. Taco has prepared Avocado Artillery Tortilla. Yummm!
Mrs. Tex Mex has prepared Chili Canon Wrap. Yummm!
The kitchen has prepared Bean Blaster Taco 6 times
The kitchen has prepared Avocado Artillery Tortilla 2 times
The kitchen has prepared Chili Canon Wrap 2 times

Add Chef read model

Let’s add another read model to keep track of all the chefs and . This time let’s only create the class for the read model:

// Chef.cs
using System.Collections.Generic;

namespace Kitchen
{
    public class Chef
    {
        public string Name = "";
        public List<string> Dishes = new List<string>();
    }
}

// Chef.ts
export class Chef {
    constructor(
        public name: string = '',
        public dishes: string[] = []
    ) { }
}

Create and get the inline projection for Chef read model

You can also create a Projection inline in the client building steps instead of declaring a class for it.

Let’s create an inline Projection for the Chef read model:

// Program.cs
using System;
using System.Threading.Tasks;
using Dolittle.SDK;
using Dolittle.SDK.Tenancy;

namespace Kitchen
{
    class Program
    {
        public async static Task Main()
        {
            var client = Client
                .ForMicroservice("f39b1f61-d360-4675-b859-53c05c87c0e6")
                .WithEventTypes(eventTypes =>
                    eventTypes.Register<DishPrepared>())
                .WithEventHandlers(builder =>
                    builder.RegisterEventHandler<DishHandler>())
                .WithProjections(builder => {
                    builder.RegisterProjection<DishCounter>();

                    builder.CreateProjection("0767bc04-bc03-40b8-a0be-5f6c6130f68b")
                        .ForReadModel<Chef>()
                        .On<DishPrepared>(_ => _.KeyFromProperty(_ => _.Chef), (chef, @event, projectionContext) => {
                            chef.Name = @event.Chef;
                            if (!chef.Dishes.Contains(@event.Dish)) chef.Dishes.Add(@event.Dish);
                            return chef;
                        });
                })
                .Build();

            var started = client.Start();

            var eventStore = client.EventStore.ForTenant(TenantId.Development);

            await eventStore.Commit(_ =>
                _.CreateEvent(new DishPrepared("Bean Blaster Taco", "Mr. Taco"))
                .FromEventSource("bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9"))
                .ConfigureAwait(false);
            await eventStore.Commit(_ =>
                _.CreateEvent(new DishPrepared("Bean Blaster Taco", "Mrs. Tex Mex"))
                .FromEventSource("bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9"))
                .ConfigureAwait(false);
            await eventStore.Commit(_ =>
                _.CreateEvent(new DishPrepared("Avocado Artillery Tortilla", "Mr. Taco"))
                .FromEventSource("bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9"))
                .ConfigureAwait(false);
            await eventStore.Commit(_ =>
                _.CreateEvent(new DishPrepared("Chili Canon Wrap", "Mrs. Tex Mex"))
                .FromEventSource("bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9"))
                .ConfigureAwait(false);

            await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);

            var dishes = await client.Projections
                .ForTenant(TenantId.Development)
                .GetAll<DishCounter>().ConfigureAwait(false);

            foreach (var (dish, state) in dishes) {
                 Console.WriteLine($"The kitchen has prepared {dish} {state.State.NumberOfTimesPrepared} times");
            }

            var chef = await client.Projections
                .ForTenant(TenantId.Development)
                .Get<Chef>("Mrs. Tex Mex").ConfigureAwait(false);
            Console.WriteLine($"{chef.Key} has prepared {string.Join(", ", chef.State.Dishes)}");

            // Blocks until the EventHandlers are finished, i.e. forever
            await started.ConfigureAwait(false);
        }
    }
}

The Get<Chef>('key') method returns a Projection instance with that particular key. The key is declared by the KeyFromProperty(_.Chef) callback function on the On() method. In this case, the id of each Chef projection instance is based on the chefs name.

// index.ts
import { Client } from '@dolittle/sdk';
import { TenantId } from '@dolittle/sdk.execution';
import { DishPrepared } from './DishPrepared';
import { DishHandler } from './DishHandler';
import { DishCounter } from './DishCounter';
import { Chef } from './Chef';

const client = Client
    .forMicroservice('f39b1f61-d360-4675-b859-53c05c87c0e6')
    .withEventTypes(eventTypes =>
        eventTypes.register(DishPrepared))
    .withEventHandlers(builder =>
        builder.register(DishHandler))
    .withProjections(builder => {
        builder.register(DishCounter);

        builder.createProjection('0767bc04-bc03-40b8-a0be-5f6c6130f68b')
            .forReadModel(Chef)
            .on(DishPrepared, _ => _.keyFromProperty('Chef'), (chef, event, projectionContext) => {
                chef.name = event.Chef;
                if (!chef.dishes.includes(event.Dish)) chef.dishes.push(event.Dish);
                return chef;
            });
    })
    .build();

(async () => {
    const eventStore = client.eventStore.forTenant(TenantId.development);

    await eventStore.commit(new DishPrepared('Bean Blaster Taco', 'Mr. Taco'), 'bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9');
    await eventStore.commit(new DishPrepared('Bean Blaster Taco', 'Mrs. Tex Mex'),'bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9');
    await eventStore.commit(new DishPrepared('Avocado Artillery Tortilla', 'Mr. Taco'), 'bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9');
    await eventStore.commit(new DishPrepared('Chili Canon Wrap', 'Mrs. Tex Mex'), 'bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9');

    setTimeout(async () => {
        for (const [dish, { state: counter }] of await client.projections.forTenant(TenantId.development).getAll(DishCounter)) {
            console.log(`The kitchen has prepared ${dish} ${counter.numberOfTimesPrepared} times`);
        }

        const chef = await client.projections.forTenant(TenantId.development).get<Chef>(Chef, 'Mrs. Tex Mex');
        console.log(`${chef.key} has prepared ${chef.state.dishes}`);
    }, 1000);
})();

The get<Chef>(Chef, 'key') method returns a Projection instance with that particular key. The key is declared by the keyFromProperty('Chef') callback function on the on() method. In this case, the id of each Chef projection instance is based on the chefs name.

Run your microservice with the inline Chef projection

Run your code, and get a delicious serving of taco:

$ dotnet run
Mr. Taco has prepared Bean Blaster Taco. Yummm!
Mrs. Tex Mex has prepared Bean Blaster Taco. Yummm!
Mr. Taco has prepared Avocado Artillery Tortilla. Yummm!
Mrs. Tex Mex has prepared Chili Canon Wrap. Yummm!
The kitchen has prepared Bean Blaster Taco 6 times
The kitchen has prepared Avocado Artillery Tortilla 2 times
The kitchen has prepared Chili Canon Wrap 2 times
Mrs. Tex Mex has prepared Bean Blaster Taco,Chili Canon Wrap

$ npx ts-node index.ts
Mr. Taco has prepared Bean Blaster Taco. Yummm!
Mrs. Tex Mex has prepared Bean Blaster Taco. Yummm!
Mr. Taco has prepared Avocado Artillery Tortilla. Yummm!
Mrs. Tex Mex has prepared Chili Canon Wrap. Yummm!
The kitchen has prepared Bean Blaster Taco 6 times
The kitchen has prepared Avocado Artillery Tortilla 2 times
The kitchen has prepared Chili Canon Wrap 2 times
Mrs. Tex Mex has prepared Bean Blaster Taco,Chili Canon Wrap

What’s next

4 - Embeddings

Get started with Embeddings

Welcome to the tutorial for Embeddings in Dolittle, where you learn how to write a Microservice that event sources data coming from an external system. This external system is keeping track of the chefs in the kitchen, like a HR system. We’re going to be “freeing” this data by event sourcing with the help of Embeddings, so that other parts of the code can utilize it more freely.

Embeddings are like a combination of an Aggregate and a Projection. They have a collection of states (the read models) with unique keys (within the embedding). Whenever a new updated state is pushed/pulled from the external system, that updated state will be compared against its representative read model in the embedding. Whenever the states differ, the Runtime will call the embedding to resolve the difference into events. The embedding will then handle these events and modify its state to match the desired state.

The main point of embeddings is event source the changes coming from an external system. An embeddings read model exists so that we can commit the correct events and uphold its logic. Other event handlers, projections and microservices can then build upon these events.

Example of a embedding:

Diagram of embeddings

After this tutorial you will have:

  • a running Dolittle environment with a Runtime and a MongoDB, and
  • a Microservice that commits Events
  • an Embedding which creates events based on changes in the HR system.

Use the tabs to switch between the C# and TypeScript code examples. Full tutorial code available on GitHub for C# and TypeScript.

Setup

Setup is the same as in the getting started tutorial.

Prerequisites:

Before getting started, your directory should look something like this:

└── Kitchen/
    ├── Program.cs
    └── Kitchen.csproj

Prerequisites:

Before getting started, your directory should look something like this:

└── kitchen/
    ├── .eslintrc
    ├── index.ts
    ├── package.json
    └── tsconfig.json

Start the Dolittle environment

Start the Dolittle environment with all the necessary dependencies (if you didn’t have it running already) with the following command:

$ docker run -p 50053:50053 -p 27017:27017 dolittle/runtime:latest-development

This will start a container with the Dolittle Development Runtime on port 50053 and a MongoDB server on port 27017. The Runtime handles committing the events and the embeddings, while the MongoDB is used for persistence.

Create the events

In this example, we want to event source the data coming from a mocked HR system. We want to keep track of hired employees, their workplace and whenever they retire. Let’s create 3 different EventTypes to signal whenever an employee is hired, transferred or retires:

EmployeeHired:

// EmployeeHired.cs
using Dolittle.SDK.Events;

namespace Kitchen
{
    [EventType("8fdf45bc-f484-4348-bcb0-4d6f134aaf6c")]
    public class EmployeeHired
    {
        public string Name { get; set; }

        public EmployeeHired(string name) => Name = name;
    }
}

EmployeeTransferred:

// EmployeeTransferred.cs
using Dolittle.SDK.Events;

namespace Kitchen
{
    [EventType("b27f2a39-a2d4-43a7-9952-62e39cbc7ebc")]
    public class EmployeeTransferred
    {
        public string Name { get; set; }
        public string From { get; set; }
        public string To { get; set; }

        public EmployeeTransferred(string name, string from, string to)
        {
            Name = name;
            From = from;
            To = to;
        }
    }
}

EmployeeRetired:

// EmployeeRetired.cs
using Dolittle.SDK.Events;

namespace Kitchen
{
    [EventType("1932beb4-c8cd-4fee-9a7e-a92af3693510")]
    public class EmployeeRetired
    {
        public string Name { get; set; }

        public EmployeeRetired(string name) => Name = name;
    }
}

EmployeeHired:

//EmployeeHired.ts
import { eventType } from '@dolittle/sdk.events';

@eventType('8fdf45bc-f484-4348-bcb0-4d6f134aaf6c')
export class EmployeeHired {
    constructor(readonly name: string) {}
}

EmployeeTransferred:

//EmployeeTransferred.ts
import { eventType } from '@dolittle/sdk.events';

@eventType('b27f2a39-a2d4-43a7-9952-62e39cbc7ebc')
export class EmployeeTransferred {
    constructor(readonly name: string, readonly from: string, readonly to: string) {}
}

EmployeeRetired:

//EmployeeRetired.ts
import { eventType } from '@dolittle/sdk.events';

@eventType('1932beb4-c8cd-4fee-9a7e-a92af3693510')
export class EmployeeRetired {
    constructor(readonly name: string) {}
}

Create an Employee Embedding

In this example, we want to events source the data coming from a mocked HR system by using Embeddings. Let’s create an Embedding that keeps track of the changes coming from the HR system by committing and handling of the events we made earlier:

// Employee.cs
using Dolittle.SDK.Projections;

namespace Kitchen
{
    [Embedding("e5577d2c-0de7-481c-b5be-6ef613c2fcd6")]
    public class Employee
    {
        public string Name { get; set; } = "";
        public string Workplace { get; set; } = "Unassigned";

        public object ResolveUpdateToEvents(Employee updatedEmployee, EmbeddingContext context)
        {
            if (Name != updatedEmployee.Name)
            {
                return new EmployeeHired(updatedEmployee.Name);
            }
            else if (Workplace != updatedEmployee.Workplace)
            {
                return new EmployeeTransferred(Name, updatedEmployee.Workplace);
            }

            throw new NotImplementedException();
        }

        public object ResolveDeletionToEvents(EmbeddingContext context)
        {
            return new EmployeeRetired(Name);
        }

        public void On(EmployeeHired @event, EmbeddingProjectContext context)
        {
            Name = @event.Name;
        }

        public void On(EmployeeTransferred @event, EmbeddingProjectContext context)
        {
            Workplace = @event.To;
        }

        public ProjectionResult<Employee> On(EmployeeRetired @event, EmbeddingProjectContext context)
        {
            return ProjectionResult<Employee>.Delete;
        }
    }
}

The [Embedding()] attribute identifies this embedding in the Runtime, and is used to keep track of the events that it creates and processes, it’s state and the retrying the handling of an event if the handler fails (throws an exception).

ResolveUpdateToEvents() method will be called whenever the current state of the embeddings read model is different from the updated state. This method needs to return one or many events that will update the read model so that it moves “closer” to matching the desired state. The Runtime will then apply the returned events onto the embeddings On() methods. If the states still differ, it will call the ResolveUpdateToEvents() method again until the read models current state matches the updated state. At that point, the events will be committed to the Event Log. If the On() methods fail, or the Runtime detects that the embeddings state is looping, the process will be stopped and no events will be committed. This means that events will only be committed if they successfully result in the states matching.

The ResolveDeletionToEvents() method is the same, except the resulting events have to result in the read model being deleted. This is done by returning a ProjectionResult<Employee>.Delete in the corresponding On() method.

The committed events are always public Aggregate events. The AggregateRootId is the same as the EmbeddingId, and the EventSourceId is computed from the read models key.

Unlike projections, you don’t need to specify a KeySelector for the On() methods. The Runtime will automatically calculate a unique EventSourceId for the committed events based on the embeddings Key.

// Employee.ts
import { CouldNotResolveUpdateToEvents, embedding, EmbeddingContext, EmbeddingProjectContext, on, resolveDeletionToEvents, resolveUpdateToEvents } from '@dolittle/sdk.embeddings';
import { ProjectionResult } from '@dolittle/sdk.projections';
import { EmployeeHired } from './EmployeeHired';
import { EmployeeRetired } from './EmployeeRetired';
import { EmployeeTransferred } from './EmployeeTransferred';

@embedding('e5577d2c-0de7-481c-b5be-6ef613c2fcd6')
export class Employee {

    constructor(
        public name: string = '',
        public workplace: string = 'Unassigned') {
    }

    @resolveUpdateToEvents()
    resolveUpdateToEvents(updatedEmployee: Employee, context: EmbeddingContext) {
        if (this.name !== updatedEmployee.name) {
            return new EmployeeHired(updatedEmployee.name);
        } else if (this.workplace !== updatedEmployee.workplace) {
            return new EmployeeTransferred(this.name, this.workplace, updatedEmployee.workplace);
        }

        throw new CouldNotResolveUpdateToEvents();
    }

    @resolveDeletionToEvents()
    resolveDeletionToEvents(context: EmbeddingContext) {
        return new EmployeeRetired(this.name);
    }

    @on(EmployeeHired)
    onEmployeeHired(event: EmployeeHired, context: EmbeddingProjectContext) {
        this.name = event.name;
    }

    @on(EmployeeTransferred)
    onEmployeeTransferred(event: EmployeeTransferred, context: EmbeddingProjectContext) {
        this.workplace = event.to;
    }

    @on(EmployeeRetired)
    onEmployeeRetired(event: EmployeeRetired, context: EmbeddingProjectContext) {
        return ProjectionResult.delete;
    }
}

Register the Employee embedding, and update and delete a read model

Let’s register the new event types and the embedding. Then we can update and delete a read model from it.

// Program.cs
using System;
using System.Threading.Tasks;
using Dolittle.SDK;
using Dolittle.SDK.Tenancy;

namespace Kitchen
{
    class Program
    {
        public static async Task Main()
        {
            var client = Client
                .ForMicroservice("f39b1f61-d360-4675-b859-53c05c87c0e6")
                .WithEventTypes(eventTypes =>
                {
                    eventTypes.Register<EmployeeHired>();
                    eventTypes.Register<EmployeeTransferred>();
                    eventTypes.Register<EmployeeRetired>();
                })
                .WithEmbeddings(builder =>
                    builder.RegisterEmbedding<Employee>())
                .Build();
            _ = client.Start();

            // wait for the registration to complete
            await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);

            // mock of the state from the external HR system
            var updatedEmployee = new Employee
            {
                Name = "Mr. Taco",
                Workplace = "Street Food Taco Truck"
            };

            await client.Embeddings
                .ForTenant(TenantId.Development)
                .Update(updatedEmployee.Name, updatedEmployee);
            Console.WriteLine($"Updated {updatedEmployee.Name}.");

            await client.Embeddings
                .ForTenant(TenantId.Development)
                .Delete<Employee>(updatedEmployee.Name);
            Console.WriteLine($"Deleted {updatedEmployee.Name}.");

            // wait for the processing to finish before severing the connection
            await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
        }
    }
}

The Update() method tries to update the embeddings read model with the specified key to match the updated state by calling the embeddings ResolveUpdateToEvents() method. If no read model exists with the key, it will create one with the read model set to the embedding’s initial state.

The Delete() method will call the embeddings ResolveDeletionToEvents() for the specified key. This method then returns one or many events, which when handled will delete the read model.

// index.ts

import { Client } from '@dolittle/sdk';
import { TenantId } from '@dolittle/sdk.execution';
import { Employee } from './Employee';
import { EmployeeHired } from './EmployeeHired';
import { EmployeeRetired } from './EmployeeRetired';
import { EmployeeTransferred } from './EmployeeTransferred';

const client = Client
    .forMicroservice('f39b1f61-d360-4675-b859-53c05c87c0e6')
    .withEventTypes(eventTypes => {
        eventTypes.register(EmployeeHired);
        eventTypes.register(EmployeeTransferred);
        eventTypes.register(EmployeeRetired);
    })
    .withEmbeddings(builder => {
        builder.register(Employee);
    })
    .build();

(async () => {

    // wait for the registration to complete
    setTimeout(async () => {
        // mock of the state from the external HR system
        const updatedEmployee = new Employee(
            'Mr. Taco',
            'Street Food Taco Truck');

        await client.embeddings
            .forTenant(TenantId.development)
            .update(Employee, updatedEmployee.name, updatedEmployee);
        console.log(`Updated ${updatedEmployee.name}`);

        await client.embeddings
            .forTenant(TenantId.development)
            .delete(Employee, updatedEmployee.name);
        console.log(`Deleted ${updatedEmployee.name}`);
    }, 1000);
})();

The update() method tries to update the embeddings read model with the specified key to match the updated state by calling the embeddings @resolveUpdateToEvents() decorated method. If no read model exists with the key, it will create one with the read model set to the embedding’s initial state.

The delete() method will call the embeddings @resolveDeletionToEvents() decorated method for the specified key. This method then returns one or many events, which when handled will delete the read model.

Run your microservice

Let’s run the code! It should commit events to the event log, one for hiring "Mr. Taco", one for transferring him to "Street Food Taco Truck", and one for Mr. Tacos retirement.

$ dotnet run
Updated Mr. Taco.
Deleted Mr. Taco.

$ npx ts-node index.ts
Updated Mr. Taco.
Deleted Mr. Taco.

Check the events

Let’s check the committed events from the event log:

You can look at the events in the database through a database viewer (like MongoDB Compass) or by querying the database running in the dolittle/runtime:latest-development image through the mongo shell.

MongoDB Compass:

MongoDB Compass with 2 events

mongo shell:

$ docker exec <mongo-container> mongo event_store --quiet --eval 'db.getCollection("event-log").find({}, {Content: 1})'
{ "_id" : NumberDecimal("0"), "Content" : { "Name" : "Mr. Taco" } }
{ "_id" : NumberDecimal("1"), "Content" : { "Name" : "Mr. Taco", "From" : "Unassigned", "To" : "Street Food Taco Truck" } }
{ "_id" : NumberDecimal("2"), "Content" : { "Name" : "Mr. Taco" } }

Check the persisted embeddings

We can also check the state of the embeddings read models from the database. Embeddings are saved into a database defined in the resources.json (defaults to embeddings), and each embedding gets it’s unique collection named embedding-<embedding-id>. Deleted embeddings are “soft-deleted” with the IsRemoved property marking their deletion. If the read model is later updated, IsRemoved will be set to false.

MongoDB Compass:

MongoDB Compass showing mr taco read model

mongo shell:

$ docker exec <mongo-container> mongo embeddings --quiet --eval 'db.getCollection("embedding-e5577d2c-0de7-481c-b5be-6ef613c2fcd6").find().pretty()'
{
	"_id" : "Mr. Taco",
	"Content" : "{\"Name\":\"Mr. Taco\",\"Workplace\":\"Street Food Taco Truck\"}",
	"IsRemoved" : true,
	"Version" : NumberLong(3)
}

Get the Embeddings

You can also get the read models and keys for an embedding. This can be useful when figuring out what states still exist in the external system compared to the embeddings read models or when debugging.

For example, the HR system might only return the currently hired employees. Any employees not returned by the HR system but still in the embedding could then be marked as retired.

/*
setup builder and update the read model first
*/
var mrTaco = client.Embeddings
    .ForTenant(TenantId.Development)
    .Get<Employee>("Mr. Taco");
var allEmployees = client.Embeddings
    .ForTenant(TenantId.Development)
    .GetAll<Employee>();
var employeeKeys = client.Embeddings
    .ForTenant(TenantId.Development)
    .GetKeys<Employee>();

/*
setup builder and update the read model first
*/
const mrTaco = client.embeddings
    .forTenant(TenantId.development)
    .get(Employee, 'Mr. Taco');
const allEmployees = client.embeddings
    .forTenant(TenantId.development)
    .getAll(Employee);
cosnt employeeKeys = client.embeddings
    .forTenant(TenantId.development)
    .getKeys(Employee);

If you try to get an embedding that doesn’t exist, the Runtime will return you the initial state of the embedding.

What’s next

5 - Event Horizon

Get started with the Event Horizon

Welcome to the tutorial for Event Horizon, where you learn how to write a Microservice that produces public events of dishes prepared by chefs, and another microservice consumes those events.

After this tutorial you will have:

Use the tabs to switch between the C# and TypeScript code examples. Full tutorial code available on GitHub for C# and TypeScript.

Prerequisites

This tutorial builds directly upon the getting started guide and the files from the it.

Setup

This tutorial will have a setup with two microservices; one that produces public events, and a consumer that subscribes to those public events. Let’s make a folder structure that resembles that:

└── event-horizon-tutorial/
    ├── consumer/
    ├── producer/
    └── environment/
        └── docker-compose.yml

Go into both the consumer and the producer folders and initiate the project as we’ve gone through in our getting started guide. I.e copy over all the code from the getting started tutorial to the consumer and producer folders. You can choose different languages for the microservices if you want to.

We’ll come back to the docker-compose later in this tutorial.

Producer

Create a Public Filter

A public filter filters all public events that pass the filter into a public stream, which is special stream that another microservice can subscribe to.

A public filter is defined as a method that returns a partitioned filter result, which is an object with two properties:

  • a boolean that says whether the event should be included in the public stream
  • a partition id which is the partition that the event should belong to in the public stream.

Only public events get filtered through the public filters.

// Program.cs
using System;
using System.Threading.Tasks;
using Dolittle.SDK;
using Dolittle.SDK.Tenancy;
using Dolittle.SDK.Events;
using Dolittle.SDK.Events.Filters;

namespace Kitchen
{
    class Program
    {
        public static void Main()
        {
            var client = Client
                .ForMicroservice("f39b1f61-d360-4675-b859-53c05c87c0e6")
                .WithEventTypes(eventTypes =>
                    eventTypes.Register<DishPrepared>())
                .WithEventHandlers(builder =>
                    builder.RegisterEventHandler<DishHandler>())
                .WithFilters(filtersBuilder =>
                    filtersBuilder
                        .CreatePublicFilter("2c087657-b318-40b1-ae92-a400de44e507", filterBuilder =>
                            filterBuilder.Handle((@event, eventContext) =>
                            {
                                Console.WriteLine($"Filtering event {@event} to public stream");
                                return Task.FromResult(new PartitionedFilterResult(true, PartitionId.Unspecified));
                            })))
                .Build();
            // Rest of your code here...
        }
    }
}

// index.ts
import { Client } from '@dolittle/sdk';
import { EventContext, PartitionId } from '@dolittle/sdk.events';
import { PartitionedFilterResult } from '@dolittle/sdk.events.filtering';
import { TenantId } from '@dolittle/sdk.execution';
import { DishPrepared } from './DishPrepared';
import { DishHandler } from './DishHandler';

const client = Client
    .forMicroservice('f39b1f61-d360-4675-b859-53c05c87c0e6')
    .withEventTypes(eventTypes =>
        eventTypes.register(DishPrepared))
    .withEventHandlers(builder =>
        builder.register(DishHandler))
    .withFilters(filterBuilder =>
        filterBuilder
            .createPublicFilter('2c087657-b318-40b1-ae92-a400de44e507', fb =>
                fb.handle((event: any, context: EventContext) => {
                    console.log(`Filtering event ${JSON.stringify(event)} to public stream`);
                    return new PartitionedFilterResult(true, PartitionId.unspecified);
                })
            ))
    .build();
    // Rest of your code here...

Notice that the returned PartitionedFilterResult has true and an unspecified PartitionId (which is the same as an empty GUID). This means that this filter creates a public stream that includes all public events, and that they are put into the unspecified partition of that stream.

Commit the public event

Now that we have a public stream we can commit public events to start filtering them. Let’s commit a DishPrepared event as a public event from the producer microservice:

// Program.cs
using Dolittle.SDK;
using Dolittle.SDK.Tenancy;

namespace Kitchen
{
    class Program
    {
        public static void Main()
        {
            // Where you build the client...

            var preparedTaco = new DishPrepared("Bean Blaster Taco", "Mr. Taco");

            client.EventStore
                .ForTenant(TenantId.Development)
                .CommitPublicEvent(preparedTaco, "bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9")
                .GetAwaiter().GetResult();

            // Blocks until the EventHandlers are finished, i.e. forever
            client.Start().Wait();
        }
    }
}

// index.ts
import { Client } from '@dolittle/sdk';
import { TenantId } from '@dolittle/sdk.execution';
import { DishPrepared } from './DishPrepared';
import { DishHandler } from './DishHandler';

// Where you build the client...

const preparedTaco = new DishPrepared('Bean Blaster Taco', 'Mr. Taco');

client.eventStore
    .forTenant(TenantId.development)
    .commitPublic(preparedTaco, 'bfe6f6e4-ada2-4344-8a3b-65a3e1fe16e9');

})();

Now we have a producer microservice with a public stream of DishPrepared events.

Consumer

Subscribe to the public stream of events

Let’s create another microservice that subscribes to the producer’s public stream.

// Program.cs
using System;
using Dolittle.SDK;
using Dolittle.SDK.Tenancy;
using Dolittle.SDK.Events;

namespace Kitchen
{
    class Program
    {
        public static void Main()
        {
            var client = Client.ForMicroservice("a14bb24e-51f3-4d83-9eba-44c4cffe6bb9")
                .WithRuntimeOn("localhost", 50055)
                .WithEventTypes(eventTypes =>
                    eventTypes.Register<DishPrepared>())
                .WithEventHorizons(eventHorizons =>
                    eventHorizons.ForTenant(TenantId.Development, subscriptions =>
                        subscriptions
                            .FromProducerMicroservice("f39b1f61-d360-4675-b859-53c05c87c0e6")
                            .FromProducerTenant(TenantId.Development)
                            .FromProducerStream("2c087657-b318-40b1-ae92-a400de44e507")
                            .FromProducerPartition(PartitionId.Unspecified)
                            .ToScope("808ddde4-c937-4f5c-9dc2-140580f6919e")))
                .WithEventHandlers(_ =>
                    _.CreateEventHandler("6c3d358f-3ecc-4c92-a91e-5fc34cacf27e")
                        .InScope("808ddde4-c937-4f5c-9dc2-140580f6919e")
                        .Partitioned()
                        .Handle<DishPrepared>((@event, context) => Console.WriteLine($"Handled event {@event} from public stream")))
                .Build();
            // Blocks until the EventHandlers are finished, i.e. forever
            client.Start().Wait();
        }
    }
}

// index.ts
import { Client } from '@dolittle/sdk';
import { TenantId } from '@dolittle/sdk.execution';
import { PartitionId } from '@dolittle/sdk.events';
import { DishPrepared } from './DishPrepared';

const client = Client
    .forMicroservice('a14bb24e-51f3-4d83-9eba-44c4cffe6bb9')
    .withRuntimeOn('localhost', 50055)
    .withEventTypes(eventTypes =>
        eventTypes.register(DishPrepared))
    .withEventHorizons(_ => {
        _.forTenant(TenantId.development, ts =>
            ts.fromProducerMicroservice('f39b1f61-d360-4675-b859-53c05c87c0e6')
                .fromProducerTenant(TenantId.development)
                .fromProducerStream('2c087657-b318-40b1-ae92-a400de44e507')
                .fromProducerPartition(PartitionId.unspecified.value)
                .toScope('808ddde4-c937-4f5c-9dc2-140580f6919e'))})
    .withEventHandlers(eventHandlers =>
        eventHandlers
            .createEventHandler("6c3d358f-3ecc-4c92-a91e-5fc34cacf27e", _ =>
                _.inScope("808ddde4-c937-4f5c-9dc2-140580f6919e")
                .partitioned()
                .handle(DishPrepared, (event, context) => console.log(`Handled event ${JSON.stringify(event)} from public stream`))))
    .build();

Now we have a consumer microservice that:

  • Connects to another Runtime running on port 50055
  • Subscribes to the producer’s public stream with the id of 2c087657-b318-40b1-ae92-a400de44e507 (same as the producer’s public filter)
  • Puts those events into a Scope with id of 808ddde4-c937-4f5c-9dc2-140580f6919e
  • Handles them incoming events in a scoped event handler with an id of 6c3d358f-3ecc-4c92-a91e-5fc34cacf27e

There’s a lot of stuff going on the code so let’s break it down:

Connection to the Runtime

// Program.cs
.WithRuntimeOn("localhost", 50055)
// Rest of builder here...

// index.ts
.withRuntimeOn('localhost', 50055)
// Rest of builder here...

This line configures the hostname and port of the Runtime for this client. By default, it connects to the Runtimes default port of 50053 on localhost.

Since we in this tutorial will end up with two running instances of the Runtime, they will have to run with different ports. The producer Runtime will be running on the default 50053 port, and the consumer Runtime will be running on port 50055. We’ll see this reflected in the docker-compose.yml file later in this tutorial.

Event Horizon

// Program.cs
.WithEventHorizons(eventHorizons =>
    eventHorizons.ForTenant(TenantId.Development, subscriptions =>
        subscriptions
            .FromProducerMicroservice("f39b1f61-d360-4675-b859-53c05c87c0e6")
            .FromProducerTenant(TenantId.Development)
            .FromProducerStream("2c087657-b318-40b1-ae92-a400de44e507")
            .FromProducerPartition(PartitionId.Unspecified)
            .ToScope("808ddde4-c937-4f5c-9dc2-140580f6919e")))
// Rest of builder here...

// index.ts
.withEventHorizons(_ =>
    _.forTenant(TenantId.development, ts =>
        ts.fromProducerMicroservice('f39b1f61-d360-4675-b859-53c05c87c0e6')
            .fromProducerTenant(TenantId.development)
            .fromProducerStream('2c087657-b318-40b1-ae92-a400de44e507')
            .fromProducerPartition(PartitionId.unspecified.value)
            .toScope('808ddde4-c937-4f5c-9dc2-140580f6919e')))
// Rest of builder here...

Here we define an event horizon subscription. Each subscription is submitted and managed by the Runtime. A subscription defines:

When the consumer’s Runtime receives a subscription, it will send a subscription request to the producer’s Runtime. If the producer accepts that request, the producer’s Runtime will start sending the public stream over to the consumer’s Runtime, one event at a time.

The acceptance depends on two things:

  • The consumer needs to know where to access the other microservices, ie the URL address.
  • The producer needs to give formal Consent for a tenant in another microservice to subscribe to public streams of a tenant.

We’ll setup the consent later.

The consumer will receive events from the producer and put those events in a specialized event-log that is identified by the scope’s id, so that events received over the event horizon don’t mix with private events. We’ll talk more about the scope when we talk about the scoped event handler.

Scoped Event Handler

// Program.cs
.WithEventHandlers(_ =>
    _.CreateEventHandler("6c3d358f-3ecc-4c92-a91e-5fc34cacf27e")
        .InScope("808ddde4-c937-4f5c-9dc2-140580f6919e")
        .Partitioned()
        .Handle<DishPrepared>((@event, context) => Console.WriteLine($"Handled event {@event} from public stream")))
// Rest of builder here...

// index.ts
.withEventHandlers(eventHandlers =>
    eventHandlers
        .createEventHandler("6c3d358f-3ecc-4c92-a91e-5fc34cacf27e", _ =>
            _.inScope("808ddde4-c937-4f5c-9dc2-140580f6919e")
            .partitioned()
            .handle(DishPrepared, (event, context) => console.log(`Handled event ${JSON.stringify(event)} from public stream`))))
})
// Rest of builder here...

Here we use the opportunity to create an event handler inline by using the client’s builder function. This way we don’t need to create a class and register it as an event handler.

This code will create a partitioned event handler with id 6c3d358f-3ecc-4c92-a91e-5fc34cacf27e (same as from getting started) in a specific scope.

Remember, that the events from an event horizon subscription get put into a scoped event-log that is identified by the scope id. Having the scope id defined when creating an event handler signifies that it will only handle events in that scope and no other.

Setup your environment

Now we have the producer and consumer microservices Heads coded, we need to setup the environment for them to run in and configure their Runtimes to be connected.

Let’s go to the environment folder we created in the beginning of this tutorial. Here we’ll need to configure:

Resources

resources.json define a microservices event store. We have 2 microservices so they both need their own event store database. By default the database is called event_store.

Let’s create 2 files, consumer-resources.json and producer-resources.json:

//consumer-resources.json
{
    // the tenant to define this resource for
    "445f8ea8-1a6f-40d7-b2fc-796dba92dc44": {
        "eventStore": {
            "servers": [
                // hostname of the mongodb
                "mongo"
            ],
            // the database name for the event store
            "database": "consumer_event_store"
        }
    }
}
//producer-resources.json
{
    // the tenant to define this resource for
    "445f8ea8-1a6f-40d7-b2fc-796dba92dc44": {
        "eventStore": {
            "servers": [
                // hostname of the mongodb
                "mongo"
            ],
            // the database name for the event store
            "database": "producer_event_store"
        }
    }
}

Note that the development tenant is 445f8ea8-1a6f-40d7-b2fc-796dba92dc44 (same as TenantId.Development).

Endpoints

endpoints.json defines the private (where the SDK connects) and public port (where other Runtimes can connect) of the Runtime.

We can leave the producer with the default ports (50052 for public, 50053 for private), but let’s create consumer-endpoints.json to change the consumer’s ports:

//consumer-endpoints.json
{
    "public": {
        "port": 50054
    },
    "private": {
        "port": 50055
    }
}

The 50055 port is the port that we configured the consumer microservice earlier in the withRuntimeOn() method.

Microservices

microservices.json define where the producer microservices are so that the consumer can subscribe to them.

Let’s create a consumer-microservices.json file to define where the consumer can find the producer:

// consumer-microservices.json
{
    // the producer microservices id, hostname and port
    "f39b1f61-d360-4675-b859-53c05c87c0e6": {
        "host": "producer-runtime",
        "port": 50052
    }
}

event-horizon-consents.json defines the Consents that the producer gives to consumers.

Let’s create producer-event-horizon-consents.json where we give a consumer consent to subscribe to our public stream.

// producer-event-horizon-consents.json
{
    // the producer's tenant that gives the consent
    "445f8ea8-1a6f-40d7-b2fc-796dba92dc44": [
        {
            // the consumer's microservice and tenant to give consent to
            "microservice": "a14bb24e-51f3-4d83-9eba-44c4cffe6bb9",
            "tenant": "445f8ea8-1a6f-40d7-b2fc-796dba92dc44",
            // the producer's public stream and partition to give consent to subscribe to
            "stream": "2c087657-b318-40b1-ae92-a400de44e507",
            "partition": "00000000-0000-0000-0000-000000000000",
            // an identifier for this consent. This is random
            "consent": "ad57aa2b-e641-4251-b800-dd171e175d1f"
        }
    ]
}

Configure docker-compose.yml

Now we can glue all the configuration files together in the docker-compose.yml. The configuration files are mounted inside /app.dolittle/ inside the dolittle/runtime image.

version: '3.1'
services:
  mongo:
    image: dolittle/mongodb
    hostname: mongo
    ports:
      - 27017:27017
    logging:
      driver: none
 
  consumer-runtime:
    image: dolittle/runtime
    volumes:
      - ./consumer-resources.json:/app/.dolittle/resources.json
      - ./consumer-endpoints.json:/app/.dolittle/endpoints.json
      - ./consumer-microservices.json:/app/.dolittle/microservices.json
    ports:
      - 50054:50054
      - 50055:50055

  producer-runtime:
    image: dolittle/runtime
    volumes:
      - ./producer-resources.json:/app/.dolittle/resources.json
      - ./producer-event-horizon-consents.json:/app/.dolittle/event-horizon-consents.json
    ports:
      - 50052:50052
      - 50053:50053

Start the environment

Start the docker-compose with this command

$ docker-compose up

This will spin up a MongoDB container and two Runtimes.

Run your microservices

Run both the consumer and producer microservices in their respective folders, and see the consumer handle the events from the producer:

Producer

$ dotnet run
Filtering event EventHorizon.Producer.DishPrepared to public streams
Mr. Taco has prepared Bean Blaster Taco. Yummm!

Consumer

$ dotnet run
Handled event EventHorizon.Consumer.DishPrepared from public stream

Producer

$ npx ts-node index.ts
Filtering event {"Dish":"Bean Blaster Taco","Chef":"Mr. Taco"} to public stream
Mr. Taco has prepared Bean Blaster Taco. Yummm!

Consumer

$ npx ts-node index.ts
Handled event {"Dish":"Bean Blaster Taco","Chef":"Mr. Taco"} from public stream

What’s next