Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Eventuous/eventuous/llms.txt

Use this file to discover all available pages before exploring further.

This guide walks you through building a minimal but fully functional event-sourced booking service using Eventuous and KurrentDB. You will define a domain event, model aggregate state, implement business logic in an aggregate, wire up a command service, and expose an HTTP endpoint — all in a standard ASP.NET Core application. The code below comes directly from the official Eventuous samples.

Prerequisites

Before you start, make sure you have:
  • .NET 8 SDK or later
  • A running KurrentDB instance (see the Docker command below)
  • A new webapi project: dotnet new webapi -n Bookings
Start KurrentDB locally with Docker:
docker run --name kurrentdb-node -it -p 2113:2113 \
  kurrentplatform/kurrentdb:latest \
  --insecure --run-projections=All --enable-atom-pub-over-http
1

Install packages

Add the core Eventuous package and the KurrentDB store to your project:
dotnet add package Eventuous
dotnet add package Eventuous.KurrentDB
dotnet add package Eventuous.Extensions.AspNetCore
dotnet add package Eventuous.Extensions.DependencyInjection
If you want MongoDB projections and OpenTelemetry tracing, also add:
dotnet add package Eventuous.Projections.MongoDB
dotnet add package Eventuous.Diagnostics.OpenTelemetry
2

Define domain events

Events are the source of truth. They are immutable records decorated with [EventType], which controls the type name stored in the event stream. Create a static class to namespace your versioned events:
using Eventuous;
using NodaTime;

namespace Bookings.Domain.Bookings;

public static class BookingEvents {
    public static class V1 {
        [EventType("V1.RoomBooked")]
        public record RoomBooked(
            string         GuestId,
            string         RoomId,
            LocalDate      CheckInDate,
            LocalDate      CheckOutDate,
            float          BookingPrice,
            float          PrepaidAmount,
            float          OutstandingAmount,
            string         Currency,
            DateTimeOffset BookingDate
        );

        [EventType("V1.PaymentRecorded")]
        public record PaymentRecorded(
            float          PaidAmount,
            float          Outstanding,
            string         Currency,
            string         PaymentId,
            string         PaidBy,
            DateTimeOffset PaidAt
        );

        [EventType("V1.FullyPaid")]
        public record BookingFullyPaid(DateTimeOffset FullyPaidAt);

        [EventType("V1.Overpaid")]
        public record BookingOverpaid(DateTimeOffset OverpaidAt);
    }
}
Using versioned namespaces (V1, V2, …) lets you evolve events without breaking existing streams.
3

Define the typed identity

Eventuous requires every aggregate to have a strongly-typed identity that wraps a string. Derive from the Id abstract record:
using Eventuous;

namespace Bookings.Domain.Bookings;

public record BookingId(string Value) : Id(Value);
The Id base record validates that the value is non-null and non-whitespace, and provides an implicit conversion to string.
4

Define the aggregate state

State is a pure, immutable record that knows how to fold domain events. Derive from State<T> and register event handlers with On<TEvent>() in the constructor:
using System.Collections.Immutable;
using Eventuous;
using static Bookings.Domain.Bookings.BookingEvents;

namespace Bookings.Domain.Bookings;

public record BookingState : State<BookingState> {
    public string     GuestId     { get; init; } = null!;
    public RoomId     RoomId      { get; init; } = null!;
    public StayPeriod Period      { get; init; } = null!;
    public Money      Price       { get; init; } = null!;
    public Money      Outstanding { get; init; } = null!;
    public bool       Paid        { get; init; }

    public ImmutableArray<PaymentRecord> Payments { get; init; }
        = ImmutableArray<PaymentRecord>.Empty;

    internal bool HasPaymentBeenRegistered(string paymentId)
        => Payments.Any(x => x.PaymentId == paymentId);

    public BookingState() {
        On<V1.RoomBooked>(HandleBooked);
        On<V1.PaymentRecorded>(HandlePayment);
        On<V1.BookingFullyPaid>((state, _) => state with { Paid = true });
    }

    static BookingState HandleBooked(BookingState state, V1.RoomBooked booked)
        => state with {
            RoomId      = new(booked.RoomId),
            Period      = new(booked.CheckInDate, booked.CheckOutDate),
            GuestId     = booked.GuestId,
            Price       = new() { Amount = booked.BookingPrice,      Currency = booked.Currency },
            Outstanding = new() { Amount = booked.OutstandingAmount, Currency = booked.Currency }
        };

    static BookingState HandlePayment(BookingState state, V1.PaymentRecorded e)
        => state with {
            Outstanding = new() { Amount = e.Outstanding, Currency = e.Currency },
            Payments    = state.Payments.Add(new(e.PaymentId, new(e.PaidAmount, e.Currency)))
        };
}

public record PaymentRecord(string PaymentId, Money PaidAmount);
State is never mutated directlyOn<T> handlers return a new record via with expressions.
5

Define the aggregate

The aggregate contains business logic. Derive from Aggregate<TState> and call Apply() to record new events. Use EnsureDoesntExist() / EnsureExists() to guard against invalid operations:
using Eventuous;
using static Bookings.Domain.Bookings.BookingEvents;

namespace Bookings.Domain.Bookings;

public class Booking : Aggregate<BookingState> {
    public async Task BookRoom(
        string          guestId,
        RoomId          roomId,
        StayPeriod      period,
        Money           price,
        Money           prepaid,
        DateTimeOffset  bookedAt,
        IsRoomAvailable isRoomAvailable
    ) {
        EnsureDoesntExist();
        await EnsureRoomAvailable(roomId, period, isRoomAvailable);

        var outstanding = price - prepaid;

        Apply(new V1.RoomBooked(
            guestId, roomId,
            period.CheckIn, period.CheckOut,
            price.Amount, prepaid.Amount, outstanding.Amount,
            price.Currency, bookedAt
        ));

        MarkFullyPaidIfNecessary(bookedAt);
    }

    public void RecordPayment(
        Money paid, string paymentId, string paidBy, DateTimeOffset paidAt
    ) {
        EnsureExists();

        if (State.HasPaymentBeenRegistered(paymentId)) return;

        var outstanding = State.Outstanding - paid;

        Apply(new V1.PaymentRecorded(
            paid.Amount, outstanding.Amount, paid.Currency,
            paymentId, paidBy, paidAt
        ));

        MarkFullyPaidIfNecessary(paidAt);
        MarkOverpaid(paidAt);
    }

    void MarkFullyPaidIfNecessary(DateTimeOffset when) {
        if (State.Outstanding.Amount <= 0)
            Apply(new V1.BookingFullyPaid(when));
    }

    void MarkOverpaid(DateTimeOffset when) {
        if (State.Outstanding.Amount < 0)
            Apply(new V1.BookingOverpaid(when));
    }

    static async Task EnsureRoomAvailable(
        RoomId roomId, StayPeriod period, IsRoomAvailable isRoomAvailable
    ) {
        var available = await isRoomAvailable(roomId, period);
        if (!available) throw new DomainException("Room not available");
    }
}
Apply() appends the event to Changes, then immediately folds it into State so subsequent business-logic calls see the updated state within the same operation.
6

Define commands and the command service

Commands are plain records. The command service wires a command type to an expected aggregate state and an action:
// Commands.cs
namespace Bookings.Application;

public static class BookingCommands {
    public record BookRoom(
        string         BookingId,
        string         GuestId,
        string         RoomId,
        DateTime       CheckInDate,
        DateTime       CheckOutDate,
        float          BookingPrice,
        float          PrepaidAmount,
        string         Currency,
        DateTimeOffset BookingDate
    );

    public record RecordPayment(
        string BookingId, float PaidAmount,
        string Currency, string PaymentId, string PaidBy
    );
}
// BookingsCommandService.cs
using Bookings.Domain;
using Bookings.Domain.Bookings;
using Eventuous;
using NodaTime;
using static Bookings.Application.BookingCommands;

namespace Bookings.Application;

public class BookingsCommandService
    : CommandService<Booking, BookingState, BookingId> {

    public BookingsCommandService(
        IEventStore store,
        Services.IsRoomAvailable isRoomAvailable
    ) : base(store) {
        On<BookRoom>()
            .InState(ExpectedState.New)
            .GetId(cmd => new BookingId(cmd.BookingId))
            .ActAsync((booking, cmd, _) => booking.BookRoom(
                cmd.GuestId,
                new(cmd.RoomId),
                new StayPeriod(
                    LocalDate.FromDateTime(cmd.CheckInDate),
                    LocalDate.FromDateTime(cmd.CheckOutDate)
                ),
                new Money(cmd.BookingPrice, cmd.Currency),
                new Money(cmd.PrepaidAmount, cmd.Currency),
                DateTimeOffset.Now,
                isRoomAvailable
            ));

        On<RecordPayment>()
            .InState(ExpectedState.Existing)
            .GetId(cmd => new BookingId(cmd.BookingId))
            .Act((booking, cmd) => booking.RecordPayment(
                new Money(cmd.PaidAmount, cmd.Currency),
                cmd.PaymentId, cmd.PaidBy, DateTimeOffset.Now
            ));
    }
}
  • InState(ExpectedState.New) — the aggregate stream must not yet exist.
  • InState(ExpectedState.Existing) — the stream must already exist.
  • GetId() — extracts the aggregate identity from the command.
  • ActAsync() / Act() — invokes the aggregate method.
7

Register services

Wire everything together in your DI setup. The extension methods come from Eventuous.Extensions.DependencyInjection and Eventuous.KurrentDB:
using Bookings.Application;
using Bookings.Domain.Bookings;
using Eventuous;
using Eventuous.KurrentDB;

namespace Bookings;

public static class Registrations {
    public static void AddEventuous(
        this IServiceCollection services,
        IConfiguration configuration
    ) {
        // 1. Configure the default event serializer
        DefaultEventSerializer.SetDefaultSerializer(
            new DefaultEventSerializer(
                new JsonSerializerOptions(JsonSerializerDefaults.Web)
            )
        );

        // 2. Register the KurrentDB client from configuration
        services.AddKurrentDBClient(
            configuration["KurrentDB:ConnectionString"]!
        );

        // 3. Register the KurrentDB-backed event store
        services.AddEventStore<KurrentDBEventStore>();

        // 4. Register the command service
        services.AddCommandService<BookingsCommandService, BookingState>();
    }
}
Add the connection string to appsettings.json:
{
  "KurrentDB": {
    "ConnectionString": "kurrentdb://localhost:2113?tls=false"
  }
}
8

Expose an HTTP endpoint

Use CommandHttpApiBase<TState> from Eventuous.Extensions.AspNetCore to create a controller that delegates directly to the command service:
using Bookings.Domain.Bookings;
using Eventuous;
using Eventuous.Extensions.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using static Bookings.Application.BookingCommands;

namespace Bookings.HttpApi.Bookings;

[Route("/booking")]
public class CommandApi(ICommandService<BookingState> service)
    : CommandHttpApiBase<BookingState>(service) {

    [HttpPost, Route("book")]
    public Task<ActionResult<Result<BookingState>.Ok>> BookRoom(
        [FromBody] BookRoom cmd,
        CancellationToken cancellationToken
    ) => Handle(cmd, cancellationToken);

    [HttpPost, Route("recordPayment")]
    public Task<ActionResult<Result<BookingState>.Ok>> RecordPayment(
        [FromBody] RecordPayment cmd,
        CancellationToken cancellationToken
    ) => Handle(cmd, cancellationToken);
}
Then in Program.cs, call your registration method and map controllers:
using Bookings;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddEventuous(builder.Configuration);

var app = builder.Build();

app.MapControllers();
app.Run("http://*:5051");
POST to http://localhost:5051/booking/book with a JSON body matching BookRoom — Eventuous handles loading, executing, and persisting events automatically.

What happens under the hood

When you POST /booking/book, the command service:
  1. Extracts BookingId from the command via GetId().
  2. Loads the event stream Booking-{id} from KurrentDB and replays all events through BookingState.
  3. Calls booking.BookRoom(...), which validates business rules and calls Apply() for each new event.
  4. Appends the pending Changes to KurrentDB with optimistic concurrency using OriginalVersion.
  5. Returns the updated BookingState as the HTTP response body.

Next steps

Core Concepts

Understand the full mental model behind Eventuous.

Aggregates

Explore Aggregate<TState> in depth.

Command Services

Learn all command-service patterns and options.

Subscriptions

Build projections and read models with durable subscriptions.

Build docs developers (and LLMs) love