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.
A cookie, a store, and a layer
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.