Lesson 2 of 30
What Is a Microservice
A microservice is a small program that does one job, owns its own data, and is started and stopped on its own. This lesson builds a store as one program, then splits it into two programs and shows what changes.
The lesson uses Axum, a Rust web framework. Routes, handlers, and JSON in Axum are taught in Your First Axum Server. Each term is also defined here at first use.
Words used in this lesson:
| Term | Meaning |
|---|---|
| Process | One running program. Two programs started separately are two processes. |
| Port | A number on a machine. A program that waits for requests listens on one port. Two processes cannot listen on the same port. |
| HTTP | The text format a client uses to send a request and a server uses to send a response. |
| Endpoint | One address a program answers, such as POST /orders. |
| Handler | A Rust function that answers one endpoint. |
| JSON | A text format for data: {"name":"Pen","price_cents":120}. |
| Status code | A number in the response: 200 means success, 404 means not found, 502 means a server this server called failed. |
Project setup
The store has two parts. The catalog knows which products exist and what they cost. Orders records that a customer bought some quantity of a product.
Example 1 is one crate. Create it with cargo new storefront, and replace Cargo.toml.
Cargo.toml
[package]
name = "storefront"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "net"] }axumis the web framework.serdeconverts Rust structs to and from JSON. Thederivefeature adds#[derive(Serialize, Deserialize)].tokiorunsasynccode.macrosenables#[tokio::main],rt-multi-threadis the runtime itself, andnetgivesTcpListener.
Example 1: one program with two modules
Catalog and orders are two modules (named groups of code) inside one process. Orders calls a catalog function to look up a product.
src/catalog.rs
#[derive(Clone, Copy)]
pub struct Product {
pub id: u64,
pub name: &'static str,
pub price_cents: u32,
}
const PRODUCTS: [Product; 2] = [
Product { id: 1, name: "Notebook", price_cents: 450 },
Product { id: 2, name: "Pen", price_cents: 120 },
];
pub fn find(id: u64) -> Option<Product> {
PRODUCTS.iter().copied().find(|p| p.id == id)
}What each line does:
Productis one product.Clone, Copylets the code copy it instead of borrowing it.pubmakes the struct and its fields visible to other modules.PRODUCTSis a fixed list of two products. Prices are whole cents (450 is 4.50) because whole numbers avoid rounding errors.findreturnsSome(product)when the id exists andNonewhen it does not.iter().copied().find(...)walks the list and stops at the first match.
src/orders.rs
use std::sync::atomic::{AtomicU64, Ordering};
use serde::Serialize;
use crate::catalog::Product;
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Serialize)]
pub struct Order {
pub id: u64,
pub product_id: u64,
pub name: &'static str,
pub unit_price_cents: u32,
pub quantity: u32,
pub total_cents: u32,
}
pub fn place(product: Product, quantity: u32) -> Order {
Order {
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
product_id: product.id,
name: product.name,
unit_price_cents: product.price_cents,
quantity,
total_cents: product.price_cents.saturating_mul(quantity),
}
}What each line does:
NEXT_IDis a counter shared by the whole process.fetch_add(1, ...)returns the current value and adds one, so each order gets the next number. Restarting the program resets it to 1.#[derive(Serialize)]letsJson(order)turn the struct into JSON. Without it the code does not compile.- The order stores its own copy of
nameandunit_price_cents. A later price change in the catalog does not change old orders. saturating_mulmultiplies and stops at the largestu32instead of overflowing.
src/main.rs
use axum::{http::StatusCode, routing::post, Json, Router};
use serde::Deserialize;
mod catalog;
mod orders;
#[derive(Deserialize)]
struct NewOrder {
product_id: u64,
quantity: u32,
}
async fn create_order(
Json(input): Json<NewOrder>,
) -> Result<(StatusCode, Json<orders::Order>), StatusCode> {
let product = catalog::find(input.product_id).ok_or(StatusCode::UNPROCESSABLE_ENTITY)?;
let order = orders::place(product, input.quantity);
Ok((StatusCode::CREATED, Json(order)))
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/orders", post(create_order));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}What each line does:
mod catalog;andmod orders;loadsrc/catalog.rsandsrc/orders.rs.NewOrderis the JSON the client must send.Deserializelets Axum build it from the request body.create_orderis the handler.Json(input)in the arguments reads the body and fails the request if it does not matchNewOrder.ok_or(StatusCode::UNPROCESSABLE_ENTITY)?turnsNoneinto a 422 response and returns early. 422 means the request was well formed but its content cannot be used.Ok((StatusCode::CREATED, Json(order)))answers with status 201 and the order as JSON.Router::new().route("/orders", post(create_order))says: a POST to/ordersrunscreate_order. A router maps addresses to handlers.bind("127.0.0.1:3000")listens on port 3000, reachable only from this machine.axum::servehandles requests until the program stops.
Run it with cargo run, then place an order from a second terminal. curl -i prints the status line and headers before the body. The date header changes on every call and is left out of the outputs in this lesson.
curl -i -X POST localhost:3000/orders -H 'content-type: application/json' -d '{"product_id":1,"quantity":2}'HTTP/1.1 201 Created
content-type: application/json
content-length: 95
{"id":1,"product_id":1,"name":"Notebook","unit_price_cents":450,"quantity":2,"total_cents":900}The catalog lookup is a normal function call. It cannot time out, and the compiler checks the types on both sides.
Common mistakes and the responses they produce:
curl -i -X POST localhost:3000/orders -H 'content-type: application/json' -d '{"product_id":99,"quantity":1}'HTTP/1.1 422 Unprocessable Entity
content-length: 0
Product 99 does not exist, so find returned None.
curl -i -X POST localhost:3000/orders -H 'content-type: application/json' -d '{"product_id":1}'HTTP/1.1 422 Unprocessable Entity
content-type: text/plain; charset=utf-8
content-length: 103
Failed to deserialize the JSON body into the target type: missing field `quantity` at line 1 column 16The body has no quantity, so Json<NewOrder> rejected it before create_order ran.
curl -i localhost:3000/ordersHTTP/1.1 405 Method Not Allowed
allow: POST
content-length: 0
The path exists but only for POST. A GET gets 405. A path that no route matches gets 404.
Example 2: the catalog as its own program
To split, the catalog becomes a second program with its own port. A Cargo workspace holds both programs in one folder. Each has its own Cargo.toml and builds its own executable.
Cargo.toml (workspace root, replaces the crate file from Example 1)
[workspace]
members = ["catalog", "orders"]
resolver = "2"catalog/Cargo.toml
[package]
name = "catalog"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "net"] }catalog/src/main.rs
use axum::{extract::Path, http::StatusCode, routing::get, Json, Router};
use serde::Serialize;
#[derive(Clone, Copy, Serialize)]
struct Product {
id: u64,
name: &'static str,
price_cents: u32,
}
static PRODUCTS: [Product; 2] = [
Product { id: 1, name: "Notebook", price_cents: 450 },
Product { id: 2, name: "Pen", price_cents: 120 },
];
async fn get_product(Path(id): Path<u64>) -> Result<Json<Product>, StatusCode> {
PRODUCTS
.iter()
.copied()
.find(|p| p.id == id)
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/products/{id}", get(get_product));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3001").await.unwrap();
axum::serve(listener, app).await.unwrap();
}What changed from Example 1:
findbecame the endpointGET /products/{id}.{id}is a placeholder, andPath(id)gives the handler the value from the address as au64.Productnow derivesSerializebecause the program sends it as JSON. The function call became a response body.- The program listens on port 3001, so it can run beside a program on port 3000.
- An unknown id gives
NOT_FOUND(404). Before,findreturnedNoneinside the same program.
Start it with cargo run -p catalog and call it.
curl -i localhost:3001/products/1HTTP/1.1 200 OK
content-type: application/json
content-length: 44
{"id":1,"name":"Notebook","price_cents":450}curl -i localhost:3001/products/99HTTP/1.1 404 Not Found
content-length: 0
The catalog owns the product list. No other program has the list. Another program that needs a product must ask this endpoint.
Example 3: orders calls the catalog over HTTP
Orders becomes a second program. To look up a product it sends an HTTP request to the catalog. reqwest is the Rust library that sends HTTP requests, and its Client is the object that sends them.
orders/Cargo.toml
[package]
name = "orders"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
reqwest = { version = "0.12", default-features = false, features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "net"] }default-features = false removes reqwest's HTTPS support. Both programs run on one machine over plain HTTP, so it is not needed. json adds .json() for reading a JSON response.
orders/src/main.rs
use std::sync::atomic::{AtomicU64, Ordering};
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use serde::{Deserialize, Serialize};
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Clone)]
struct AppState {
client: reqwest::Client,
catalog_url: String,
}
#[derive(Deserialize)]
struct CatalogProduct {
name: String,
price_cents: u32,
}
#[derive(Deserialize)]
struct NewOrder {
product_id: u64,
quantity: u32,
}
#[derive(Serialize)]
struct Order {
id: u64,
product_id: u64,
name: String,
unit_price_cents: u32,
quantity: u32,
total_cents: u32,
}
async fn create_order(
State(state): State<AppState>,
Json(input): Json<NewOrder>,
) -> Result<(StatusCode, Json<Order>), StatusCode> {
let url = format!("{}/products/{}", state.catalog_url, input.product_id);
let response = state.client.get(url).send().await.map_err(|_| StatusCode::BAD_GATEWAY)?;
let product: CatalogProduct = response
.error_for_status()
.map_err(|_| StatusCode::BAD_GATEWAY)?
.json()
.await
.map_err(|_| StatusCode::BAD_GATEWAY)?;
let order = Order {
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
product_id: input.product_id,
name: product.name,
unit_price_cents: product.price_cents,
quantity: input.quantity,
total_cents: product.price_cents.saturating_mul(input.quantity),
};
Ok((StatusCode::CREATED, Json(order)))
}
#[tokio::main]
async fn main() {
let state = AppState {
client: reqwest::Client::new(),
catalog_url: "http://127.0.0.1:3001".to_owned(),
};
let app = Router::new().route("/orders", post(create_order)).with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}What each new part does:
AppStateholds values every request needs: the HTTP client and the catalog address. State is data Axum hands to each handler..with_state(state)registers it, andState(state)in the handler arguments reads it. Withoutwith_statethe code does not compile.reqwest::Client::new()is created once and reused, so open connections are reused between requests.CatalogProductlists only the fields orders needs. The JSON shape the catalog sends is the contract between the two programs: the agreed format both sides rely on. Orders readsnameandprice_centsand ignoresid..send().awaitsends the request and waits for the response headers. It fails if no connection can be made..error_for_status()turns a 4xx or 5xx response into an error. Without it a 404 body would be parsed as a product and fail later with a confusing message..json().awaitreads the body and builds aCatalogProduct. It fails if a field is missing.- Every failure becomes 502 Bad Gateway: this server called another server and the call failed.
Start the catalog in one terminal (cargo run -p catalog) and orders in another (cargo run -p orders).
curl -i -X POST localhost:3000/orders -H 'content-type: application/json' -d '{"product_id":2,"quantity":3}'HTTP/1.1 201 Created
content-type: application/json
content-length: 90
{"id":1,"product_id":2,"name":"Pen","unit_price_cents":120,"quantity":3,"total_cents":360}The response is the same as in Example 1. The work now crosses a network connection between two processes.
Example 4: what breaks when the catalog is down
Stop the catalog with Ctrl-C. Orders keeps running. Send the same request.
curl -i -X POST localhost:3000/orders -H 'content-type: application/json' -d '{"product_id":2,"quantity":3}'HTTP/1.1 502 Bad Gateway
content-length: 0
The operating system refused the connection because no process listens on port 3001. send() returned an error, and the handler mapped it to 502.
Two problems remain in Example 3:
- A request for product 99 also gives 502. The catalog answered 404, but
error_for_statusand themap_errturned it into the same status as a dead catalog. The caller cannot tell "bad product id" from "catalog broken". - If the catalog is running but slow,
send().awaitwaits with no limit. Every waiting order request holds memory and a connection, and the customer sees no answer.
Neither problem exists in Example 1, where the lookup is a function call.
Example 5: a timeout and separate failure statuses
A timeout is a time limit on a call. When the limit passes, the caller stops waiting and reports an error. Orders gets one, and each failure gets its own status: 422 for an unknown product, 504 for a timeout, 502 for anything else.
First the catalog gets an optional delay, so a slow catalog can be tested. The environment variable DELAY_MS sets how many milliseconds to wait before answering. This example needs the tokio time feature.
catalog/Cargo.toml (changed line: tokio features)
[package]
name = "catalog"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "net", "time"] }catalog/src/main.rs
use std::time::Duration;
use axum::{extract::Path, http::StatusCode, routing::get, Json, Router};
use serde::Serialize;
#[derive(Clone, Copy, Serialize)]
struct Product {
id: u64,
name: &'static str,
price_cents: u32,
}
static PRODUCTS: [Product; 2] = [
Product { id: 1, name: "Notebook", price_cents: 450 },
Product { id: 2, name: "Pen", price_cents: 120 },
];
async fn get_product(Path(id): Path<u64>) -> Result<Json<Product>, StatusCode> {
let delay_ms: u64 = std::env::var("DELAY_MS")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(0);
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
PRODUCTS
.iter()
.copied()
.find(|p| p.id == id)
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/products/{id}", get(get_product));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3001").await.unwrap();
axum::serve(listener, app).await.unwrap();
}The added lines read DELAY_MS (0 when unset or not a number) and wait that long with tokio::time::sleep. .await on a sleep pauses only this request, not the program.
Next, the code that calls the catalog moves into its own file in orders. Orders has two files now.
orders/src/catalog.rs
use std::time::Duration;
use axum::http::StatusCode;
use serde::Deserialize;
#[derive(Deserialize)]
pub struct CatalogProduct {
pub name: String,
pub price_cents: u32,
}
pub fn build_client() -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(1))
.timeout(Duration::from_secs(2))
.build()
.unwrap()
}
fn failure(error: &reqwest::Error) -> StatusCode {
if error.is_timeout() {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
}
}
pub async fn fetch_product(
client: &reqwest::Client,
base_url: &str,
id: u64,
) -> Result<CatalogProduct, StatusCode> {
let url = format!("{base_url}/products/{id}");
let response = client.get(url).send().await.map_err(|e| failure(&e))?;
if response.status() == StatusCode::NOT_FOUND {
return Err(StatusCode::UNPROCESSABLE_ENTITY);
}
let ok = response.error_for_status().map_err(|e| failure(&e))?;
ok.json().await.map_err(|e| failure(&e))
}What each part does:
connect_timeout(1s)limits how long opening the connection may take.timeout(2s)limits the whole request, including reading the body.failuremaps a timeout to 504 Gateway Timeout and every other error to 502.fetch_productchecks for a 404 first and maps it to 422, so an unknown product is a different result from a broken catalog.reqwest::StatusCodeand Axum'sStatusCodeare the same type from thehttpcrate, so==works.
orders/src/main.rs
use std::sync::atomic::{AtomicU64, Ordering};
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use serde::{Deserialize, Serialize};
mod catalog;
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Clone)]
struct AppState {
client: reqwest::Client,
catalog_url: String,
}
#[derive(Deserialize)]
struct NewOrder {
product_id: u64,
quantity: u32,
}
#[derive(Serialize)]
struct Order {
id: u64,
product_id: u64,
name: String,
unit_price_cents: u32,
quantity: u32,
total_cents: u32,
}
async fn create_order(
State(state): State<AppState>,
Json(input): Json<NewOrder>,
) -> Result<(StatusCode, Json<Order>), StatusCode> {
let product =
catalog::fetch_product(&state.client, &state.catalog_url, input.product_id).await?;
let order = Order {
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
product_id: input.product_id,
name: product.name,
unit_price_cents: product.price_cents,
quantity: input.quantity,
total_cents: product.price_cents.saturating_mul(input.quantity),
};
Ok((StatusCode::CREATED, Json(order)))
}
#[tokio::main]
async fn main() {
let state = AppState {
client: catalog::build_client(),
catalog_url: "http://127.0.0.1:3001".to_owned(),
};
let app = Router::new().route("/orders", post(create_order)).with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Start the catalog with a 5 second delay, and orders as before.
DELAY_MS=5000 cargo run -p catalogcurl -i -X POST localhost:3000/orders -H 'content-type: application/json' -d '{"product_id":1,"quantity":1}'HTTP/1.1 504 Gateway Timeout
content-length: 0
The response arrives after about 2 seconds, not 5. Orders stopped waiting at its own limit.
A timeout does not say whether the catalog finished its work. For a read this does not matter. For a write, such as charging a card, a retry could do the work twice. SQL transactions covers how to make retries safe.
What the split changed
| Property | One program | Two programs |
|---|---|---|
| Catalog lookup | Function call | HTTP request |
| Type check | Compiler checks both sides | JSON contract, checked at run time |
| Failures | The program runs or does not | The other program can be down, slow, or return an error while this one is healthy |
| Release | One build, one restart | Each program is built and restarted alone |
| Data | One process owns all memory | Each program owns its data, and others ask through its endpoints |
| Counter for order ids | One counter | Each running copy of orders has its own counter and would repeat ids |
Two more costs follow from the table:
- A field removed from the catalog response breaks orders when the request runs. The compiler does not see it.
- Splitting pays off when teams or parts of the system need to change, scale, or fail separately. Otherwise Example 1 has fewer parts to run and debug. Splitting a System Into Services covers where to draw the lines.
Complete example
The whole project is this section. File tree:
storefront/
Cargo.toml
catalog/
Cargo.toml
src/main.rs
orders/
Cargo.toml
src/
main.rs
catalog.rsCargo.toml (workspace root)
[workspace]
members = ["catalog", "orders"]
resolver = "2"catalog/Cargo.toml
[package]
name = "catalog"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "net", "time"] }catalog/src/main.rs
use std::time::Duration;
use axum::{extract::Path, http::StatusCode, routing::get, Json, Router};
use serde::Serialize;
#[derive(Clone, Copy, Serialize)]
struct Product {
id: u64,
name: &'static str,
price_cents: u32,
}
static PRODUCTS: [Product; 2] = [
Product { id: 1, name: "Notebook", price_cents: 450 },
Product { id: 2, name: "Pen", price_cents: 120 },
];
async fn get_product(Path(id): Path<u64>) -> Result<Json<Product>, StatusCode> {
let delay_ms: u64 = std::env::var("DELAY_MS")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(0);
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
PRODUCTS
.iter()
.copied()
.find(|p| p.id == id)
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/products/{id}", get(get_product));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3001").await.unwrap();
axum::serve(listener, app).await.unwrap();
}orders/Cargo.toml
[package]
name = "orders"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
reqwest = { version = "0.12", default-features = false, features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "net"] }orders/src/catalog.rs
use std::time::Duration;
use axum::http::StatusCode;
use serde::Deserialize;
#[derive(Deserialize)]
pub struct CatalogProduct {
pub name: String,
pub price_cents: u32,
}
pub fn build_client() -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(1))
.timeout(Duration::from_secs(2))
.build()
.unwrap()
}
fn failure(error: &reqwest::Error) -> StatusCode {
if error.is_timeout() {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
}
}
pub async fn fetch_product(
client: &reqwest::Client,
base_url: &str,
id: u64,
) -> Result<CatalogProduct, StatusCode> {
let url = format!("{base_url}/products/{id}");
let response = client.get(url).send().await.map_err(|e| failure(&e))?;
if response.status() == StatusCode::NOT_FOUND {
return Err(StatusCode::UNPROCESSABLE_ENTITY);
}
let ok = response.error_for_status().map_err(|e| failure(&e))?;
ok.json().await.map_err(|e| failure(&e))
}orders/src/main.rs
use std::sync::atomic::{AtomicU64, Ordering};
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use serde::{Deserialize, Serialize};
mod catalog;
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Clone)]
struct AppState {
client: reqwest::Client,
catalog_url: String,
}
#[derive(Deserialize)]
struct NewOrder {
product_id: u64,
quantity: u32,
}
#[derive(Serialize)]
struct Order {
id: u64,
product_id: u64,
name: String,
unit_price_cents: u32,
quantity: u32,
total_cents: u32,
}
async fn create_order(
State(state): State<AppState>,
Json(input): Json<NewOrder>,
) -> Result<(StatusCode, Json<Order>), StatusCode> {
let product =
catalog::fetch_product(&state.client, &state.catalog_url, input.product_id).await?;
let order = Order {
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
product_id: input.product_id,
name: product.name,
unit_price_cents: product.price_cents,
quantity: input.quantity,
total_cents: product.price_cents.saturating_mul(input.quantity),
};
Ok((StatusCode::CREATED, Json(order)))
}
#[tokio::main]
async fn main() {
let state = AppState {
client: catalog::build_client(),
catalog_url: "http://127.0.0.1:3001".to_owned(),
};
let app = Router::new().route("/orders", post(create_order)).with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Run and try it
Run each command in its own terminal, from the storefront folder.
cargo run -p catalogcargo run -p ordersRead a product from the catalog directly.
curl -i localhost:3001/products/1HTTP/1.1 200 OK
content-type: application/json
content-length: 44
{"id":1,"name":"Notebook","price_cents":450}Place an order.
curl -i -X POST localhost:3000/orders -H 'content-type: application/json' -d '{"product_id":2,"quantity":3}'HTTP/1.1 201 Created
content-type: application/json
content-length: 90
{"id":1,"product_id":2,"name":"Pen","unit_price_cents":120,"quantity":3,"total_cents":360}Order an unknown product.
curl -i -X POST localhost:3000/orders -H 'content-type: application/json' -d '{"product_id":99,"quantity":1}'HTTP/1.1 422 Unprocessable Entity
content-length: 0
Failure case 1: stop the catalog with Ctrl-C in its terminal, then repeat the order request from before. Orders is still running.
curl -i -X POST localhost:3000/orders -H 'content-type: application/json' -d '{"product_id":2,"quantity":3}'HTTP/1.1 502 Bad Gateway
content-length: 0
Failure case 2: start the catalog again with a delay longer than the 2 second limit, then repeat the request.
DELAY_MS=5000 cargo run -p catalogcurl -i -X POST localhost:3000/orders -H 'content-type: application/json' -d '{"product_id":2,"quantity":3}'HTTP/1.1 504 Gateway Timeout
content-length: 0
Start the catalog without DELAY_MS and the same request returns 201 with order id 2, because the order counter lives in the orders process and is still running.
Summary
- A microservice is a program that does one job, owns its own data, and is started and stopped on its own.
- Splitting turns a function call into an HTTP request between two processes.
- The JSON shape one service sends is a contract that the other service relies on. The compiler does not check it across programs.
- A service that calls another must handle the other being down (connection refused, 502) or slow (timeout, 504).
- A client timeout sets how long a caller waits. Without one, a slow service holds every waiting request.
- A timeout does not show whether the other service finished its work.
- Each service owns its data and other services read it through the owner's endpoints.
- Several running copies of a service each have their own memory, so counters and caches in memory are per copy.
