Skip to content
rusthacks.dev
Architecture

CQRS in Rust and Whether It Pays Off for You Compared

CQRS splits the model you write to from the model you read from. See the idea in Rust types, where it helps (different read shapes, scale) and where it only adds moving parts.

3 min read
Summarize in AI
Flat illustration for the post “CQRS in Rust and Whether It Pays Off for You Compared”, composed around architecture.

CRUD code uses one struct for everything: it is what you validate on write, store, and send to the screen. As an application grows, that struct strains. Writes want strict rules and normalized data; reads want joins and summaries shaped for each page. CQRS gives each side its own model. Everything compiles on Rust 1.90 with edition 2024 and uses only std types in the examples.

The smallest version: two kinds of function

rust
use std::collections::HashMap;

pub type OrderId = u64;

// ---- write side ----
pub struct PlaceOrder {
    pub customer: String,
    pub items: Vec<(String, u32)>,
}

#[derive(Debug)]
pub enum OrderError {
    Empty,
    BadQuantity,
}

#[derive(Default)]
pub struct Db {
    next_id: OrderId,
    orders: HashMap<OrderId, (String, Vec<(String, u32)>)>,
}

impl Db {
    pub fn place_order(&mut self, cmd: PlaceOrder) -> Result<OrderId, OrderError> {
        if cmd.items.is_empty() {
            return Err(OrderError::Empty);
        }
        if cmd.items.iter().any(|(_, q)| *q == 0) {
            return Err(OrderError::BadQuantity);
        }
        self.next_id += 1;
        self.orders.insert(self.next_id, (cmd.customer, cmd.items));
        Ok(self.next_id)
    }
}

// ---- read side ----
#[derive(Debug)]
pub struct OrderSummary {
    pub id: OrderId,
    pub customer: String,
    pub item_count: u32,
}

impl Db {
    pub fn recent_orders(&self, limit: usize) -> Vec<OrderSummary> {
        let mut ids: Vec<_> = self.orders.keys().copied().collect();
        ids.sort_unstable_by(|a, b| b.cmp(a));
        ids.into_iter()
            .take(limit)
            .map(|id| {
                let (customer, items) = &self.orders[&id];
                OrderSummary {
                    id,
                    customer: customer.clone(),
                    item_count: items.iter().map(|(_, q)| q).sum(),
                }
            })
            .collect()
    }
}

PlaceOrder is a command: a request to change state, validated and applied. OrderSummary is a read model: a shape designed for a list page, with a computed item_count. Neither is the stored representation. Handlers on the write side return an id or an error; query functions return plain data and never mutate.

What you gain

  • **Write rules stay focused.** The command handler checks invariants and nothing about display.
  • **Reads can be as denormalized as the page needs,** without polluting the domain model: a summary type per screen, one query per type.
  • **Different scaling.** Reads often outnumber writes by orders of magnitude and can use replicas or caches.
  • **Evolution is easier,** because adding a new screen means adding a read model, not changing the aggregate.

A separate read store

At larger scale the read side is a different table, index or database, updated from write-side changes:

Text
command -> write model -> database -> change feed -> projector -> read store
                                                                     ^
                                                            query ---+

The projector consumes events or change records and updates the read store. Delivery needs to be reliable, hence patterns like the outbox (outbox pattern). The price is **eventual consistency**: right after a command, a query may not show the change yet. The UI has to cope (return the created id and show the item from the response, poll, or read your own write from the primary).

When it does not pay

SituationVerdict
Forms over tables, reads match writesplain CRUD
Moderate load, one databaseplain CRUD, maybe a read function per screen
Complex rules plus many different read shapesCQRS in one database
Heavy read load or search needsseparate read store
Audit trail or temporal queriespair with event sourcing

A lot of "CQRS" benefit comes from the first step only: stop returning the write struct everywhere, and write dedicated query types. Do that before adding buses and stores. Rust helps because types are cheap: a read model per screen is a small struct and a FromRow derive (FromRow).

Organizing the code

Text
src/
  commands/    place_order.rs   (validate, change state)
  queries/     recent_orders.rs (read-only, returns summaries)
  domain/      order.rs         (invariants)

Keep query code free of domain imports and command code free of display concerns. The layering ideas are in hexagonal architecture and clean architecture.

Taggedcqrs
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.