Quickstart ASP.NET Core
Pre-requisites
- .NET 8 or higher
- Docker Desktop or compatible
- MongoDB client (e.g. MongoDB Compass)
Objective
In this quickstart, you will create a simple solution that covers the most important aspects of getting started with Chronicle in an ASP.NET Core application.
The sample will focus on a straightforward and well-understood domain: a library.
You can find the complete working sample here. which also leverages common things from here.
Docker
Chronicle is available as a Docker Image. For local development, we recommend
using the development images. The latest-development
tag will get you the most recent version.
The development image includes a MongoDB server, so you don't need any additional setup.
To run the server as a daemon, execute the following command in your terminal:
docker run -d -p 27017:27017 -p 8080:8080 -p 35000:35000 cratis/chronicle:latest-development
If you prefer to have a Docker Compose file, we recommend the following setup with Aspire to give you open telemetry data:
services:
chronicle:
image: cratis/chronicle:latest-development
environment:
- OTEL_EXPORTER_OTLP_ENDPOINT=http://aspire-dashboard:18889
ports:
- 27017:27017
- 8080:8080
- 11111:11111
- 30000:30000
- 35000:35000
aspire-dashboard:
image: mcr.microsoft.com/dotnet/aspire-dashboard:latest
environment:
- DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true
- DOTNET_DASHBOARD_OTLP_ENDPOINT_URL=http://chronicle:18889
- ALLOW_UNSECURED_TRANSPORT=true
- DOTNET_ENVIRONMENT=Development
ports:
- 18888:18888
- 4317:18889
Setup project
Start by creating a folder for your project and then create a .NET web project inside this folder:
dotnet new web
Add a reference to the Chronicle ASP.NET Core package:
dotnet add package Cratis.Chronicle.AspNetCore
WebApplication
When using ASP.NET Core you typically use the WebApplicationBuilder
to build up your application.
This includes having an IOC (Inversion of Control) container setup for dependency injection of all your services.
Chronicle supports this paradigm out of the box, and there are convenience methods for hooking this up real easily.
In your Program.cs
simply change your setup to the following:
var builder = WebApplication.CreateBuilder(args)
.AddCratisChronicle(options => options.EventStore = "Quickstart");
The code adds Chronicle to your application and sets the name of the event store to use. In contrast to what you need to do when running bare-bone as shown in the console sample, all discovery and registration of artifacts will happen automatically.
var app = builder.Build();
app.UseCratisChronicle();
Events
Defining an event is straightforward. You can use either a C# class
or a record
type.
We recommend using a record
type because records are immutable, which aligns with the nature of an event.
To define an event type, simply add the [EventType]
attribute to the new type. This attribute allows the discovery system to automatically detect all event types. You can read more about event types here.
Below is a set of events we will use for our library sample.
[EventType]
public record UserOnboarded(string Name, string Email);
[EventType]
public record BookAddedToInventory(string Title, string Author, string ISBN);
[EventType]
public record BookBorrowed(Guid UserId);
Appending events
Once you have defined the events, you can start using them. Events represent state changes in your system, and you use them by appending them to an event sequence.
Chronicle provides a default event sequence called the event log. The event log is typically the main sequence you use, similar to the main
branch of a Git repository.
You can access the event log through a property on the IEventStore
type:
var eventLog = eventStore.EventLog;
The event log provides methods for appending single or multiple events to the event sequence. In this example, we will use the method for appending a single event.
The following code appends a couple of UserOnboarded
events to indicate that users have been onboarded to the system.
await eventLog.Append(Guid.NewGuid(), new UserOnboarded("Jane Doe", "jane@interwebs.net"));
await eventLog.Append(Guid.NewGuid(), new UserOnboarded("John Doe", "john@interwebs.net"));
Next, we want to append a couple of events to represent books being added to our inventory:
await eventLog.Append(Guid.NewGuid(), new BookAddedToInventory("Metaprogramming in C#: Automate your .NET development and simplify overcomplicated code", "Einar Ingebrigtsen", "978-1837635429"));
await eventLog.Append(Guid.NewGuid(), new BookAddedToInventory("Understanding Eventsourcing: Planning and Implementing scalable Systems with Eventmodeling and Eventsourcing", "Martin Dilger", "979-8300933043"));
Notice that the first parameter for the Append
method is the event source identifier.
This identifier uniquely represents the object we're working on, similar to a primary key in a database.
In our example, we are dealing with two concepts: user and book, so the identifiers will uniquely represent individual users and books.
After running your application, the events will be appended. You can verify this by opening the Chronicle workbench, which is included in the Chronicle development image. Open your browser and navigate to http://localhost:8080.
Then, go to the Quickstart event store and select Sequences. You should now be able to see the events:
Creating read state
Events represent all the changes that have occurred in our system. With event sourcing, we typically don't use the events directly to display the current state. Instead, we react to these changes to produce and update the current state. This current state is referred to as the read state, or more concretely, read models.
We use the events as the write state and the read models as the read state. The process of transforming events into read models is called projecting.
Chronicle supports multiple ways to project from one or more events to a read model.
Reactor
Creating a Reactor offers the most flexibility. It can be used in any scenario where you want to react to an event being appended. This means it can perform tasks beyond just producing the current state of your application. It is ideal for if-this-then-that scenarios but can also be used for data creation.
Let's start by defining a read model that will be used in the reducer.
public record User(Guid Id, string Name, string Email)
The following code reacts to the UserOnboarded
event and then creates a new User
and inserts into a MongoDB database.
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Reactors;
using MongoDB.Driver;
namespace Quickstart;
public class UsersReactor : IReactor
{
public async Task Onboarded(UserOnboarded @event, EventContext context)
{
var user = new User(Guid.Parse(context.EventSourceId), @event.Name, @event.Email);
var collection = Globals.Database.GetCollection<User>("users");
await collection.InsertOneAsync(user);
}
}
Note: The code leverages a
Globals
object that is found as part of the full sample and is configured with the MongoDB database to use.
The method Onboarded
is not defined by the IReactor
interface. The IReactor
interface serves as a marker interface for discovery purposes.
Methods in a reactor are convention-based, meaning they will be automatically detected and invoked as long as they adhere to the expected signature conventions.
Supported method signatures include:
void <MethodName>(EventType @event);
void <MethodName>(EventType @event, EventContext context);
Task <MethodName>(EventType @event);
Task <MethodName>(EventType @event, EventContext context);
Opening your database client, you should be able to see the users:
Reducer
If you don't need the flexibility of a Reactor and are only interested in producing the correct current state, a Reducer is the ideal choice. It functions similarly to a Reactor but abstracts away the database management, allowing Chronicle to handle it for you.
Let's begin by defining a read model that will be used in the reducer.
public record Book(Guid Id, string Title, string Author, string ISBN)
For the read model we will need code that produces the correct state.
The following code reacts to BookAddedToInventory
and produces the new state that should be persisted.
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Reducers;
namespace Quickstart.Common;
public class BooksReducer : IReducerFor<Book>
{
public Task<Book> Added(BookAddedToInventory @event, Book? initialState, EventContext context) =>
Task.FromResult(new Book(Guid.Parse(@context.EventSourceId), @event.Title, @event.Author, @event.ISBN));
}
The method Added
is not defined by the IReducerFor<>
interface. The IReducerFor<>
interface serves as a marker interface for discovery purposes.
It requires a generic argument specifying the type of the read model. Chronicle uses this type to gather information about properties and types for the underlying database.
Methods in a reducer are convention-based, meaning they will be automatically detected and invoked as long as they adhere to the expected signature conventions.
Supported method signatures include:
ReadModel <MethodName>(EventType @event, ReadModel? initialState);
ReadModel <MethodName>(EventType @event, , ReadModel? initialState, EventContext context);
Task<ReadModel> <MethodName>(EventType @event, ReadModel? initialState);
Task<ReadModel> <MethodName>(EventType @event, ReadModel? initialState, EventContext context);
Note: Chronicle only supports MongoDB for reducers at the moment.
Opening your database client, you should be able to see the books:
Projections
While reducers provide a programmatic, imperative approach to altering state in your system, projections offer a declarative approach. Although projections may not have the flexibility of a reducer or the power of a reactor, they possess unique capabilities that can be challenging to achieve with a reactor or reducer. For instance, projections support relationships such as one-to-many and one-to-one. When your goal is to produce state, projections will often be sufficient and will help you achieve your objectives more quickly.
Let's start by defining a read model that will be used in the projection.
public record BorrowedBook(Guid Id, Guid UserId, string Title, string User, DateTimeOffset Borrowed, DateTimeOffset Returned)
The projection declares operations to do in a fluent manner, effectively mapping out the events it is interested in and telling the projection engine what to do with them.
using Cratis.Chronicle.Projections;
namespace Quickstart.Common;
public class BorrowedBooksProjection : IProjectionFor<BorrowedBook>
{
public void Define(IProjectionBuilderFor<BorrowedBook> builder) => builder
.From<BookBorrowed>(from => from
.Set(m => m.UserId).To(e => e.UserId)
.Set(m => m.Borrowed).ToEventContextProperty(c => c.Occurred))
.Join<BookAddedToInventory>(bookBuilder => bookBuilder
.On(m => m.Id)
.Set(m => m.Title).To(e => e.Title))
.Join<UserOnboarded>(userBuilder => userBuilder
.On(m => m.UserId)
.Set(m => m.User).To(e => e.Name))
.RemovedWith<BookReturned>();
}
With this projection, we specify that from the BookBorrowed
event, we are interested in storing which user borrowed the book and the time it was borrowed.
The time is derived from the Occurred
property of the EventContext
. For display purposes, we want to show the name of the book and the name of the user
who borrowed it. Instead of performing complex queries to retrieve this information when getting the data, we can produce it as the events occur.
We achieve this by leveraging the Join functionality of the projection engine. By joining with another event that holds the necessary information,
we can populate the read model. To get the book title, we join with the BookBorrowed
event, and for the user's name, we join with the UserOnboarded
event.
Note: The lambdas provided to the projection builders are not callbacks that get executed; they are expressions representing properties in a type-safe manner, ensuring compile-time checks.
To verify that the projection works and produces the correct read models, we need code that generates the BookBorrowed
event:
eventLog.Append("92ac7c15-d04b-4d2b-b5b6-641ab681afe7", new BookBorrowed(Guid.Parse("5060c856-c0a1-454d-a261-1bbd1b1fbee2")));
Note: the EventSourceId type is of type of string while the
BookBorrowed
event is expecting aGuid
. Both of the hardcoded identifiers are typically things you would need to grab from the read model for a real system.
Running this and opening your database client, you should be able to see the borrowed books:
Using in APIs
Appending events is typically something you would be doing directly, or indirectly through an API exposed as a minimal API or a Controller.
app.MapPost("/api/books/{bookId}/borrow/{userId}", async (
[FromServices] IEventLog eventLog,
[FromRoute] Guid bookId,
[FromRoute] Guid userId) => await eventLog.Append(bookId, new BookBorrowed(userId)));
The code exposes an API endpoint that takes parameters for what book to borrow and what user is borrowing it.
It then appends a BookBorrowed
event. The bookId
is used as the event source, while
the userId
is part of the event.
Services
Chronicle will leverage the IOC container to get instances of any artifacts it discovers and will create instances of,
such as Reactors, Reducers and Projections.
In order for this to work, the artifacts needs to be registered as services. In your Program.cs
file you would add
service registrations for the artifacts you have:
builder.Services.AddTransient<UsersReactor>();
builder.Services.AddTransient<BooksReducer>();
builder.Services.AddTransient<BorrowedBooksProjection>();
builder.Services.AddTransient<OverdueBooksProjection>();
builder.Services.AddTransient<ReservedBooksProjection>();
This can become very tedious to do as your solution grows, Cratis Fundamentals offers a way couple of extension methods that will automatically hook this up:
builder.Services
.AddBindingsByConvention()
.AddSelfBindings();
The first statement adds service bindings by convention, which is basically any service that implements an interface with the same name only prefixed with I
will be automatically registered (IFoo
-> Foo
). The second statement automatically registers all
class instances as themselves, meaning you can then take dependencies to concrete types without having to register them.
You might run into issues with the Microsoft service provider not validating these correctly. Depending on your setup you might want to ignore the validation errors that will happen at runtime. This can be done by adding the following code:
builder.Host.UseDefaultServiceProvider(_ =>
{
_.ValidateScopes = false;
_.ValidateOnBuild = false;
});
The code turns of validation of wrong scoping of dependencies and general validation when it is building the service provider.
MongoDB
When leveraging the Reducer and Projection capabilities of Chronicle, your MongoDB Client needs to be configured to match how it produces documents and naming conventions. By adding the following code, you'll have something that matches:
BsonSerializer
.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
var pack = new ConventionPack
{
// We want to ignore extra elements that might be in the documents, Chronicle adds some metadata to the documents
new IgnoreExtraElementsConvention(true),
// Chronicle uses camelCase for element names, so we need to use this convention
new CamelCaseElementNameConvention()
};
ConventionRegistry.Register("conventions", pack, t => true);
In addition to this, since you're in an environment with dependency injection enabled you typically want to register the things being used to be able to take these in as dependencies into your types such as controllers, route handler or any other type that needs it.
For instance, for MongoDB, it can be very convenient to not have to think about the actual MongoClient
and just focus on
what you need; access to the specific database or specific collections.
The following code shows how to set this up for your WebApplicationBuilder
to enable that scenario.
builder.Services.AddSingleton<IMongoClient>(new MongoClient("mongodb://localhost:27017"));
builder.Services.AddSingleton(provider => provider.GetRequiredService<IMongoClient>().GetDatabase("Quickstart"));
builder.Services.AddTransient(provider => provider.GetRequiredService<IMongoDatabase>().GetCollection<User>("book"));
Note: The code adds service registrations for typed collections based on types found in the sample code.
With this you can now quite easily create a type that encapsulates getting the data that takes a specific collection as a dependency:
public class Books(IMongoCollection<Book> collection)
{
public IEnumerable<Book> GetAll() => collection.Find(Builders<Book>.Filter.Empty).ToList();
}