Using Preconditions
Every command declares at least one precondition, so that writing without any check is always a decision, never an oversight.
There are these kinds:
OnStateReadguards the state the command is decided on.OnPristineSubject,OnPopulatedSubject, andOnEventIDrequire the subject to have no events yet, to have events, or to be on a given event, for example the revision the caller hands over.Requireturns any other precondition of the client SDK into one of the command, such as an EventQL query.Unconditionallywrites without any check.
Preconditions can be combined, and all of them must hold. If a precondition does not hold, nothing is written, and Execute returns an error of the category ErrConflict (see Handling Errors). If a command declares no preconditions, requires a precondition that is nil, as with Require(nil), combines Unconditionally with others, or declares a zero Precondition, which none of these functions returns, Execute returns an error of the category ErrPermanent before reading anything. To check the preconditions of a command this way without executing it, call the CheckPreconditions function with the command, which returns the same error, or nil. The CheckReadOptions function does the same for the options of a read (see Reading Events).
To find out what kind a precondition is, call its IsOnStateRead or IsUnconditional function. Its Database function returns the precondition of the client SDK that the database checks for it: the one Require made it from, which is how OnPristineSubject and OnPopulatedSubject make theirs, or the one of NewIsSubjectOnEventIDPrecondition for OnEventID. For any other, it returns false.
Guarding Against Concurrent Changes#
If a command may only write events in case nothing has been written to its subject since Execute read the state, use the OnStateRead function. This fits most commands, since the decider decides on exactly that state:
func (c ReturnBook) Preconditions() []architecturekit.Precondition {
return []architecturekit.Precondition{
architecturekit.OnStateRead(),
}
}
Execute fills in the ID of the last event it has read. For a subject without any events, it requires the subject to still be pristine instead. This also holds with several processes writing to the same subjects, and with a state cache (see Caching States).
If something else was written in between, the command fails with an error of the category ErrConflict. Since the decider decided on the state that Execute read, deciding again on the new state is what would have happened had the command arrived a moment later. To have Execute do so, hand over the WithConflictRetries option when creating the store, with the number of times to decide again:
store := architecturekit.NewStore(client, "https://library.eventsourcingdb.io", architecturekit.WithConflictRetries(4))
Execute then reads the state anew, and the decider decides on it, up to four more times. Once the retries are used up, the command fails with ErrConflict, as without the option.
Note that a negative number of retries makes WithConflictRetries panic.
Note that the store decides again only on a conflict. A write whose outcome is unknown is never tried again, since its events may have been stored (see Handling Errors).
Changed in 2.1Note that this only applies to commands whose preconditions include OnStateRead. A command that checks only a revision the caller hands over is never decided again, since the caller has to learn about the conflict, and deciding again would fail the same way (see Checking the Revision of the Caller). A command that checks both is decided again, since the database does not say which precondition did not hold. If the revision of the caller is outdated, every attempt fails the same way, until the retries are used up. On the same subject, one of the two is enough: if the revision of the caller holds, so does OnStateRead.
Checking the Revision of the Caller#
If a command may only write events in case its subject has not changed since the caller last read it, for example in a user interface, use the OnEventID function with the subject and the ID of the last event the caller has seen. For that, add a field for the ID:
type BorrowBook struct {
BookID string
ReaderID string
BorrowedUntil string
ExpectedEventID string
}
func (c BorrowBook) Preconditions() []architecturekit.Precondition {
return []architecturekit.Precondition{
architecturekit.OnEventID(c.Subject(), c.ExpectedEventID),
}
}
Note that the caller has to provide the event ID. A view can keep it for that purpose, as long as its items follow every event of their subject (see Defining Views).
Note that the event ID usually comes from the caller, so Execute checks it before it reads anything. An empty event ID, or one that is not an event ID at all, makes Execute fail with an error that wraps ErrNotARevision and names the ID, as in not a revision: "abc", as Read does for a bound (see Reading Events), and the httpapi package answers it with 400 Bad Request and the error as the message (see Mapping Errors to Status Codes). It does so only once nothing else is wrong with the preconditions, so that a mistake in the code is not hidden behind one of the caller, and Write, CheckPreconditions, and the test fixture of architecturekittest refuse the ID the same way. Require with NewIsSubjectOnEventIDPrecondition of the client SDK leaves the ID to the database instead, which refuses such an ID as a malformed request, so that Execute fails with an error of the category ErrPermanent.
Preventing Duplicates#
If a command may only write events in case its subject does not yet have any events, use the OnPristineSubject function:
func (c AcquireBook) Preconditions() []architecturekit.Precondition {
return []architecturekit.Precondition{
architecturekit.OnPristineSubject(c.Subject()),
}
}
Requiring an Existing SubjectRequires EventSourcingDB 1.2#
If a command may only write events in case its subject already has at least one event, use the OnPopulatedSubject function. Combine it with OnStateRead to also guard the state:
func (c ReturnBook) Preconditions() []architecturekit.Precondition {
return []architecturekit.Precondition{
architecturekit.OnPopulatedSubject(c.Subject()),
architecturekit.OnStateRead(),
}
}
Note that a database without the isSubjectPopulated precondition refuses the write with 400 Bad Request, so that Execute and Write fail with an error of the category ErrPermanent.
Enforcing Rules Across Subjects#
If a command may only write events depending on an EventQL query, use the NewIsEventQLQueryTruePrecondition function of the client SDK, and wrap it with the Require function. For example, to acquire every ISBN only once, extend the preconditions of AcquireBook:
func (c AcquireBook) Preconditions() []architecturekit.Precondition {
return []architecturekit.Precondition{
architecturekit.OnPristineSubject(c.Subject()),
architecturekit.Require(eventsourcingdb.NewIsEventQLQueryTruePrecondition(fmt.Sprintf(
"FROM e IN events WHERE e.type == 'io.eventsourcingdb.library.book-acquired' AND e.data.isbn == '%s' PROJECT INTO COUNT() == 0",
c.ISBN,
))),
}
}
Note that the query must return a single row with a single value, which is interpreted as a boolean.
The ISBN comes from outside, but becomes part of the query as text, so check it first. Execute sends the query to the database only together with the events, once the decider has decided, so a check in the decider keeps every value that is not an ISBN out of the query. Here, an ISBN consists of digits and hyphens, and may end with an X:
var isbnPattern = regexp.MustCompile(`^[0-9][0-9-]*[0-9X]$`)
var acquireBook = architecturekit.NewDecider(bookState,
func(ctx context.Context, cmd AcquireBook, book Book) ([]architecturekit.Event, error) {
if !isbnPattern.MatchString(cmd.ISBN) {
return nil, architecturekit.NewDomainError("%q is not an ISBN", cmd.ISBN)
}
// ...
})
Note that a value must never go into an EventQL query unchecked. A quote, as in it's, breaks the query, so that the write fails, and a value made up for that purpose changes what the query checks.
Changed in 2.0When the ISBN is taken, the database refuses the write with 409 Conflict and the reason state conflict: precondition failed, the same answer as for a subject that has changed since it was read. It does not say which precondition failed, so the kit can not tell the two apart:
- The command fails with an error of the category
ErrConflict, which is transient (see Handling Errors). - The
httpapipackage answers it with409 Conflictand the messageconflict: the data has changed since it was read, which tells the caller to try again, although that never helps (see Handling Commands over HTTP). - Changed in 2.1If the command declares
OnStateReadas well, and the store decides again on conflicts,Executedecides again and again in vain, until the retries are used up (see Guarding Against Concurrent Changes).
To answer a taken ISBN with a domain error instead, check it in the decider, against a view that holds the acquired books by their ISBN (see Defining Views). The catalog does not hold the ISBN, so here, a small view of its own does, which a projection fills (see Defining Projections):
type ISBNItem struct {
ISBN string
BookID string
}
func newISBNs() *architecturekit.InMemoryView[string, ISBNItem] {
return architecturekit.NewInMemoryView(func(item ISBNItem) string { return item.ISBN })
}
func newISBNProjection(isbns *architecturekit.InMemoryView[string, ISBNItem]) *architecturekit.TypedProjection {
return architecturekit.NewTypedProjection().
On(func(ctx context.Context, event architecturekit.Envelope[BookAcquired]) error {
_, err := isbns.Insert(ctx, event.ID, ISBNItem{
ISBN: event.Data.ISBN,
BookID: bookIDOf(event.Subject),
})
return err
})
}
The decider gets the view from a function that creates it, as it gets a clock (see Using the Current Time). If the ISBN is taken, it returns a domain error, which the httpapi package answers with 422 Unprocessable Entity and the error as the message, as in ISBN has already been acquired: 978-0756906788:
var ErrISBNAlreadyAcquired = architecturekit.NewDomainError("ISBN has already been acquired")
func acquireBookDecider(isbns architecturekit.KeyedView[string, ISBNItem]) architecturekit.Decider[AcquireBook, Book] {
return architecturekit.NewDecider(bookState,
func(ctx context.Context, cmd AcquireBook, book Book) ([]architecturekit.Event, error) {
if !isbnPattern.MatchString(cmd.ISBN) {
return nil, architecturekit.NewDomainError("%q is not an ISBN", cmd.ISBN)
}
_, isTaken, err := isbns.Get(ctx, cmd.ISBN)
if err != nil {
return nil, err
}
if isTaken {
return nil, fmt.Errorf("%w: %s", ErrISBNAlreadyAcquired, cmd.ISBN)
}
// ...
})
}
isbns := newISBNs()
acquireBook := acquireBookDecider(isbns)
Keep the EventQL precondition all the same, as the guard. A view lags behind the events, so when two acquisitions of the same ISBN arrive at the same moment, both pass the view, since it has seen neither of them. The database writes only one of them, and the other fails with ErrConflict, which is the right answer in that rare case: once the view has seen the first acquisition, trying again gets the domain error.
Writing Unconditionally#
If a command may write its events whatever has been written to its subject in the meantime, for example CommentOnBook, which only records a comment that does not depend on the state, use the Unconditionally function:
func (c CommentOnBook) Preconditions() []architecturekit.Precondition {
return []architecturekit.Precondition{
architecturekit.Unconditionally(),
}
}
Note that Unconditionally can not be combined with other preconditions.