Learn / Tutorials / First Steps with ArchitectureKit / Rust / Serving Commands and Queries over HTTP
Step 5 of 6

Serving Commands and Queries over HTTP

In this step, you turn the library into an HTTP API that accepts commands and answers queries, built with axum. The API keeps its view up to date while it runs, and lets a caller read its own writes.

Mapping Requests to Commands#

The commands from Making Decisions know nothing about HTTP. To accept them over HTTP, define a request type for each command, which serde reads from the JSON body, and a conversion that turns a request into a command.

First, add the feature axum of ArchitectureKit, which brings extractors and answers for axum 0.8 and turns on the feature http as well, then axum itself and chrono, which checks the dates below, and the feature net of Tokio, which brings the TcpListener that axum serves on:

cargo add architecturekit --features axum
cargo add axum@0.8 chrono
cargo add tokio --features net

Create a file src/requests.rs:

use architecturekit::httpapi::HttpError;
use chrono::NaiveDate;
use serde::Deserialize;

use crate::commands::{AcquireBook, BOOK_SUBJECT, BorrowBook, ReturnBook};
use crate::queries::ListBooks;

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcquireBookRequest {
  pub book_id: String,
  pub title: String,
  pub author: String,
  pub isbn: String,
}

impl TryFrom<AcquireBookRequest> for AcquireBook {
  type Error = HttpError;

  fn try_from(request: AcquireBookRequest) -> Result<Self, HttpError> {
    BOOK_SUBJECT.check(&[&request.book_id])?;

    Ok(AcquireBook {
      book_id: request.book_id,
      title: request.title,
      author: request.author,
      isbn: request.isbn,
    })
  }
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BorrowBookRequest {
  pub book_id: String,
  pub reader_id: String,
  pub borrowed_until: String,
}

impl TryFrom<BorrowBookRequest> for BorrowBook {
  type Error = HttpError;

  fn try_from(request: BorrowBookRequest) -> Result<Self, HttpError> {
    BOOK_SUBJECT.check(&[&request.book_id])?;
    if request.borrowed_until.parse::<NaiveDate>().is_err() {
      return Err(HttpError::malformed("borrowedUntil must be a date such as 2026-10-24"));
    }

    Ok(BorrowBook {
      book_id: request.book_id,
      reader_id: request.reader_id,
      borrowed_until: request.borrowed_until,
    })
  }
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReturnBookRequest {
  pub book_id: String,
}

impl TryFrom<ReturnBookRequest> for ReturnBook {
  type Error = HttpError;

  fn try_from(request: ReturnBookRequest) -> Result<Self, HttpError> {
    BOOK_SUBJECT.check(&[&request.book_id])?;

    Ok(ReturnBook { book_id: request.book_id })
  }
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListBooksRequest {
  #[serde(default)]
  pub only_available: bool,
}

impl From<ListBooksRequest> for ListBooks {
  fn from(request: ListBooksRequest) -> Self {
    ListBooks { only_available: request.only_available }
  }
}

Each request type derives Deserialize, with its fields named in camelCase, as in bookId, and each conversion implements TryFrom, which returns the command, or refuses the request with an HttpError. An HttpError says how the API answers, with a status code and a message. The library does not authenticate its users, so the reader who borrows a book is named in the body. For an API with authentication, see Setting Up an HTTP API.

The conversions are also the place to validate a request, since a refusal is answered before the command is executed, and HttpError::malformed answers with 400 Bad Request (for refusing with other status codes, see Authorizing Commands). They check two things:

  • The ID of a book becomes part of a subject, and composing a subject from an empty ID, or from one with a character that a segment of a subject may not contain, such as a slash, a dot, or a space, is a programming error that makes build panic. So the conversions first call check on the scheme of the subject, which returns an error for such an ID instead, and ? turns it into an HttpError with 400 Bad Request (see Composing Subjects).
  • The schema of BookBorrowed, which ArchitectureKit derives from the struct, only requires the return date to be a string, so EventSourcingDB would store any text as a return date. Checking the date turns a mistake of the caller into a 400 Bad Request, before anything is stored.

ListBooksRequest does the same for the ListBooks query, which can not be refused, so its conversion implements From. A query is asked with the HTTP method QUERY, which changes nothing, like GET, but carries a body, like POST, so a query takes its input from the body, as a command does. #[serde(default)] lets the body leave out whether to list only the available books, which then is false. GetBook needs no request type, since it reads the ID of the book from the path, as its handler below shows. That ID only looks up an item and never becomes part of a subject, so it does not need to be checked.

Mapping Items to Responses#

The items of the catalog do not derive Serialize, since what a caller sees is decided by a query and its answer, not by the view. So the answer over HTTP maps the items to a response type that derives Serialize, the counterpart of the request types above.

Create a file src/responses.rs:

use architecturekit::InMemoryView;
use architecturekit::httpapi::{HttpError, QueryAnswer};
use serde::Serialize;

use crate::catalog::BookItem;
use crate::queries::{GetBook, ListBooks, get_book, list_books};

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BookBody {
  pub id: String,
  pub title: String,
  pub author: String,
  pub is_borrowed: bool,
  pub borrowed_until: String,
}

impl From<BookItem> for BookBody {
  fn from(book: BookItem) -> Self {
    BookBody {
      id: book.id,
      title: book.title,
      author: book.author,
      is_borrowed: book.is_borrowed,
      borrowed_until: book.borrowed_until,
    }
  }
}

pub async fn answer_list_books(
  catalog: &InMemoryView<String, BookItem>,
  query: &ListBooks,
) -> Result<QueryAnswer<Vec<BookBody>>, HttpError> {
  let books = list_books(catalog, query).await?;
  let bodies = books.into_iter().map(BookBody::from).collect();

  Ok(QueryAnswer::new(bodies))
}

pub async fn answer_get_book(
  catalog: &InMemoryView<String, BookItem>,
  query: &GetBook,
) -> Result<QueryAnswer<BookBody>, HttpError> {
  let book = get_book(catalog, query).await?;

  Ok(QueryAnswer::found(book.map(BookBody::from)))
}

answer_list_books answers the ListBooks query with list_books from Defining a View and a Query, and turns every item into a BookBody. answer_get_book does the same for a single book with get_book. Both return a QueryAnswer, which the API answers with its result as JSON: QueryAnswer::new with the given result, and QueryAnswer::found with the book, or with 404 Not Found if there is none. The queries return the error of the view, which can not occur for a view in memory, and which ? turns into an HttpError, as it would for a view that can fail. Since the answer decides what a caller sees, the catalog can change its items without changing the API. For details, see Handling Queries over HTTP.

Wiring the API#

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

pub mod book;
pub mod catalog;
pub mod commands;
pub mod deciders;
pub mod events;
pub mod queries;
pub mod requests;
pub mod responses;
pub mod store;

Replace the content of src/main.rs with the following program, which serves the three commands and the two queries on port 8080:

use std::error::Error;
use std::sync::Arc;

use architecturekit::httpapi::{
  self, CommandAnswer, DEFAULT_WAIT, HttpError, NoBody, QueryAnswer, Reading, Strict,
};
use architecturekit::{
  CancellationToken, Decider, DomainError, InMemoryView, ProjectionOptions, Store, Subjects,
  wait_caught_up,
};
use axum::Router;
use axum::extract::{Path, State};
use axum::routing::post;
use library::book::Book;
use library::catalog::{BookItem, CatalogProjection, new_catalog};
use library::commands::{AcquireBook, BOOK_SUBJECT, BorrowBook, ReturnBook};
use library::deciders::{acquire_book, borrow_book, return_book};
use library::queries::{GetBook, ListBooks};
use library::requests::{
  AcquireBookRequest, BorrowBookRequest, ListBooksRequest, ReturnBookRequest,
};
use library::responses::{BookBody, answer_get_book, answer_list_books};
use library::store::new_store;
use tokio::net::TcpListener;

struct App {
  store: Store,
  acquire_book: Decider<AcquireBook, Book, DomainError>,
  borrow_book: Decider<BorrowBook, Book, DomainError>,
  return_book: Decider<ReturnBook, Book, DomainError>,
  catalog: Arc<InMemoryView<String, BookItem>>,
}

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

  let catalog = new_catalog();
  let run = store.start_projection(
    Subjects::tree(BOOK_SUBJECT.root()),
    Arc::new(CatalogProjection::new(Arc::clone(&catalog))),
    CancellationToken::new(),
    ProjectionOptions::named("catalog"),
  );

  wait_caught_up(&[&run]).await?;

  let app = App {
    store,
    acquire_book: acquire_book(),
    borrow_book: borrow_book(),
    return_book: return_book(),
    catalog,
  };

  let router = Router::new()
    .route("/api/acquire-book", post(handle_acquire_book))
    .route("/api/borrow-book", post(handle_borrow_book))
    .route("/api/return-book", post(handle_return_book))
    .route("/api/books", httpapi::query(handle_list_books))
    .route("/api/books/{id}", httpapi::query(handle_get_book))
    .with_state(Arc::new(app));

  let listener = TcpListener::bind("localhost:8080").await?;

  println!("Listening on http://localhost:8080.");
  axum::serve(listener, router).await?;

  Ok(())
}

async fn handle_acquire_book(
  State(app): State<Arc<App>>,
  Strict(request): Strict<AcquireBookRequest>,
) -> Result<CommandAnswer, HttpError> {
  let command = AcquireBook::try_from(request)?;
  let written = app.store.execute(&app.acquire_book, command).await?;

  Ok(CommandAnswer::new(&written))
}

async fn handle_borrow_book(
  State(app): State<Arc<App>>,
  Strict(request): Strict<BorrowBookRequest>,
) -> Result<CommandAnswer, HttpError> {
  let command = BorrowBook::try_from(request)?;
  let written = app.store.execute(&app.borrow_book, command).await?;

  Ok(CommandAnswer::new(&written))
}

async fn handle_return_book(
  State(app): State<Arc<App>>,
  Strict(request): Strict<ReturnBookRequest>,
) -> Result<CommandAnswer, HttpError> {
  let command = ReturnBook::try_from(request)?;
  let written = app.store.execute(&app.return_book, command).await?;

  Ok(CommandAnswer::new(&written))
}

async fn handle_list_books(
  State(app): State<Arc<App>>,
  reading: Reading,
  Strict(request): Strict<ListBooksRequest>,
) -> Result<QueryAnswer<Vec<BookBody>>, HttpError> {
  let query = ListBooks::from(request);

  reading
    .revisioned(&app.catalog, &query, DEFAULT_WAIT, || answer_list_books(&app.catalog, &query))
    .await
}

async fn handle_get_book(
  State(app): State<Arc<App>>,
  reading: Reading,
  Path(id): Path<String>,
  _: NoBody,
) -> Result<QueryAnswer<BookBody>, HttpError> {
  let query = GetBook { book_id: id };

  reading
    .revisioned(&app.catalog, &query, DEFAULT_WAIT, || answer_get_book(&app.catalog, &query))
    .await
}

Unlike catch_up_projection, start_projection does not stop once it has applied the stored events. It runs the projection on a task of its own and keeps observing new events, so the catalog follows every command the API executes, until the CancellationToken is canceled, which this program never does. ProjectionOptions::named gives the projection a name, by which it reports its failures. Since a half-built view would answer wrongly, the program calls wait_caught_up with the run, which returns once the projection has applied all events that were stored when it started, and only then serves requests. If the projection ends before, wait_caught_up returns why, and the program stops. An application with several projections hands over all of their runs at once.

The projection tells the catalog about every event in seen, so the catalog knows its revision, which the queries need to let callers read their own writes, as described below.

The handlers share an App with the store, the deciders, and the catalog, which axum hands to every handler as its state. Each command is an ordinary handler of axum:

  • Its extractor Strict reads the body as JSON into the request type. It answers a request without the header content-type: application/json with 415 Unsupported Media Type, and a body that does not fit the type, also one with a field the type does not know, with 400 Bad Request.
  • The handler turns the request into the command with try_from, and executes the command with its decider.
  • CommandAnswer::new answers with the revision the command has written, which is the ID of the last written event.

? turns every error on the way into an HttpError, which answers it by its category, such as a rejection of the decider with 422 Unprocessable Entity.

Each query is a handler as well, routed with httpapi::query, which accepts only the method QUERY, and answers any other one with 405 Method Not Allowed. handle_list_books reads its query from the body, as a command does. handle_get_book reads the ID of the book from the path, where the route names it {id}, and takes NoBody, which accepts only an empty body or {}. Both answer through revisioned of the extractor Reading, which reads the revision of the catalog, and calls the function that answers, answer_list_books or answer_get_book, once the catalog has caught up with the caller, as described below.

Start the API:

cargo run

It prints the following line:

Listening on http://localhost:8080.

Open another terminal and ask for all books. A query is sent with the method QUERY and its input as JSON in the body, which for all books is an empty object. The catalog has been built from the events of the previous steps:

curl \
  -X QUERY \
  -H "content-type: application/json" \
  -d '{}' \
  http://localhost:8080/api/books
[{"author":"Arthur C. Clarke","borrowedUntil":"2026-10-24","id":"42","isBorrowed":true,"title":"2001 – A Space Odyssey"}]

Sending Commands#

The library acquires a second copy of the book, with the ID 43:

curl \
  -i \
  -X POST \
  -H "content-type: application/json" \
  -d '{"bookId":"43","title":"2001 – A Space Odyssey","author":"Arthur C. Clarke","isbn":"978-0756906788"}' \
  http://localhost:8080/api/acquire-book

The API answers with 200 OK and the revision it has written, which is the ID of the last written event. Apart from the date header, which shows the current time in this and all following responses, it looks like this:

HTTP/1.1 200 OK
content-type: application/json
cache-control: no-store
content-length: 17
date: Fri, 13 Nov 2026 07:05:02 GMT

{"revision":"2"}

Now try to lend book 42 to a second reader:

curl \
  -i \
  -X POST \
  -H "content-type: application/json" \
  -d '{"bookId":"42","readerId":"17","borrowedUntil":"2026-10-31"}' \
  http://localhost:8080/api/borrow-book

The decider rejects the command, just as in Making Decisions, and the API answers with 422 Unprocessable Entity and the message of the rejection:

HTTP/1.1 422 Unprocessable Entity
content-type: application/json
cache-control: no-store
content-length: 42
date: Fri, 13 Nov 2026 07:05:02 GMT

{"message":"book 42 is already borrowed"}

A request with a return date that is not a date does not even reach the decider:

curl \
  -i \
  -X POST \
  -H "content-type: application/json" \
  -d '{"bookId":"43","readerId":"17","borrowedUntil":"tomorrow"}' \
  http://localhost:8080/api/borrow-book

Its conversion refuses it, and the API answers with 400 Bad Request:

HTTP/1.1 400 Bad Request
content-type: application/json
cache-control: no-store
content-length: 81
date: Fri, 13 Nov 2026 07:05:02 GMT

{"message":"malformed request: borrowedUntil must be a date such as 2026-10-24"}

Every error maps to the status code that matches it. For the complete list, see Mapping Errors to Status Codes.

Reading Your Own Writes#

The projection applies new events in the background, so the catalog lags a little behind the events that have been written. A query sent right after a command may therefore not see the command's events yet – the view is eventually consistent.

To see its own write, a caller sends the revision from the answer in the Wait-For-Revision header. The query then waits until the catalog has seen that event, for at most five seconds, as DEFAULT_WAIT says. Ask for the available books after acquiring book 43, whose event has the ID 2:

curl \
  -i \
  -X QUERY \
  -H "content-type: application/json" \
  -H "Wait-For-Revision: 2" \
  -d '{"onlyAvailable":true}' \
  http://localhost:8080/api/books

The response contains book 43, which is available, but not book 42, which is borrowed:

HTTP/1.1 200 OK
content-type: application/json
cache-control: private, no-cache
revision: 2
etag: "2-d9777700d4f2af6"
content-length: 115
date: Fri, 13 Nov 2026 07:05:09 GMT

[{"author":"Arthur C. Clarke","borrowedUntil":"","id":"43","isBorrowed":false,"title":"2001 – A Space Odyssey"}]

The revision header tells which revision the answer shows. For how the etag header saves a caller from downloading an unchanged answer again, see Reading Your Own Writes over HTTP.

Getting a Single Book#

To get a single book, put its ID into the path. The query has no other input, so the request has no body. Ask for book 43:

curl \
  -i \
  -X QUERY \
  http://localhost:8080/api/books/43

The response contains the book as a single object, rather than as a list:

HTTP/1.1 200 OK
content-type: application/json
cache-control: private, no-cache
revision: 2
etag: "2-af5ed19b920bef6c"
content-length: 113
date: Fri, 13 Nov 2026 07:05:09 GMT

{"author":"Arthur C. Clarke","borrowedUntil":"","id":"43","isBorrowed":false,"title":"2001 – A Space Odyssey"}

Now ask for a book that does not exist:

curl \
  -i \
  -X QUERY \
  http://localhost:8080/api/books/99

get_book returns None, which QueryAnswer::found answers with 404 Not Found and the message not found (see Mapping Errors to Status Codes):

HTTP/1.1 404 Not Found
content-type: application/json
cache-control: no-store
content-length: 24
date: Fri, 13 Nov 2026 07:05:09 GMT

{"message":"not found"}

Your Turn#

Try a few more requests, and use Wait-For-Revision to see their effect on the catalog:

  • Return book 42, and then lend it to reader 17.
  • Return book 43, although it is not borrowed. The API answers with 200 OK, but the revision is empty, since there was nothing to do.
  • Send a request without the content-type header of the examples, so that curl -d sends the body as application/x-www-form-urlencoded, or with a field the request type does not know, and look at the status codes.