Learn / Tutorials / First Steps with ArchitectureKit / Defining a View and a Query
Step 4 of 6

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 a query that reads 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/architecturekit"
)

type BookItem struct {
  ID            string `json:"id"`
  Title         string `json:"title"`
  Author        string `json:"author"`
  IsBorrowed    bool   `json:"isBorrowed"`
  BorrowedUntil string `json:"borrowedUntil"`
}

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.NewProjection().
    On(func(ctx context.Context, event architecturekit.Envelope[BookAcquired]) error {
      return catalog.Insert(ctx, event.ID, BookItem{
        ID:     bookIDOf(event.Subject),
        Title:  event.Data.Title,
        Author: event.Data.Author,
      })
    }).
    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 {
  values, _ := bookSubject.Match(subject)
  return values["book"]
}

NewInMemoryView creates a view that holds its items in memory, and takes the key of an item from the given function. NewProjection 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. Since the events do not contain the ID of the book, bookIDOf takes it out of the subject, using the subject scheme from Making Decisions.

For details, see Defining Views and Projections.

Defining the Query#

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:

package main

import (
  "context"
  "slices"

  "github.com/thenativeweb/architecturekit-golang/architecturekit"
  "github.com/thenativeweb/architecturekit-golang/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, err := catalog.All(ctx)
    if err != nil {
      return nil, err
    }

    if q.OnlyAvailable {
      items = query.Where(items, func(item BookItem) bool {
        return !item.IsBorrowed
      })
    }

    return slices.Collect(items), nil
  }
}

All returns an iterator over the items of the view, in the order in which they were added. The query package filters, orders, and pages such iterators, and Where keeps only the items that match. For everything else it offers, see Defining Queries.

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/architecturekit"
)

func main() {
  store, err := newStore()
  if err != nil {
    log.Fatal(err)
  }

  ctx := context.TODO()

  catalog := newCatalog()

  err = architecturekit.CatchUpProjection(ctx, store, "/books", true, 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 the fourth argument makes it read recursively, 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.