Learn / Tutorials / First Steps with ArchitectureKit / Rust / 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 Command with 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 src/commands.rs:

use std::sync::LazyLock;

use architecturekit::{Command, Precondition, Subject, SubjectScheme};

pub static BOOK_SUBJECT: LazyLock<SubjectScheme> =
  LazyLock::new(|| SubjectScheme::new("/books/{book}"));

#[derive(Clone)]
pub struct AcquireBook {
  pub book_id: String,
  pub title: String,
  pub author: String,
  pub isbn: String,
}

impl Command for AcquireBook {
  fn subject(&self) -> Subject {
    BOOK_SUBJECT.build(&[&self.book_id])
  }

  fn preconditions(&self) -> Vec<Precondition> {
    vec![Precondition::on_state_read()]
  }
}

#[derive(Clone)]
pub struct BorrowBook {
  pub book_id: String,
  pub reader_id: String,
  pub borrowed_until: String,
}

impl Command for BorrowBook {
  fn subject(&self) -> Subject {
    BOOK_SUBJECT.build(&[&self.book_id])
  }

  fn preconditions(&self) -> Vec<Precondition> {
    vec![Precondition::on_state_read()]
  }
}

#[derive(Clone)]
pub struct ReturnBook {
  pub book_id: String,
}

impl Command for ReturnBook {
  fn subject(&self) -> Subject {
    BOOK_SUBJECT.build(&[&self.book_id])
  }

  fn preconditions(&self) -> Vec<Precondition> {
    vec![Precondition::on_state_read()]
  }
}

Every book has a subject of its own, such as /books/42, which holds all events of that book. A SubjectScheme defines the structure of these subjects once, and build composes a subject from the ID of a book. The scheme is a static, which LazyLock creates on first use, so that all commands share it, and later on, the view takes subjects apart again with the same scheme. For details, see Composing Subjects. The commands derive Clone, which executing them requires.

The precondition Precondition::on_state_read 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. Create it with Decider::new, from a closure that receives the command and the current state, and returns a future of the events to write, or of an error to reject the command. The decider takes the types of the command, the state, and the error from the closure, and the type of its events from the state.

Create a file src/deciders.rs:

use architecturekit::{Decider, DomainError};

use crate::book::{Book, BookEvent};
use crate::commands::{AcquireBook, BorrowBook, ReturnBook};
use crate::events::{BookAcquired, BookBorrowed, BookReturned};

pub fn acquire_book() -> Decider<AcquireBook, Book, DomainError> {
  Decider::new(|cmd: AcquireBook, book: Book| async move {
    if book.is_acquired {
      return Err(DomainError::new(format!("book {} has already been acquired", cmd.book_id)));
    }

    Ok(vec![BookEvent::Acquired(BookAcquired {
      title: cmd.title,
      author: cmd.author,
      isbn: cmd.isbn,
    })])
  })
}

pub fn borrow_book() -> Decider<BorrowBook, Book, DomainError> {
  Decider::new(|cmd: BorrowBook, book: Book| async move {
    if !book.is_acquired {
      return Err(DomainError::new(format!("book {} does not exist", cmd.book_id)));
    }
    if book.is_borrowed {
      return Err(DomainError::new(format!("book {} is already borrowed", cmd.book_id)));
    }

    Ok(vec![BookEvent::Borrowed(BookBorrowed {
      borrowed_by: cmd.reader_id,
      borrowed_until: cmd.borrowed_until,
    })])
  })
}

pub fn return_book() -> Decider<ReturnBook, Book, DomainError> {
  Decider::new(|_: ReturnBook, book: Book| async move {
    if !book.is_borrowed {
      return Ok(vec![]);
    }

    Ok(vec![BookEvent::Returned(BookReturned {})])
  })
}

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 a DomainError, which carries a message for the caller. The events are variants of BookEvent, so a decider can only return events that the state knows, which the compiler checks.

Returning a book that is not borrowed is not an error: there is simply nothing to do, so return_book returns no events, and no error.

The closure returns a future, so that a decider could load what else it needs to decide, such as the state of another book. These deciders need nothing else, and 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#

Add the new modules to src/lib.rs, which now reads:

pub mod book;
pub mod commands;
pub mod deciders;
pub mod events;
pub mod store;

To execute a command, call execute on the store with the decider and the command. Replace the content of src/main.rs with the following program, which acquires a book, lends it to one reader, and then tries to lend it to another one:

use std::error::Error;

use architecturekit::{Categorized, Category, DomainError, ExecuteError, eventsourcingdb::Event};
use library::commands::{AcquireBook, BorrowBook};
use library::deciders::{acquire_book, borrow_book};
use library::store::new_store;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
  let store = new_store().await?;

  let executed = store
    .execute(
      &acquire_book(),
      AcquireBook {
        book_id: "42".to_owned(),
        title: "2001 – A Space Odyssey".to_owned(),
        author: "Arthur C. Clarke".to_owned(),
        isbn: "978-0756906788".to_owned(),
      },
    )
    .await;
  report(executed)?;

  let executed = store
    .execute(
      &borrow_book(),
      BorrowBook {
        book_id: "42".to_owned(),
        reader_id: "23".to_owned(),
        borrowed_until: "2026-10-24".to_owned(),
      },
    )
    .await;
  report(executed)?;

  let executed = store
    .execute(
      &borrow_book(),
      BorrowBook {
        book_id: "42".to_owned(),
        reader_id: "17".to_owned(),
        borrowed_until: "2026-10-31".to_owned(),
      },
    )
    .await;
  report(executed)?;

  Ok(())
}

fn report(
  executed: Result<Vec<Event>, ExecuteError<DomainError>>,
) -> Result<(), ExecuteError<DomainError>> {
  match executed {
    Ok(written) => {
      for event in written {
        println!("Written: {} {} {}", event.id(), event.ty(), event.subject());
      }
      Ok(())
    }
    Err(failure) if failure.category() == Some(Category::Domain) => {
      println!("Rejected: {failure}");
      Ok(())
    }
    Err(failure) => Err(failure),
  }
}

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, as events of the client SDK.

The report function prints the written events, or the reason for a rejection. ArchitectureKit sorts its failures into categories, which category of the trait Categorized returns, and a DomainError belongs to the category Category::Domain. Everything else is treated as a failure here, which report hands back, so that main stops with it. For all categories, see Handling Errors.

Run the program:

cargo 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.