Skip to content

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

src/Library.Catalog/Runtime.cs (add to existing)
[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

src/Library.Catalog/Domain/Commands/AddBook.cs
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.

The AddBook handler indexes books into a search index. That's this module:

src/Library.Catalog/Runtime.cs (add to existing)
[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

src/Library.Catalog/Domain/Queries/SearchBooks.cs
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.

Next → Adding a REST API