Learn / Tutorials / First Steps with ArchitectureKit / Making Decisions
Step 3 of 6

Making Decisions

In this step, you define the commands of the library and the deciders that decide on them, and execute commands against EventSourcingDB – including one that is rejected, because the book is already borrowed.

Defining Commands#

A command describes what someone wants to do. In ArchitectureKit, a command is a struct that implements two functions: Subject, which returns the subject the command acts on, and Preconditions, which returns the conditions under which its events may be written.

Create a file commands.go:

package main

import "github.com/thenativeweb/architecturekit-golang/architecturekit"

var bookSubject = architecturekit.NewSubjectScheme("/books/{book}")

type AcquireBook struct {
  BookID string
  Title  string
  Author string
  ISBN   string
}

func (c AcquireBook) Subject() string {
  return bookSubject.Build(c.BookID)
}

func (c AcquireBook) Preconditions() []architecturekit.Precondition {
  return []architecturekit.Precondition{
    architecturekit.OnStateRead(),
  }
}

type BorrowBook struct {
  BookID        string
  ReaderID      string
  BorrowedUntil string
}

func (c BorrowBook) Subject() string {
  return bookSubject.Build(c.BookID)
}

func (c BorrowBook) Preconditions() []architecturekit.Precondition {
  return []architecturekit.Precondition{
    architecturekit.OnStateRead(),
  }
}

type ReturnBook struct {
  BookID string
}

func (c ReturnBook) Subject() string {
  return bookSubject.Build(c.BookID)
}

func (c ReturnBook) Preconditions() []architecturekit.Precondition {
  return []architecturekit.Precondition{
    architecturekit.OnStateRead(),
  }
}

Every book has a subject of its own, such as /books/42, which holds all events of that book. NewSubjectScheme defines the structure of these subjects once, and Build composes a subject from the ID of a book. Later on, the view takes subjects apart again with the same scheme. For details, see Composing Subjects.

The precondition OnStateRead makes sure that the events of a command are only written if nothing has been written to the book's subject since its state was read. If two readers try to borrow the same book at the same time, both commands are decided on the same state, but only the first one's events are written. The second one fails with a conflict, instead of lending the book twice. For the other preconditions, see Using Preconditions.

Deciding on Commands#

A decider connects the state with the decision made on it. Its Decide function receives the command and the current state, and returns the events to write, or an error to reject the command.

Create a file deciders.go:

package main

import (
  "context"

  "github.com/thenativeweb/architecturekit-golang/architecturekit"
)

var acquireBook = architecturekit.Decider[AcquireBook, Book]{
  State: bookState,
  Decide: func(ctx context.Context, cmd AcquireBook, book Book) ([]architecturekit.Event, error) {
    if book.IsAcquired {
      return nil, architecturekit.NewDomainError("book %s has already been acquired", cmd.BookID)
    }

    return []architecturekit.Event{
      BookAcquired{
        Title:  cmd.Title,
        Author: cmd.Author,
        ISBN:   cmd.ISBN,
      },
    }, nil
  },
}

var borrowBook = architecturekit.Decider[BorrowBook, Book]{
  State: bookState,
  Decide: func(ctx context.Context, cmd BorrowBook, book Book) ([]architecturekit.Event, error) {
    if !book.IsAcquired {
      return nil, architecturekit.NewDomainError("book %s does not exist", cmd.BookID)
    }
    if book.IsBorrowed {
      return nil, architecturekit.NewDomainError("book %s is already borrowed", cmd.BookID)
    }

    return []architecturekit.Event{
      BookBorrowed{
        BorrowedBy:    cmd.ReaderID,
        BorrowedUntil: cmd.BorrowedUntil,
      },
    }, nil
  },
}

var returnBook = architecturekit.Decider[ReturnBook, Book]{
  State: bookState,
  Decide: func(ctx context.Context, cmd ReturnBook, book Book) ([]architecturekit.Event, error) {
    if !book.IsBorrowed {
      return nil, nil
    }

    return []architecturekit.Event{
      BookReturned{},
    }, nil
  },
}

Each decider checks the business rules of its command. A book can be acquired only once, and it can be borrowed only if it exists and is not borrowed yet. To reject a command, a decider returns an error created with NewDomainError, which takes a format string and arguments, like fmt.Errorf.

Returning a book that is not borrowed is not an error: there is simply nothing to do, so returnBook returns neither events nor an error.

Note that the deciders neither read nor write anything. ArchitectureKit reads the state before it calls a decider, and writes the events afterwards. That keeps the decisions free of any infrastructure, which makes them easy to test (see Testing Deciders). For details, see Making Decisions.

Executing Commands#

To execute a command, call the Execute function with a context, the store, the decider, and the command. Replace the content of main.go with the following program, which acquires a book, lends it to one reader, and then tries to lend it to another one:

package main

import (
  "context"
  "errors"
  "fmt"
  "log"

  "github.com/thenativeweb/architecturekit-golang/architecturekit"
  "github.com/thenativeweb/eventsourcingdb-client-golang/eventsourcingdb"
)

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

  ctx := context.TODO()

  writtenEvents, err := architecturekit.Execute(ctx, store, acquireBook, AcquireBook{
    BookID: "42",
    Title:  "2001 – A Space Odyssey",
    Author: "Arthur C. Clarke",
    ISBN:   "978-0756906788",
  })
  report(writtenEvents, err)

  writtenEvents, err = architecturekit.Execute(ctx, store, borrowBook, BorrowBook{
    BookID:        "42",
    ReaderID:      "23",
    BorrowedUntil: "2026-10-24",
  })
  report(writtenEvents, err)

  writtenEvents, err = architecturekit.Execute(ctx, store, borrowBook, BorrowBook{
    BookID:        "42",
    ReaderID:      "17",
    BorrowedUntil: "2026-10-31",
  })
  report(writtenEvents, err)
}

func report(writtenEvents []eventsourcingdb.Event, err error) {
  if errors.Is(err, architecturekit.ErrDomain) {
    fmt.Println("Rejected:", err)
    return
  }
  if err != nil {
    log.Fatal(err)
  }

  for _, event := range writtenEvents {
    fmt.Println("Written:", event.ID, event.Type, event.Subject)
  }
}

Execute reads the events of the command's subject, evolves the state from them, calls the decider, and writes the events it returns, together with the preconditions of the command. It returns the written events, as EventSourcingDB has stored them.

The report function prints the written events, or the reason for a rejection. Every error that ArchitectureKit returns belongs to a category, which you check with errors.Is: a rejection by a decider belongs to ErrDomain, and everything else is treated as a failure here. For all categories, see Handling Errors.

Run the program:

go run .

The first two commands write an event each, while the third one is rejected, because the state that was built from the two events says that the book is borrowed:

Written: 0 io.eventsourcingdb.library.book-acquired /books/42
Written: 1 io.eventsourcingdb.library.book-borrowed /books/42
Rejected: book 42 is already borrowed

Run it a second time. The events are still stored in EventSourcingDB, so now every command is rejected, and nothing is written:

Rejected: book 42 has already been acquired
Rejected: book 42 is already borrowed
Rejected: book 42 is already borrowed

This also means that running the program again does no harm: the following steps expect exactly the two events with the IDs 0 and 1, no matter how often you have run it.