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.
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 directly — On<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.csnamespace 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.csusing 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>(); }}
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.