Docs / ArchitectureKit 2.0 / Fundamentals / Defining Views

Defining Views

A view holds the data that queries read.

Define the shape of an item as a struct, and call the NewInMemoryView function with a function that returns the key of an item, to create a view that holds such items in memory:

type BookItem struct {
  ID            string
  Title         string
  Author        string
  IsBorrowed    bool
  BorrowedUntil string
  EventID       string
}

func newCatalog() *architecturekit.InMemoryView[string, BookItem] {
  return architecturekit.NewInMemoryView(
    func(item BookItem) string { return item.ID },
    architecturekit.RevisionIn(func(item *BookItem) *string { return &item.EventID }),
  )
}

catalog := newCatalog()

An item carries no JSON annotations, since what a caller sees is decided by a query and its answer, not by the view (see Handling Queries over HTTP).

Note that calling NewInMemoryView with nil as the function, or as one of the options, panics.

Every item has a revision of its own, which is the ID of the last event that changed it. The RevisionIn option makes the view keep it in a field of the item, so that a caller can hand it over to a command that uses the OnEventID function (see Checking the Revision of the Caller). The view sets the field whenever it changes an item, so you never set it yourself. Without the option, the view keeps the revisions to itself.

Note that calling RevisionIn with nil, or twice, panics.

The revision of an item fits such a precondition only if the item stands for exactly one subject, and the projection applies every event type of that subject to the item. The precondition checks the last event of the subject, so as soon as an event lands in the subject that the view does not apply to the item, the two drift apart, and every command with the revision of the item fails with an error of the category ErrConflict, until an event changes the item again. Apply an event type that does not change the item, such as one the state ignores, with a change that does nothing (see Defining Projections). For an item that gathers several subjects, such as all books a reader has borrowed, there is no single subject its revision could stand for, so use OnStateRead for the commands instead (see Guarding Against Concurrent Changes).

Every function that changes the view takes the ID of the event it applies. An ID that is empty or not a revision, such as abc, is refused at once, rather than at the next change of the item: the function fails with an error of the category ErrPermanent that also wraps ErrNotARevision, and the view stays as it is. An event that is not newer than the item it is about is skipped, so applying the same event twice changes nothing, as long as the item is still there. The view forgets the revision of an item it removes, so an event that adds the item, applied again after a later event has removed it, adds it again. A projection that is rebuilt applies every event once, in order, so with a view in memory, that does not happen.

The functions that read and change items take a context and report an error, as a view in a database would need: All and Lookup hand it out along with the items, and every other one returns it. So a view in a database can offer functions of the same shape, which the handlers of a projection call the same way. The kit has no interface for the functions that change a view, though: a projection takes its view by its type, such as *InMemoryView, so moving it to a view in a database changes that type.

If an upcaster splits a stored event into several events, they all carry the ID of the stored event (see Versioning Events). A projection created with NewTypedProjection hands each of them to its handler with a context that holds its position among them, and the view reads it from the context it gets. For the same ID, the view counts a later event as newer than an earlier one, so every one of them is applied, in order, also if several of them change the same item. Applying the stored event again still changes nothing, and the revision of the item stays the ID of the stored event. That is why a handler always hands the context it gets on to the view, rather than one of its own, such as context.Background().

Note that the view as a whole has a revision as well, which is the last event it has seen at all, rather than the last one that changed a particular item (see Reading Your Own Writes).

Adding Items#

To add an item, call the Insert function with a context, the ID of the event, and the item:

outcome, err := catalog.Insert(ctx, event.ID, BookItem{
  ID:     "42",
  Title:  "2001 – A Space Odyssey",
  Author: "Arthur C. Clarke",
})
if err != nil {
  // ...
}

Insert returns an Outcome, which tells what it did: architecturekit.Added if it added the item, and architecturekit.AlreadyApplied if an item with the same key has seen the event, or a newer one, so the event was skipped. The latter is not an error, since it happens when events are applied a second time. So a projection that does more than store the item, for example one that also counts the books, does so only for architecturekit.Added (see Changing and Removing Items).

If the key of the item is already taken, and the event is newer than the item with that key, Insert fails with an error of the category ErrPermanent, since two items with the same key point to a mistake in the events or in the key. To add an item or change the existing one, call the Upsert function instead, with the key and a function that changes the item. It starts from the existing item, or from an empty one if there is none, so it has to set the fields that make up the key:

outcome, err := catalog.Upsert(ctx, "42", event.ID, func(item *BookItem) {
  item.ID = "42"
  item.IsBorrowed = true
})

Upsert returns an Outcome as well: architecturekit.Added if it added the item, architecturekit.Applied if it changed the existing one, and architecturekit.AlreadyApplied if the existing item has seen the event, or a newer one, so the event was skipped.

Note that an item whose key, after the change, differs from the given one fails with an error of the category ErrPermanent, which also catches a function that forgets to set the key.

Reading Items#

To read the item with a given key, call the Get function. It returns false if there is none:

book, isFound, err := catalog.Get(ctx, "42")
if err != nil {
  // ...
}

To read all items, call the All function. It returns an iterator over a copy of the items, in the order in which they were added, which hands out each item together with an error. Use it e.g. inside a for range loop:

for item, err := range catalog.All(ctx) {
  if err != nil {
    // ...
  }

  // ...
}

The view in memory never fails while it hands out its items, so the error is always nil. A view in a database, however, may fail halfway, which is why every view hands out an error, and every loop checks it.

Note that the items you get share their slices, maps, and pointees with the view, so never change them (see Sharing Items with Readers).

Changing and Removing Items#

To change the item with a given key, call the Update function with a function that changes it. To remove it, call the Delete function. Like Insert and Upsert, both return an Outcome, which tells what they did:

updated, err := catalog.Update(ctx, "42", event.ID, func(item *BookItem) {
  item.IsBorrowed = true
})

deleted, err := catalog.Delete(ctx, "42", event.ID)
Outcome Meaning
architecturekit.Added A new item was added. Only Insert and Upsert report it.
architecturekit.Applied An existing item was changed or removed.
architecturekit.Missing There is no item with the key. Only Update and Delete report it.
architecturekit.AlreadyApplied The item has seen the event, or a newer one, so nothing was changed.

Neither Missing nor AlreadyApplied is an error, since both happen when events are applied a second time, as a later event may have removed the item already. If an event about an item that does not exist means that the view and the database disagree, say so in the projection:

outcome, err := catalog.Update(ctx, bookID, event.ID, func(item *BookItem) {
  item.IsBorrowed = true
})
if err != nil {
  return err
}
if outcome == architecturekit.Missing {
  return fmt.Errorf("event %s is about book %s, which was never acquired", event.ID, bookID)
}

Note that changing the key of an item fails with an error of the category ErrPermanent.

To change or remove several items at once, call the UpdateWhere or the DeleteWhere function with a function that selects them. Both return the number of items they changed or removed:

isByClarke := func(item BookItem) bool {
  return item.Author == "Arthur C. Clarke"
}

changed, err := catalog.UpdateWhere(ctx, isByClarke, event.ID, func(item *BookItem) {
  item.Author = "Sir Arthur C. Clarke"
})

removed, err := catalog.DeleteWhere(ctx, isByClarke, event.ID)

Note that the view stays locked while it runs a function you hand over, such as a change, or the function that selects the items for UpdateWhere and DeleteWhere. Such a function must not use the same view, not even to read an item with Get or All, since that blocks forever. The same holds for Upsert, and for the Update function of an index. To copy data from another item of the view into the one you change, read it before, and use it inside the change.

Sharing Items with Readers#

Get and All hand out plain copies of the items, which share their slices, maps, and pointees with the view. So an item is shared with every reader who got it, also while a projection changes it. That is why a reader must never change an item it got, and why a change replaces a field that holds a slice, a map, or a pointer, rather than writing into it. Writing into it would change what the readers hold, possibly while they are reading it, and writing into a map while somebody reads it may crash the process. Replacing the field with one built anew is safe:

type ReaderItem struct {
  ID       string
  BookIDs  []string
  DueDates map[string]string
}

outcome, err := readers.Update(ctx, event.Data.BorrowedBy, event.ID, func(item *ReaderItem) {
  item.BookIDs = append(slices.Clone(item.BookIDs), bookID)
})

Note that append(item.BookIDs, bookID) alone does not build the slice anew: if its array has room left, append writes into the array that the readers share.

To have the view take care of that, hand over the CloneWith option with a function that returns a copy of an item that shares no data with the original, like the Clone function of a state (see Caching States). The view then clones the item before every change, and hands the clone to the change, which may write into it freely:

readers := architecturekit.NewInMemoryView(
  func(item ReaderItem) string { return item.ID },
  architecturekit.CloneWith(func(item ReaderItem) ReaderItem {
    item.BookIDs = slices.Clone(item.BookIDs)
    item.DueDates = maps.Clone(item.DueDates)
    return item
  }),
)

outcome, err := readers.Update(ctx, event.Data.BorrowedBy, event.ID, func(item *ReaderItem) {
  if item.DueDates == nil {
    item.DueDates = map[string]string{}
  }

  item.DueDates[bookID] = event.Data.BorrowedUntil
})

The change creates the map if it is nil, since an item may have been added without one, and maps.Clone keeps nil.

The view clones an item whenever it changes an existing one, with Update, Upsert, UpdateWhere, and the Update function of an index, but not when it adds one, since nobody has read a new item yet. Readers still never change an item they got, since the view hands out what it holds.

Note that an index whose value is taken from a slice, a map, or a pointee loses track of an item if a change writes into that data without a clone, since the old item then holds the new value as well (see Indexing Items).

Note that a time.Time counts as a value, since its location never changes, so an item whose only pointer is the one inside a time.Time needs no CloneWith.

Note that calling CloneWith with nil, or twice, panics.

Indexing Items#

Selecting items with a function reads every item. To find items by a value directly, for example all books by an author, add a secondary index with the Index function. It takes a function that returns the value of an item, and returns an index, which offers the Lookup, Update, and Delete functions:

byAuthor := catalog.Index(func(item BookItem) string { return item.Author })

books := byAuthor.Lookup(ctx, "Arthur C. Clarke")

changed, err := byAuthor.Update(ctx, "Arthur C. Clarke", event.ID, func(item *BookItem) {
  item.IsBorrowed = false
})

removed, err := byAuthor.Delete(ctx, "Arthur C. Clarke", event.ID)

Several items may share a value. The index follows every change to the view, also when the value of an item changes, and Lookup hands out the items in the order in which they were added, together with an error, as All does.

Note that adding an index reads every item, so add indexes before the view is used.

Note that calling Index with nil as the function panics.

To keep items somewhere else, for example in a database, implement the View interface, which consists of the All function. It returns an iterator over the items and errors. A database fails not only before it reads the rows, but also while it reads them, for example when the connection breaks halfway. So hand out such an error with an empty item, and stop. Stop as well, and close the rows, as soon as the iterator is told to stop, which happens when the caller has seen enough, for example with Take or First (see Defining Queries):

type BookTable struct {
  db *sql.DB
}

func (t *BookTable) All(ctx context.Context) iter.Seq2[BookItem, error] {
  return func(yield func(BookItem, error) bool) {
    rows, err := t.db.QueryContext(ctx, "SELECT id, title, author, is_borrowed, borrowed_until, event_id FROM books")
    if err != nil {
      yield(BookItem{}, err)
      return
    }
    defer rows.Close()

    for rows.Next() {
      var item BookItem
      err := rows.Scan(&item.ID, &item.Title, &item.Author, &item.IsBorrowed, &item.BorrowedUntil, &item.EventID)
      if err != nil {
        yield(BookItem{}, err)
        return
      }

      if !yield(item, nil) {
        return
      }
    }

    if err := rows.Err(); err != nil {
      yield(BookItem{}, err)
    }
  }
}

The error reaches the caller unchanged, through every function of the query package, so a category it belongs to, such as ErrTransient, still decides which status code an HTTP route answers with (see Mapping Errors to Status Codes).

If the view can find a single item by its key, also implement the Get function, which makes it a KeyedView:

func (t *BookTable) Get(ctx context.Context, id string) (BookItem, bool, error) {
  // ...
}

A query that reads a single item can then take an architecturekit.KeyedView[string, BookItem] instead of running over all items, and work with the view in memory and the one in a database alike. InMemoryView is a KeyedView, too.