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
dotnet add package Nagare
dotnet add package Nagare.Sqlite # SQLite — ideal for developmentFor production, swap in the database you use:
dotnet add package Nagare.SqlServer # SQL Server
dotnet add package Nagare.PostgreSql # PostgreSQLDefine 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
[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
[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
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.
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
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
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
# 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
dotnet add package Nagare.Testingvar 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