Somewhere in your past there is a pet store. Maybe you met it as Sun’s Java Pet Store in the early 2000s, maybe as the iBATIS demo that answered it, maybe as the MyBatis sample you cribbed a mapper config from in 2014. JPetStore has been the reference “small but real” Java web app for over twenty years, and that history is exactly why we’re porting it. You will never have to wonder what the app is supposed to do. You already know.

In this lesson we look at what we’re porting, run the original, and get a first Rust web server answering requests. No Rust knowledge assumed yet.

Three layers, thirteen tables

JPetStore 6 is a textbook layered application, and I mean that literally: it exists to demonstrate the layering.

The presentation layer is Stripes: ActionBean classes that bind form fields, handle events, and forward to JSP views. CartActionBean.addItemToCart() is a typical specimen.

The service layer is Spring-wired: CatalogService, AccountService, and OrderService, each a thin transactional wrapper over the layer below.

The persistence layer is MyBatis: XML mapper files holding real SQL, mapped onto plain Java objects. This is the layer that made JPetStore famous, and it’s the reason our Rust stack fits so naturally. MyBatis said “just let me write SQL” in an era of heavyweight ORMs. SQLx, which we meet in lesson 3, says the same thing with compile-time checking on top.

Underneath sits HSQLDB with thirteen tables in three clusters:

Cluster Tables
Catalog category, product, item, inventory, supplier
Accounts account, signon, profile, bannerdata
Orders orders, orderstatus, lineitem, sequence

Follow one request to see the whole machine move. You click “Add to Cart” on EST-1, the Large Angelfish. Stripes routes the request to CartActionBean, which asks CatalogService for the item, which calls the ItemMapper, which runs a SELECT joining item, product, and inventory. The bean drops the result into a session-scoped Cart object and forwards to Cart.jsp, which renders the table you see. Five files, four layers, one fish.

By lesson 7 that same click will be one handler function, one SQL query, and one template. Hold that thought.

Run the original

You’ll want the Java app running next to the port for the whole course, both to compare behavior and to keep us honest about parity. Clone it and bring it up with Docker:

git clone https://github.com/mybatis/jpetstore-6.git
cd jpetstore-6
docker compose up

Then browse to http://localhost:8080/jpetstore/. Sign in as j2ee / j2ee, add a fish to the cart, place an order. Twenty-year-old software, still doing its job.

While you’re in there, a scavenger hunt. Find these three things in the source, because we will deliberately not be porting them:

  1. Open the signon table’s seed data. Those are passwords, in plaintext, and the login code compares them with string equality.
  2. Look at the orders table definition. There’s a column for the full credit card number, and checkout writes it.
  3. Find the sequence table and getNextId(). That’s a hand-rolled auto-increment, built before databases made it everyone else’s problem.

All three were normal for demo code in 2002. None survive the port. The plan for what replaces them is written down in the companion repo’s port plan, which is worth a read before lesson 2.

Toolchain: from nothing to a listening socket

If you don’t have Rust installed, one command gets you the whole toolchain, compiler and package manager included:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Verify with cargo --version. Cargo is Maven’s job done with less ceremony: dependencies, builds, tests, and runs, all one tool, no XML.

Now the first server. Make a project and add our first two dependencies:

cargo new jpetstore-rs
cd jpetstore-rs
cargo add axum tokio --features tokio/macros,tokio/rt-multi-thread,tokio/net

Replace src/main.rs with this:

use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(home));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:8081").await.unwrap();
    println!("jpetstore-rs listening on http://{}", listener.local_addr().unwrap());
    axum::serve(listener, app).await.unwrap();
}

async fn home() -> &'static str {
    "jpetstore-rs — the pet store you already know, one binary at a time"
}

Run it:

cargo run

Browse to http://localhost:8081/. Port 8081 because the original is sitting on 8080, and the two will run side by side for eleven more lessons.

Sixteen lines. Let’s read them like a Java developer.

#[tokio::main] is an annotation, and it does what @SpringBootApplication wishes it did: it’s only a macro that wraps main in an async runtime. There’s no classpath scanning, no bean lifecycle, no magic you can’t step through. Router::new().route("/", get(home)) is your web.xml or @RequestMapping, except it’s a value you build and can test. And home is an async function returning a string slice; Axum turns the return value into an HTTP 200 with the right headers. No servlet interface, no HttpServletResponse to mutate.

Those two .unwrap() calls are the part a production codebase wouldn’t keep: they crash the program if binding or serving fails. They’re standing in for a real answer to “what happens when things go wrong,” and that answer is the entire subject of lesson 2.

What you didn’t install

Notice what this lesson never asked for: no Tomcat, no servlet container, no WAR packaging, no CATALINA_HOME. cargo run compiled a binary at target/debug/jpetstore-rs and executed it, and that binary is the web server. When we ship a release build in the final lesson, that one file will be the entire deployment artifact. The gap between those two worlds is what this course is really about.

Build it

File Action What goes in it
project root generated cargo new jpetstore-rs, then the cargo add line above
src/main.rs write All sixteen lines, shown in full above

Done when: cargo run, and curl http://localhost:8081/ answers with the greeting.

Answer key: the lesson-01 tag. Later lessons link a diff against the previous checkpoint; this one has no previous, so browse the tree.

Challenge: a health check

Every deployed service ends up needing one eventually. Add a second route, /health, that returns the string OK. You have every piece you need in the sixteen lines above. When it works, compare against the lesson-01 tag in the companion repo — your main.rs and mine should differ by about two lines.

Next lesson: we throw away the unwraps and build the error strategy the whole port will ride on.