Everything so far has been read-only: the server renders what the database says and forgets you the moment the response ships. A cart changes the contract. Now the app remembers you, across requests, and mutates state on your behalf. This lesson brings sessions, POST forms, and the arithmetic Cents has been refusing to learn since lesson 4. Checkpoint: lesson-07.

The cart is a value, not a bean

The original’s Cart is a session-scoped object graph: a Map of CartItems, each wrapping a full Item, mutated from ActionBeans wherever convenient. Ours is a plain struct in domain/cart.rs that serializes with serde and knows nothing about HTTP:

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Cart {
    lines: Vec<CartLine>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CartLine {
    pub item_id: ItemId,
    pub name: String,
    pub unit_price: Cents,
    pub quantity: u32,
}

Note lines is private. The only ways in are methods that maintain the cart’s rules: add merges instead of duplicating a line, set_quantity treats zero as removal, and both cap quantity at 9999 — the same limit the form’s max attribute suggests, enforced where it can’t be bypassed, because a hand-crafted POST doesn’t read HTML attributes.

Each line captures the display name and unit price at add time. The original does the same thing by parking whole Item objects in the session. It means the price a buyer put in the cart is the price the cart shows — and it’s a real decision with a real tradeoff, which lesson 9 revisits when orders get written to disk.

And money finally learns arithmetic — exactly as much as the cart needs:

impl Cents {
    pub fn times(self, quantity: u32) -> Cents {
        Cents(self.0 * i64::from(quantity))
    }
}

impl std::iter::Sum for Cents { /* sum the inner i64s */ }

A subtotal is unit_price.times(quantity); a total is subtotals summed. There is still no Add for two Cents, because no code adds two prices directly. Three lessons of restraint, and the API surface is still exactly what’s used.

Sessions bring the port’s seventh dependency. tower-sessions splits the job in two: a store holds the session data server-side, and a layer wraps the router, handing out cookie ids and loading the matching session for every request:

let sessions = SessionManagerLayer::new(MemoryStore::default()).with_secure(false);

Router::new()
    // ...routes...
    .fallback(not_found)
    .layer(sessions)
    .with_state(pool)

This is servlet-container session management as a library you can read — HttpSession without the container. MemoryStore means a restart empties every cart, which is the same amnesia the original’s in-memory database had; a durable store arrives with accounts in lesson 8. with_secure(false) is honest about dev being plain http.

Handlers ask for the session like they ask for the pool — an extractor argument — and the cart rides under one key:

async fn load(session: &Session) -> AppResult<Cart> {
    Ok(session.get::<Cart>(CART_KEY).await?.unwrap_or_default())
}

That unwrap_or_default encodes a rule worth saying out loud: a visitor with no cart and a visitor with an empty cart are the same visitor. No “session not initialized” errors, no null checks downstream.

POST, redirect, GET

Mutations arrive as forms. Here’s add-to-cart, the busiest of the three:

pub async fn add(
    State(pool): State<SqlitePool>,
    session: Session,
    Form(form): Form<AddForm>,
) -> AppResult<Redirect> {
    let id: ItemId = parse_id(form.item_id)?;
    let item = db::catalog::item(&pool, &id).await?.ok_or(AppError::NotFound)?;
    let product = db::catalog::product(&pool, &item.product_id)
        .await?.ok_or(AppError::NotFound)?;

    let name = match &item.attribute {
        Some(attr) => format!("{attr} {}", product.name),
        None => product.name.clone(),
    };

    let mut cart = load(&session).await?;
    cart.add(item.id, name, item.list_price);
    save(&session, &cart).await?;
    Ok(Redirect::to("/cart"))
}

Three things to notice. parse_id moved to web/mod.rs — form values are a boundary exactly like path segments, and now both use the same door. The item gets fetched fresh, so a form claiming EST-999 404s instead of polluting the cart. And the return type is Redirect, not a page: every mutation answers 303, go look at /cart. That’s the Post/Redirect/Get pattern, and it’s why refreshing the cart page rerenders it instead of re-buying a fish. The original forwards to a JSP after mutating, which is precisely how double-submit bugs are born.

update and remove are the same shape minus the database trip. The cart page gives every line its own small update form and remove form — one mutation per POST keeps the handlers boring, and boring handlers are the ones without bugs.

Build it

File Action What goes in it
Cargo.toml generated cargo add tower-sessions
src/domain/cart.rs write Cart and CartLine shown above, plus the methods (add, set_quantity, remove, lines, is_empty, total) and five tests: merge-on-add, zero-removes, totals across lines, the 9999 cap, absent-item no-op
src/domain/catalog.rs modify times and Sum for Cents, shown above; serde derives on Cents and ItemId (they ride in the session now)
src/domain/mod.rs modify pub mod cart;
src/web/cart.rs write load/save, the view handler, and add shown above; write update and remove from the same shape
src/web/mod.rs modify Four cart routes (one GET, three POST), the session layer, and parse_id relocated here as pub(crate)
src/web/catalog.rs modify Import parse_id from web instead of defining it
src/web/error.rs modify Session(#[from] tower_sessions::session::Error) variant, mapped to 500
templates/cart.html write Empty state, the lines table with per-row update/remove forms, the total
templates/item.html, product.html modify Real add-to-cart forms replace the lesson-7 placeholder
templates/base.html modify A Cart link in the header

Done when: adding EST-1 twice and EST-20 once shows a $38.50 total; setting EST-1 to quantity 5 shows $88.00; removing both shows the empty state; a POST to /cart/items answers 303; and cargo test shows 15 passing.

Answer key: lesson-06...lesson-07.

Challenge: the cart badge

The header says “Cart”. Make it say “Cart (3)” when three animals are waiting. You’ll need a count method on Cart, and — the actual puzzle — a way for the base template to know it. Every page template declares its own struct, so decide: does each catalog handler load the session and pass a count through, or does the badge only appear on pages that already have the session? There’s no free answer; feel the cost of cross-cutting state in a system where pages declare their inputs. (The original solves it with a session-scoped bean visible to every JSP. That convenience is exactly what you’re missing, and exactly what it cost.)

Next lesson: accounts. Magic words like j2ee/j2ee return — hashed this time — along with registration, sign-in, and the auth extractor that gates lesson 10’s order history.