3. Commands & Queries¶
Raw store operations work, but they don't express intent. "Save a book" isn't the same as "Add a new book to the catalog." Commands and queries give your operations names, validation, and a dispatch layer the generator builds for you.
The Dispatch Module¶
[DispatchModule]
public static partial class CatalogDispatch;
The generator scans for [CommandHandler] and [QueryHandler] methods and produces typed dispatch methods: CatalogDispatch.AddBook(...), CatalogDispatch.GetAvailableBooks(...), etc.
A Command¶
public sealed record AddBook(
[Required]
[MaxLength(256)]
string Title,
[Required]
[MaxLength(20)]
string Isbn,
AuthorId AuthorId,
[Required]
[MaxLength(256)]
string AuthorName,
int TotalCopies = 1) : ICommand;
public static class AddBookHandler
{
[Public]
[CommandHandler]
[HttpPost("/books")]
[RateLimit("default")]
public static Eff<CatalogRuntime, BookId> Handle(AddBook cmd) =>
from book in CreateBook(cmd)
from _ in CatalogStore.Books.Save<CatalogRuntime>(book)
>> CatalogSearch.SearchIndex.IndexAsync<CatalogRuntime>(id: book.Id.ToString(), book)
select book.Id;
private static Eff<CatalogRuntime, Book> CreateBook(AddBook cmd) => liftEff<CatalogRuntime, Book>(_ => new()
{
Id = BookId.New(),
Title = cmd.Title,
Isbn = cmd.Isbn,
AuthorId = cmd.AuthorId,
AuthorName = cmd.AuthorName,
AvailableCopies = cmd.TotalCopies,
TotalCopies = cmd.TotalCopies
}
);
}
The command record (AddBook) is the input. Validation attributes ([Required], [MaxLength]) are checked automatically before the handler runs. The handler returns an Eff<CatalogRuntime, BookId> — an effect that produces the new book's ID.
A few attributes you'll see throughout:
[Public]— this handler doesn't require authentication (every handler needs an access control decision)[CommandHandler]— registers this method with the dispatch module[HttpPost("/books")]— also maps it as a REST endpoint (Chapter 4)[RateLimit("default")]— protects the endpoint from abuse
The >> operator sequences effects: "save the book, then index it for search." If the save fails, the index never runs.
Search¶
The AddBook handler indexes books into a search index. That's this module:
[EffectsModule(typeof(ISearchIndex<Domain.Book>))]
public static partial class CatalogSearch;
The generator wraps ISearchIndex<Book> with effect methods. CatalogSearch.SearchIndex.IndexAsync<CatalogRuntime>(...) and CatalogSearch.SearchIndex.SearchAsync<CatalogRuntime>(...) are ready to use. In-memory by default — swap to Meilisearch for production.
A Query¶
public sealed record SearchBooks(string Query, int Limit = 20) : IQuery;
public static class SearchBooksHandler
{
[Public]
[QueryHandler]
[HttpGet("/books/search")]
public static Eff<CatalogRuntime, SearchResult<Book>> Handle(SearchBooks query) =>
CatalogSearch.SearchIndex.SearchAsync<CatalogRuntime>(
query.Query,
new SearchOptions(Limit: query.Limit));
}
Queries return data. Commands change state. The generator produces typed dispatch methods for both: CatalogDispatch.AddBook(...) and CatalogDispatch.SearchBooks(...).
Test It¶
[Test]
public async Task AddBook_SetsAvailableCopiesFromTotalCopies()
{
var runtime = TestCatalogRuntime.CreateConfigured();
var fin = await (
from _ in CatalogDispatch.AddBook("Refactoring", "978-0134757599", AuthorId.New(), "Martin Fowler", 5)
from books in CatalogDispatch.GetAvailableBooks()
select books
).RunAsync(runtime);
await Assert.That(fin).IsSuccMatching(r =>
r.Data[0].AvailableCopies == 5 && r.Data[0].TotalCopies == 5);
}
Notice: CreateConfigured() returns the production CatalogRuntime with in-memory stores. The test uses dispatch to seed data and verify results — the same code path production uses.