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, each made by a function of Precondition:
on_state_readguards the state the command is decided on.on_pristine_subject,on_populated_subject, andon_event_idrequire 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 fails with a conflict, Transient::Conflict, which belongs to the category Category::Transient (see Handling Errors). If a command declares no preconditions, or combines unconditionally with others, execute fails with ExecuteError::Permanent before reading anything. To check the preconditions of a command this way without executing it, call the check_preconditions function with the command, which returns the same failure as a PreconditionsError, or Ok(()). The check_read_options function does the same for the options of a read (see Reading Events).
To find out what kind a precondition is, call its is_on_state_read or is_unconditional 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 on_pristine_subject and on_populated_subject make theirs, or the one of is_subject_on_event_id for on_event_id. For any other, it returns None.
on_pristine_subject, on_populated_subject, and on_event_id check the subject alone. To check it together with every subject below it, take the function of the client SDK that does the same, such as is_subject_pristine, hand it PreconditionOptions with recursive set to true, and wrap it with require. For example, to acquire a book only if neither its subject nor any subject below it has events yet:
use architecturekit::eventsourcingdb;
impl Command for AcquireBook {
fn subject(&self) -> Subject {
self.book.clone()
}
fn preconditions(&self) -> Vec<Precondition> {
vec![Precondition::require(eventsourcingdb::Precondition::is_subject_pristine(
self.subject(),
Some(eventsourcingdb::PreconditionOptions { recursive: true }),
))]
}
}
Note that only a database that offers the v2 API checks a recursive precondition. Against a database that offers just the v1 API, such as EventSourcingDB 1.2, the client does not send the write at all, so that nothing is written, and execute and write fail with a permanent failure, since only upgrading the database helps (see Handling Errors).
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 Precondition::on_state_read. This fits most commands, since the decider decides on exactly that state:
impl Command for ReturnBook {
fn subject(&self) -> Subject {
self.book.clone()
}
fn preconditions(&self) -> Vec<Precondition> {
vec![Precondition::on_state_read()]
}
}
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 a conflict. 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, call conflict_retries on the builder of the store, with the number of times to decide again:
let store =
Store::builder(client, "https://library.eventsourcingdb.io").conflict_retries(4).build();
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 the conflict, as without them.
Note that the store decides again only on a conflict. A write whose outcome is unknown, ExecuteError::OutcomeUnknown, is never tried again, since its events may have been stored (see Handling Errors).
Note that this only applies to commands whose preconditions include on_state_read. 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, or on_state_read together with any other precondition, such as one made with require, is decided again only if on_state_read did not hold. A database that offers the v2 API names the precondition that did not hold, so if it is another one, such as an outdated revision of the caller, execute reports the conflict at once. A database that offers just the v1 API, such as EventSourcingDB 1.2, does not name it, so then execute decides again whenever on_state_read is among the preconditions, and if another one did not hold, every attempt fails the same way, until the retries are used up. On the same subject, a revision of the caller and on_state_read need not both be checked: if the revision of the caller holds, so does on_state_read.
Note that the conflict is a Transient::Conflict in every case, and its source is the error of the client SDK, which holds the answer of the database (see Handling Errors). Through the v2 API, its details name the precondition that did not hold by its index among the preconditions the command declares, as in {"precondition":1}, so that you can find out yourself which one it was.
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 Precondition::on_event_id with the subject and the ID of the last event the caller has seen. For that, add a field for the ID, which is a Revision:
use architecturekit::Revision;
#[derive(Clone)]
struct BorrowBook {
book: Subject,
reader_id: String,
borrowed_until: String,
expected_event_id: Revision,
}
impl Command for BorrowBook {
fn subject(&self) -> Subject {
self.book.clone()
}
fn preconditions(&self) -> Vec<Precondition> {
vec![Precondition::on_event_id(self.subject(), self.expected_event_id)]
}
}
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 an outdated event ID is reported as a conflict at once, also by a store that decides again on conflicts, since deciding again would fail the same way. That holds as well for a command that declares on_state_read besides, as long as the database offers the v2 API, which names the precondition that did not hold. A database that offers just the v1 API does not, so then execute decides again until the retries are used up (see Guarding Against Concurrent Changes).
Note that the event ID usually comes from the caller, so it is read as a Revision before the command exists, with parse, which refuses an empty ID, or one that is not an event ID at all, with a NotARevision that names it, as in not a revision: "abc". serde reads a Revision from a string the same way, so a request body with such an ID can not be read. The httpapi module answers a NotARevision with 400 Bad Request and the error as the message (see Mapping Errors to Status Codes). require with is_subject_on_event_id of the client SDK takes the ID as a string instead, and leaves it to the database, which refuses such an ID as a malformed request, so that execute fails with a permanent failure.
Preventing Duplicates#
If a command may only write events in case its subject does not yet have any events, use Precondition::on_pristine_subject:
impl Command for AcquireBook {
fn subject(&self) -> Subject {
self.book.clone()
}
fn preconditions(&self) -> Vec<Precondition> {
vec![Precondition::on_pristine_subject(self.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 Precondition::on_populated_subject. Combine it with on_state_read to also guard the state:
impl Command for ReturnBook {
fn subject(&self) -> Subject {
self.book.clone()
}
fn preconditions(&self) -> Vec<Precondition> {
vec![Precondition::on_populated_subject(self.subject()), Precondition::on_state_read()]
}
}
Note that a database without the isSubjectPopulated precondition refuses the write with 400 Bad Request, so that execute and write fail with a permanent failure.
Enforcing Rules Across Subjects#
If a command may only write events depending on an EventQL query, use the is_eventql_query_true function of the client SDK, and wrap it with Precondition::require. For example, to acquire every ISBN only once, extend the preconditions of AcquireBook:
impl Command for AcquireBook {
fn subject(&self) -> Subject {
self.book.clone()
}
fn preconditions(&self) -> Vec<Precondition> {
vec![
Precondition::on_pristine_subject(self.subject()),
Precondition::require(eventsourcingdb::Precondition::is_eventql_query_true(format!(
"FROM e IN events WHERE e.type == 'io.eventsourcingdb.library.book-acquired' AND e.data.isbn == '{}' PROJECT INTO COUNT() == 0",
self.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. A regular expression checks that, with the regex crate, which you add with cargo add regex:
use std::sync::LazyLock;
use architecturekit::{Decider, DomainError};
use regex::Regex;
static ISBN: LazyLock<Regex> = LazyLock::new(|| Regex::new("^[0-9][0-9-]*[0-9X]$").unwrap());
fn acquire_book() -> Decider<AcquireBook, Book, DomainError> {
Decider::new(|command: AcquireBook, book: Book| async move {
if !ISBN.is_match(&command.isbn) {
return Err(DomainError::new(format!("{:?} is not an ISBN", command.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.
When the ISBN is taken, the database refuses the write with the code precondition-violated, the same answer as for a subject that has changed since it was read. Which precondition did not hold is only in the details of the answer, so the kit reports the two alike:
- The command fails with a
Transient::Conflict, which is transient (see Handling Errors). - The
httpapimodule 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). - If the command declares
on_state_readas well, and the store decides again on conflicts,executereads the details to tell the two apart, and reports the taken ISBN at once, since deciding again would fail the same way. A database that offers just the v1 API sends no details, so thenexecutedecides 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):
use std::sync::Arc;
use architecturekit::{Envelope, InMemoryView, Projection, ViewError};
#[derive(Clone)]
struct IsbnItem {
isbn: String,
book_id: String,
}
struct IsbnProjection {
isbns: Arc<InMemoryView<String, IsbnItem>>,
}
impl Projection for IsbnProjection {
type Event = BookEvent;
type Error = ViewError;
async fn apply(&self, event: Envelope<BookEvent>) -> Result<(), ViewError> {
let BookEvent::Acquired(acquired) = &event.data else {
return Ok(());
};
let Some(book_id) = BOOKS.value(&event.subject, "book_id") else {
return Ok(());
};
self
.isbns
.insert(&event, IsbnItem { isbn: acquired.isbn.clone(), book_id: book_id.to_owned() })?;
Ok(())
}
}
The decider gets the view from a function that creates it, as it gets a clock (see Using the Current Time). The future of a decider owns what it uses, so the closure hands every decision a clone of the Arc. If the ISBN is taken, the decider returns a domain error, which the httpapi module answers with 422 Unprocessable Entity and the error as the message, as in ISBN has already been acquired: 978-0756906788:
use architecturekit::KeyedView;
fn acquire_book(
isbns: Arc<InMemoryView<String, IsbnItem>>,
) -> Decider<AcquireBook, Book, DomainError> {
Decider::new(move |command: AcquireBook, book: Book| {
let isbns = Arc::clone(&isbns);
async move {
if !ISBN.is_match(&command.isbn) {
return Err(DomainError::new(format!("{:?} is not an ISBN", command.isbn)));
}
let Ok(taken) = isbns.get(&command.isbn).await;
if taken.is_some() {
return Err(DomainError::new(format!("ISBN has already been acquired: {}", command.isbn)));
}
// ...
}
})
}
let isbns = Arc::new(InMemoryView::new(|item: &IsbnItem| item.isbn.clone()));
let isbn_projection = IsbnProjection { isbns: Arc::clone(&isbns) };
let acquire_book = acquire_book(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 a conflict, 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 Precondition::unconditionally:
impl Command for CommentOnBook {
fn subject(&self) -> Subject {
self.book.clone()
}
fn preconditions(&self) -> Vec<Precondition> {
vec![Precondition::unconditionally()]
}
}
Note that unconditionally can not be combined with other preconditions.