Skip to content

Getting Started

Build an event-sourced system in five minutes. We'll create a library that tracks books — adding them, borrowing, returning, and reporting lost copies.

Install

bash
dotnet add package Nagare
dotnet add package Nagare.Sqlite      # SQLite — ideal for development

For production, swap in the database you use:

bash
dotnet add package Nagare.SqlServer   # SQL Server
dotnet add package Nagare.PostgreSql  # PostgreSQL

Define your types

An event-sourced system has three core types: commands (what you want to happen), events (what happened), and state (what's true right now).

Commands — imperative mood

csharp
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(AddBook), "add")]
[JsonDerivedType(typeof(BorrowBook), "borrow")]
[JsonDerivedType(typeof(ReturnBook), "return")]
[JsonDerivedType(typeof(ReportLost), "report-lost")]
public abstract record BookCommand;

public record AddBook(string Title, string Author, string Isbn) : BookCommand;
public record BorrowBook(string BorrowerId) : BookCommand;
public record ReturnBook() : BookCommand;
public record ReportLost() : BookCommand;

Events — past tense

csharp
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(BookAdded), "book-added")]
[JsonDerivedType(typeof(BookBorrowed), "book-borrowed")]
[JsonDerivedType(typeof(BookReturned), "book-returned")]
[JsonDerivedType(typeof(BookLost), "book-lost")]
public abstract record BookEvent : IAggregateEvent<BookEvent>, IJsonable;

public record BookAdded(string Title, string Author, string Isbn) : BookEvent;
public record BookBorrowed(string BorrowerId, DateTimeOffset BorrowedAt) : BookEvent;
public record BookReturned(DateTimeOffset ReturnedAt) : BookEvent;
public record BookLost(DateTimeOffset ReportedAt) : BookEvent;

State — derived from events

csharp
public record BookState(
    bool Exists,
    string? Title,
    string? BorrowerId,
    bool IsBorrowed,
    bool IsLost) : IAggregateState<BookState>
{
    public static BookState Default => new(false, null, null, false, false);
}

State is minimal — only what command handlers need to make decisions.

Write the aggregate

The aggregate is where commands and events meet. Command handlers decide, event handlers evolve state.

csharp
public class BookAggregate : Aggregate<BookCommand, BookEvent, BookState>
{
    protected override EventHandlers<BookEvent, BookState> RegisterEventHandlers() =>
        Events
            .On<BookAdded>((state, e) =>
                state with { Exists = true, Title = e.Title })
            .On<BookBorrowed>((state, e) =>
                state with { IsBorrowed = true, BorrowerId = e.BorrowerId })
            .On<BookReturned>((state, _) =>
                state with { IsBorrowed = false, BorrowerId = null })
            .On<BookLost>((state, _) =>
                state with { IsLost = true, IsBorrowed = false, BorrowerId = null })
            .Build();

    protected override CommandHandlers<BookCommand, BookEvent, BookState> RegisterCommandHandlers() =>
        Commands
            .On<AddBook>((state, cmd) =>
                state.Exists
                    ? Then.Reject("Book already exists")
                    : Then.Persist(new BookAdded(cmd.Title, cmd.Author, cmd.Isbn)))
            .On<BorrowBook>((state, cmd) =>
                !state.Exists      ? Then.Reject("Book does not exist")
                : state.IsBorrowed ? Then.Reject("Book is already borrowed")
                : state.IsLost     ? Then.Reject("Book is reported lost")
                : Then.Persist(new BookBorrowed(cmd.BorrowerId, DateTimeOffset.UtcNow)))
            .On<ReturnBook>((state, _) =>
                !state.IsBorrowed
                    ? Then.Reject("Book is not borrowed")
                    : Then.Persist(new BookReturned(DateTimeOffset.UtcNow)))
            .On<ReportLost>((state, _) =>
                !state.Exists  ? Then.Reject("Book does not exist")
                : state.IsLost ? Then.Accept()   // idempotent
                : Then.Persist(new BookLost(DateTimeOffset.UtcNow)))
            .Build();
}

Read the command handlers top to bottom — they're business rules written as code. "If the book doesn't exist, reject. If it's already borrowed, reject. If it's lost, reject. Otherwise, persist a borrow event."

Wire it up

csharp
using Nagare;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddNagare();
builder.Services.AddAggregate<BookAggregate, BookCommand, BookEvent, BookState>();
builder.Services.AddNagareSqliteStorage(connectionString: "Data Source=nagare.db");

var app = builder.Build();

Expose an endpoint

csharp
app.MapPost("/books/{id}", async (
    string id,
    BookCommand command,
    IAggregateRepository<BookAggregate, BookCommand, BookEvent, BookState> repo) =>
{
    var aggregate = await repo.Load(new AggregateId(id));
    var reply = await aggregate.Ask(command);

    return reply.Match(
        onAccepted: () => Results.Ok(),
        onRejected: ex => Results.BadRequest(ex.Message),
        onIgnored: () => Results.Ok());
});

app.Run();

Try it

bash
# Add a book
curl -X POST http://localhost:5000/books/dune-1 \
  -H "Content-Type: application/json" \
  -d '{"$type": "add", "Title": "Dune", "Author": "Frank Herbert", "Isbn": "978-0441172719"}'

# Borrow it
curl -X POST http://localhost:5000/books/dune-1 \
  -H "Content-Type: application/json" \
  -d '{"$type": "borrow", "BorrowerId": "user-42"}'

# Try to borrow it again — rejected
curl -X POST http://localhost:5000/books/dune-1 \
  -H "Content-Type: application/json" \
  -d '{"$type": "borrow", "BorrowerId": "user-99"}'
# → 400 "Book is already borrowed"

Events flow in, truth flows out. The event store now contains the complete history of everything that happened to this book.

Test it

bash
dotnet add package Nagare.Testing
csharp
var harness = new AggregateTestHarness<BookAggregate, BookCommand, BookEvent, BookState>();

harness
    .Given(new BookAdded("Dune", "Frank Herbert", "978-0441172719"))
    .When(new BorrowBook("user-42"))
    .ThenAccepted()
    .ThenExpect<BookBorrowed>(e => e.BorrowerId == "user-42");

harness
    .Given(new BookAdded("Dune", "Frank Herbert", "978-0441172719"),
           new BookBorrowed("user-1", DateTimeOffset.UtcNow))
    .When(new BorrowBook("user-2"))
    .ThenRejected("Book is already borrowed");

No database, no DI container — just your domain logic, verified in milliseconds.

What's next

You've built a working event-sourced system. From here:

  • Core Concepts — understand the philosophy behind the flow
  • Aggregates — design consistency boundaries that last
  • Projections — build read models from the event stream
  • Testing — the natural Given-When-Then vocabulary
  • Modular Monolith — scale from single process to distributed services

流れ — flow.