Defining a View and a Query
In this step, you build the reading side of the library: a view that holds a catalog of all books, a projection that fills it from the events, and queries that read it.
Separating Reading from Writing#
The state of a book from Defining Events and State holds only what decisions need, and only for a single book. A list of all books with their titles needs a different shape, built from the same events. Keeping the two apart is the idea behind CQRS: the writing side decides on commands, and the reading side answers queries from read models, which ArchitectureKit calls views.
Defining the View#
A view holds items, each under a key of its own. For the catalog, an item describes a book, and its key is the ID of the book. A projection turns events into items: it adds an item when a book is acquired, and changes it when the book is borrowed or returned.
Create a file catalog.go:
package main
import (
"context"
"github.com/thenativeweb/architecturekit-golang/v2/architecturekit"
)
type BookItem struct {
ID string
Title string
Author string
IsBorrowed bool
BorrowedUntil string
}
func newCatalog() *architecturekit.InMemoryView[string, BookItem] {
return architecturekit.NewInMemoryView(func(item BookItem) string {
return item.ID
})
}
func newCatalogProjection(catalog *architecturekit.InMemoryView[string, BookItem]) *architecturekit.TypedProjection {
return architecturekit.NewTypedProjection().
On(func(ctx context.Context, event architecturekit.Envelope[BookAcquired]) error {
_, err := catalog.Insert(ctx, event.ID, BookItem{
ID: bookIDOf(event.Subject),
Title: event.Data.Title,
Author: event.Data.Author,
})
return err
}).
On(func(ctx context.Context, event architecturekit.Envelope[BookBorrowed]) error {
_, err := catalog.Update(ctx, bookIDOf(event.Subject), event.ID, func(item *BookItem) {
item.IsBorrowed = true
item.BorrowedUntil = event.Data.BorrowedUntil
})
return err
}).
On(func(ctx context.Context, event architecturekit.Envelope[BookReturned]) error {
_, err := catalog.Update(ctx, bookIDOf(event.Subject), event.ID, func(item *BookItem) {
item.IsBorrowed = false
item.BorrowedUntil = ""
})
return err
})
}
func bookIDOf(subject string) string {
bookID, _ := bookSubject.Value(subject, "book")
return bookID
}
NewInMemoryView creates a view that holds its items in memory, and takes the key of an item from the given function. NewTypedProjection creates a projection, and every call to On adds a handler for one event type. Each handler receives an Envelope, which holds the metadata of the event, such as its ID and Subject, and its data in Data, already decoded into the Go type of the event.
The handlers call Insert to add an item, and Update to change one. Both take the ID of the event they apply, so that the view can skip an event it has applied before. Both also return an outcome, which tells what they did, for example whether they skipped the event. The catalog does not need it, so the handlers return only the error. Since the events do not contain the ID of the book, bookIDOf takes it out of the subject with the Value function of the subject scheme from Making Decisions, which returns the value of the placeholder book.
For details, see Defining Views and Projections.
Defining the Queries#
A query describes what someone wants to know, and a function answers it by reading a view. Create a file queries.go with a query that lists books, optionally only those that are available, and one that gets a single book by its ID:
package main
import (
"context"
"github.com/thenativeweb/architecturekit-golang/v2/architecturekit"
"github.com/thenativeweb/architecturekit-golang/v2/architecturekit/query"
)
type ListBooks struct {
OnlyAvailable bool
}
func listBooks(catalog architecturekit.View[BookItem]) func(context.Context, ListBooks) ([]BookItem, error) {
return func(ctx context.Context, q ListBooks) ([]BookItem, error) {
items := catalog.All(ctx)
if q.OnlyAvailable {
items = query.Where(items, func(item BookItem) bool {
return !item.IsBorrowed
})
}
return query.Collect(items)
}
}
type GetBook struct {
BookID string
}
func getBook(catalog architecturekit.KeyedView[string, BookItem]) func(context.Context, GetBook) (BookItem, error) {
return func(ctx context.Context, q GetBook) (BookItem, error) {
book, isFound, err := catalog.Get(ctx, q.BookID)
if err != nil {
return BookItem{}, err
}
if !isFound {
return BookItem{}, query.ErrNoItems
}
return book, nil
}
}
All returns an iterator over the items of the view, in the order in which they were added, and hands out each item together with an error, since a view that keeps its items in a database may fail while it reads them. The query package filters, orders, and pages such iterators, Where keeps only the items that match, and Collect turns them into a slice, or returns the error if the view has failed. For everything else it offers, see Defining Queries.
getBook does not run over all items, since the catalog can find a book by its key. So it takes a KeyedView, a view that offers a Get function, which returns the item with the given key, and whether there is one. If there is none, getBook returns query.ErrNoItems, so that a missing book can be told apart from a failure. InMemoryView is a KeyedView, so the catalog serves both queries (see Defining Views). You put getBook to use in Serving Commands and Queries over HTTP.
Running the Projection#
Replace the content of main.go with a program that builds the catalog from the stored events and lists all books:
package main
import (
"context"
"fmt"
"log"
"github.com/thenativeweb/architecturekit-golang/v2/architecturekit"
)
func main() {
store, err := newStore()
if err != nil {
log.Fatal(err)
}
ctx := context.TODO()
catalog := newCatalog()
err = architecturekit.CatchUpProjection(ctx, store, architecturekit.SubjectTree("/books"), newCatalogProjection(catalog))
if err != nil {
log.Fatal(err)
}
books, err := listBooks(catalog)(ctx, ListBooks{})
if err != nil {
log.Fatal(err)
}
for _, book := range books {
fmt.Printf("%+v\n", book)
}
}
CatchUpProjection reads all events that are stored below the subject /books, since SubjectTree stands for /books together with every subject below it, and applies them to the projection. It returns once all stored events have been applied.
Run the program:
go run .
It prints the book from Making Decisions, now with its title and author from the BookAcquired event, and the return date from the BookBorrowed event:
{ID:42 Title:2001 – A Space Odyssey Author:Arthur C. Clarke IsBorrowed:true BorrowedUntil:2026-10-24}
The view lives in memory, so it is gone once the program ends, and the projection builds it again from the events on the next start. That is fine, because the events are the source of truth, and a view can always be rebuilt from them. For views that keep their data and resume where they stopped, see Resuming Projections.