Decision models
A decision model is the working pattern of the Dynamic Consistency Boundary. You have a decision to make, it depends on some events, and you want to record its result without another writer having invalidated it in between. The shape is always the same: read, fold, decide, append with the same query.
The four steps
Section titled “The four steps”Build the query from the projections the decision needs. Each projection filters on its own types and tags; the union of those filters is the query. Here the decision needs two counts, seats used on a course and enrolments held by a student, so the query is two items OR’d together.
Read once, at a position you remember. read_all returns the matching events and the watermark it
read up to. Fold every projection over that single pass.
Decide from the folded state. If the decision is “no”, there is no write, and nothing was locked, so nothing needs releasing.
Append the result guarded by the same query, with after set to the watermark you read at. The
store accepts the write only if nothing matching the query landed since. If it did, your model is
stale and the append is rejected.
The whole cycle
Section titled “The whole cycle”The retry handles a same-batch conflict, which is advisory: rebuild the model and try again. A durable conflict is different, and Handling conflicts covers the distinction in full.
/// The outcome of trying to enrol a student in a course.#[derive(Debug, PartialEq, Eq)]enum Outcome { Enrolled, CourseFull, AlreadyEnrolled,}
/// Enrol a student in a course, guarded so the seat limit holds even against a concurrent writer.////// The cycle reads exactly the events the decision depends on, folds them into the counts it/// needs, decides, and appends guarded by the same query from the position it read up to. A/// same-batch conflict is advisory, so the loop rebuilds the model and retries; a durable/// conflict is terminal and returns to the caller.fn enrol( client: &mut Client, course: &str, student: &str, seat_limit: usize,) -> Result<Outcome, Box<dyn Error>> { let course_tag = format!("course:{course}"); let student_tag = format!("student:{student}");
loop { // The boundary: everything tagged with this course, OR everything tagged with this student. let query = Query::items([ QueryItem::with_tags(Tags::new([Tag::new(&course_tag)?])?), QueryItem::with_tags(Tags::new([Tag::new(&student_tag)?])?), ]);
// Read once, fold both projections over the same pass. let (events, watermark) = client.read_all(query.clone(), Position::ZERO)?; let mut seats_used = 0usize; let mut already_enrolled = false; for seq in &events { if seq.event().event_type() != "StudentEnrolled" { continue; } let tags: Vec<&str> = seq.event().tags().collect(); if tags.contains(&course_tag.as_str()) { seats_used += 1; if tags.contains(&student_tag.as_str()) { already_enrolled = true; } } }
if already_enrolled { return Ok(Outcome::AlreadyEnrolled); } if seats_used >= seat_limit { return Ok(Outcome::CourseFull); }
// Append guarded by the same query, from the position the decision was made against. let event = Event::new( "StudentEnrolled", &[course_tag.as_str(), student_tag.as_str()], format!(r#"{{"course":"{course}","student":"{student}"}}"#).into_bytes(), )?; let guard = AppendCondition::new(query).after(watermark);
match client.append([event], Some(guard)) { Ok(_) => return Ok(Outcome::Enrolled), // Same-batch conflict: advisory and retryable, so rebuild the model and try again. Err(ClientError::Server { retryable: true, .. }) => continue, // Durable conflict, or any other server error: terminal for this attempt. Err(err) => return Err(Box::new(err)), } }}Why this is the boundary
Section titled “Why this is the boundary”The query that guards the append is the same query the decision read under, so the append fails on exactly the events the decision depended on, and on nothing else. Two students enrolling in the same last seat conflict, because both read the same seat count and both guard on the course’s tag. Two students enrolling in different courses do not, because their queries share no tags. No one configured that in advance; it fell out of what each decision read.
This is the payoff over a static aggregate boundary. You did not have to decide, when you designed the course and the student, that enrolment would need both. The decision declared its own boundary by reading what it needed.
Handling conflicts is the retry contract in detail. The uniqueness guard
is the special case where after is omitted. Core concepts has the exact
query and append-condition semantics.