Lesson 3 of 30
Splitting a System Into Services
The previous lesson split the Storefront into a catalog program and an orders program. This lesson covers how to decide where such a split goes and who owns which data. A service is a program that runs on its own and other programs call over the network. The hard part of a split is the data, not the code.
| Question | Example |
|---|---|
| Where do the boundaries go? | Example 1 |
| Who may read which table? | Example 2 |
| How does one service get another service's data? | Example 3 |
| How do you move a big program to services step by step? | Example 4 |
| When should you not split? | Last section |
Project setup
The examples use PostgreSQL 16 (a database server that stores data in tables), the psql command-line client, and two small Rust programs. The programs are two crates in one Cargo workspace. A workspace is a folder that holds several Rust projects and builds them together.
Cargo.toml (workspace root)
[workspace]
members = ["orders-copy", "strangler"]
resolver = "2"orders-copy/Cargo.toml (used in Example 3)
[package]
name = "orders-copy"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
serde = { version = "1.0", features = ["derive"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] }
tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "net"] }strangler/Cargo.toml (used in Example 4)
[package]
name = "strangler"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
reqwest = "0.12"
tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "net"] }axumis the web framework.tokioruns async code.serdeturns JSON text into Rust structs and back.sqlxtalks to PostgreSQL.reqwestsends HTTP requests to other programs.
Example 1: Split by business area
Start with one word: "product". Three teams in the Storefront use it, and each means something different.
- Catalog means a name, a description, and the current price.
- Inventory means how many units sit in which warehouse.
- Orders means what the customer bought: the name and price at the time of purchase.
A business area is a part of the company with its own rules and its own meaning for words like "product". Each business area becomes one service. Each service stores only the facts it needs. The three schema files below show this. A schema is the list of tables and columns a database holds. A table is a set of rows, and a column is one named field in every row.
catalog/schema.sql
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
price_cents INTEGER NOT NULL CHECK (price_cents > 0),
version BIGINT NOT NULL DEFAULT 1
);inventory/schema.sql
CREATE TABLE stock_items (
product_id BIGINT NOT NULL,
warehouse TEXT NOT NULL,
on_hand INTEGER NOT NULL CHECK (on_hand >= 0),
reserved INTEGER NOT NULL DEFAULT 0 CHECK (reserved >= 0),
PRIMARY KEY (product_id, warehouse)
);orders/schema.sql
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE order_lines (
order_id BIGINT NOT NULL REFERENCES orders (id),
line_no INTEGER NOT NULL,
product_id BIGINT NOT NULL,
name_at_purchase TEXT NOT NULL,
price_at_purchase_cents INTEGER NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
PRIMARY KEY (order_id, line_no)
);What each line does:
CREATE TABLE products (...)makes a table namedproducts. Each line inside the brackets defines one column.BIGSERIAL PRIMARY KEYonidgives every row a number the database picks. A primary key is a column whose value is unique per row, so one row can be found by it.TEXTis a string.INTEGERandBIGINTare whole numbers.TIMESTAMPTZis a date and time with a time zone.NOT NULLrejects a row that leaves the column empty.DEFAULT '...'fills the column when the row does not give a value.CHECK (price_cents > 0)rejects a row whose price is zero or negative. Prices are stored in cents (450 means 4.50) because whole numbers avoid rounding errors.versioninproductsstarts at 1. Catalog raises it each time the row changes. Example 3 uses it.stock_itemshas a two-column primary key: one row per product per warehouse.order_lines.order_id REFERENCES orders (id)is a foreign key: the database refuses a line whose order does not exist. That is allowed because both tables belong to Orders.product_idappears in all three schemas. It is the only link between the services.stock_items.product_idandorder_lines.product_idhave noREFERENCES. A foreign key cannot point into another service's database, and Example 2 shows why.name_at_purchaseandprice_at_purchase_centsare a snapshot: a copy taken at purchase time that never changes. A later price change in Catalog must not change an order that already exists. Orders owns this copy.
Each fact has one owner, the service that decides its value. Other services may hold copies.
| Fact | Owner | Copies held by |
|---|---|---|
| Product name and current price | Catalog | Orders (snapshot on each order line) |
| Units on hand | Inventory | Catalog page may show "in stock" |
| Order status | Orders | none yet |
If one action must change facts owned by two services, for example "place the order and reduce the stock", it crosses a boundary. Either service can fail halfway. A later lesson covers that case: Sagas. A boundary that many actions cross is often in the wrong place.
Example 2: Each service owns its own tables
The shortest path after a split is to keep one database and let every service read every table. It works on the first day. It breaks later, in ways that are hard to trace.
Here is a concrete failure. The Orders service runs this query to show the price of a product:
SELECT price_cents FROM products;The Catalog team decides prices need more precision, and renames the column in their own migration (a script that changes a schema):
ALTER TABLE products RENAME COLUMN price_cents TO price_minor;Catalog's tests pass, because Catalog's code was updated together with the rename. Orders was not touched, and its next query fails at run time:
ERROR: column "price_cents" does not exist
LINE 1: SELECT price_cents FROM products;
^The Orders code compiled and its tests passed. The failure appears only in production, in a service whose team did not make the change. The same coupling shows up in two more ways: a slow report query from Orders can slow down Catalog's writes, and any service that has credentials can change any row.
The rule is that only the owning service can connect to its data. Everyone else uses the owner's HTTP API. That API is the contract: the agreed set of paths, inputs, and outputs that the owner keeps stable. Inside the contract, the owner can rename columns freely.
The script below enforces the rule on a single PostgreSQL server for local practice. Each service gets its own database and its own login.
db/init.sql
CREATE ROLE catalog_svc LOGIN PASSWORD 'catalog';
CREATE ROLE inventory_svc LOGIN PASSWORD 'inventory';
CREATE ROLE orders_svc LOGIN PASSWORD 'orders';
CREATE DATABASE catalog OWNER catalog_svc;
CREATE DATABASE inventory OWNER inventory_svc;
CREATE DATABASE orders OWNER orders_svc;
REVOKE CONNECT ON DATABASE catalog FROM PUBLIC;
REVOKE CONNECT ON DATABASE inventory FROM PUBLIC;
REVOKE CONNECT ON DATABASE orders FROM PUBLIC;What each line does:
CREATE ROLE ... LOGIN PASSWORD ...makes a login name with a password. The passwords here are for local practice only.CREATE DATABASE catalog OWNER catalog_svcmakes a separate database and gives the matching login control of it.REVOKE CONNECT ON DATABASE ... FROM PUBLICremoves the default permission that lets any login connect.PUBLICmeans every login. Without these lines,orders_svccould still connect to the catalog database.
In production each service usually has its own database server, so a heavy query in one cannot slow another. Separate databases on one server, as here, give the ownership rule but not the load isolation.
Start PostgreSQL in Docker with the script mounted. The -v option puts the file where the image runs it on first start. Port 5432 is the standard PostgreSQL port.
docker run --name storefront-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 \
-v "$PWD/db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro" -d postgres:16Wait a few seconds for the server to start, then create each schema using that service's own login:
psql postgres://catalog_svc:catalog@127.0.0.1:5432/catalog -f catalog/schema.sql
psql postgres://inventory_svc:inventory@127.0.0.1:5432/inventory -f inventory/schema.sql
psql postgres://orders_svc:orders@127.0.0.1:5432/orders -f orders/schema.sqlThe address postgres://user:password@host:port/database is a connection URL. -f runs the SQL in a file. Now try to read Catalog's database as Orders:
psql postgres://orders_svc:orders@127.0.0.1:5432/catalog -c 'SELECT 1'psql: error: connection to server at "127.0.0.1", port 5432 failed: FATAL: permission denied for database "catalog"
DETAIL: User does not have CONNECT privilege.A join across two services (one query that reads tables from both) is now impossible. Data that used to come from a join must come from an API call or from a copy.
Example 3: Get another service's data
Orders needs the name and price of a product that Catalog owns. There are two ways to get it.
| Way | How it works | Cost |
|---|---|---|
| Call the API | Orders sends GET /products/1 to Catalog on every request |
Always current. Orders fails or slows down when Catalog is down or slow. |
| Keep a copy | Orders stores the fields it needs in its own table. Catalog tells Orders when a product changes. | Fast and works when Catalog is down. The copy can be out of date for a short time. |
The previous lesson used the first way. The second way needs a small table in the Orders database and a handler that updates it.
orders/product_copy.sql
CREATE TABLE product_copy (
product_id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL,
version BIGINT NOT NULL
);psql postgres://orders_svc:orders@127.0.0.1:5432/orders -f orders/product_copy.sqlThe table has only the fields Orders needs: no description, no stock. The copy is not a second owner. Catalog still decides the values, and the copy follows.
Catalog announces a change by sending an HTTP POST to Orders. The body is a change message: a small JSON document (text in the form {"key": value}) that says what changed. The program below is the Orders side. Later lessons show how a queue delivers such messages (Messaging and Kafka). The handler code stays the same.
orders-copy/src/main.rs
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::{postgres::PgPoolOptions, PgPool};
#[derive(Deserialize)]
struct ProductChanged {
product_id: i64,
name: String,
price_cents: i32,
version: i64,
}
#[derive(Serialize, sqlx::FromRow)]
struct ProductCopy {
product_id: i64,
name: String,
price_cents: i32,
version: i64,
}
async fn apply_change(
State(pool): State<PgPool>,
Json(event): Json<ProductChanged>,
) -> Result<StatusCode, StatusCode> {
sqlx::query(
"INSERT INTO product_copy (product_id, name, price_cents, version)
VALUES ($1, $2, $3, $4)
ON CONFLICT (product_id) DO UPDATE
SET name = EXCLUDED.name,
price_cents = EXCLUDED.price_cents,
version = EXCLUDED.version
WHERE product_copy.version < EXCLUDED.version",
)
.bind(event.product_id)
.bind(&event.name)
.bind(event.price_cents)
.bind(event.version)
.execute(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
async fn get_copy(
State(pool): State<PgPool>,
Path(id): Path<i64>,
) -> Result<Json<ProductCopy>, StatusCode> {
let copy = sqlx::query_as::<_, ProductCopy>(
"SELECT product_id, name, price_cents, version FROM product_copy WHERE product_id = $1",
)
.bind(id)
.fetch_optional(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
copy.map(Json).ok_or(StatusCode::NOT_FOUND)
}
#[tokio::main]
async fn main() {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new().max_connections(5).connect(&url).await.unwrap();
let app = Router::new()
.route("/events/product-changed", post(apply_change))
.route("/product-copy/{id}", get(get_copy))
.with_state(pool);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3003").await.unwrap();
axum::serve(listener, app).await.unwrap();
}What each part does:
uselines bring names into scope.Routermaps paths to handlers. A handler is an async function that receives a request and returns a response.Jsonreads or writes a JSON body.Stategives a handler the shared value given towith_state.Pathreads a value from the URL.StatusCodeis the number at the top of an HTTP response (204 means "done, no body", 404 means "not found").ProductChangedis the shape of the incoming message.#[derive(Deserialize)]lets serde build it from JSON. If a field is missing, axum answers 422 before the handler runs.ProductCopyis the shape of a stored row.Serializeturns it into JSON for the response.sqlx::FromRowlets sqlx fill it from a database row.PgPoolis a pool of open database connections that handlers share, so each request does not open a new one.PgPoolOptions::new().max_connections(5).connect(&url)opens the pool. The URL comes from theDATABASE_URLenvironment variable. The program stops with a message if the variable is missing.sqlx::query("...")builds a SQL statement.$1to$4are placeholders that.bind(...)fills in order. Placeholders keep values out of the SQL text, which blocks SQL injection (input that changes the meaning of a query).INSERT ... ON CONFLICT (product_id) DO UPDATEinserts a new row, or, if a row with thatproduct_idexists, updates it.EXCLUDEDnames the row that was being inserted.WHERE product_copy.version < EXCLUDED.versionmakes the update run only when the incoming message is newer than the stored row. A message that arrives late does not overwrite newer data. The same message sent twice changes nothing the second time..map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?turns a database failure into a 500 response. The?returns early on error.fetch_optionalreturnsNonewhen no row matches.ok_or(StatusCode::NOT_FOUND)turnsNoneinto a 404.Router::new().route(...)connects each path and method to a handler.{id}is a path value thatPath<i64>reads.TcpListener::bind("127.0.0.1:3003")opens port 3003 on this computer only.axum::servestarts answering requests.
A copy needs three more pieces in a real system: a first load of all products, a way to see how far behind it is, and a way to rebuild it from Catalog. The price that a customer is charged is not read from the copy. It comes from the snapshot on the order line, taken when the order is placed.
Example 4: Move a big program one piece at a time
A monolith is one program that holds all the features. Rewriting it as services all at once takes long, and nothing works until the end. The strangler approach moves one feature at a time. The name comes from a vine that grows around a tree until it replaces it.
A router (a small program that forwards each request to another program) is put in front of the monolith. The steps to move Catalog out:
- Put the router in front of the monolith. All requests pass to the monolith unchanged.
- Build the Catalog service with its own database. Load the product data into it.
- Send only
GET /products...requests to Catalog. Compare its answers with the monolith's. - Send product writes to Catalog. Make the old product tables read-only, then delete them.
At every step exactly one program writes each kind of data. Two writers to one table cause conflicting updates. Each step is undone by changing one rule in the router, with no data restore.
The router below sends paths that start with /products to Catalog and every other path to the monolith. It is a program of its own and needs the strangler crate from the setup section.
strangler/src/main.rs
use axum::{
body::Bytes,
extract::State,
http::{header::CONTENT_TYPE, HeaderMap, Method, StatusCode, Uri},
response::{IntoResponse, Response},
Router,
};
#[derive(Clone)]
struct Proxy {
client: reqwest::Client,
catalog: String,
legacy: String,
}
async fn forward(
State(proxy): State<Proxy>,
method: Method,
uri: Uri,
headers: HeaderMap,
body: Bytes,
) -> Response {
let base = if uri.path().starts_with("/products") {
&proxy.catalog
} else {
&proxy.legacy
};
let path = uri.path_and_query().map(|p| p.as_str()).unwrap_or("/");
let url = format!("{base}{path}");
let mut request = proxy.client.request(method, url).body(body);
if let Some(content_type) = headers.get(CONTENT_TYPE) {
request = request.header(CONTENT_TYPE, content_type);
}
let Ok(upstream) = request.send().await else {
return StatusCode::BAD_GATEWAY.into_response();
};
let status = upstream.status();
let content_type = upstream.headers().get(CONTENT_TYPE).cloned();
let Ok(bytes) = upstream.bytes().await else {
return StatusCode::BAD_GATEWAY.into_response();
};
let mut response = (status, bytes).into_response();
if let Some(content_type) = content_type {
response.headers_mut().insert(CONTENT_TYPE, content_type);
}
response
}
#[tokio::main]
async fn main() {
let proxy = Proxy {
client: reqwest::Client::new(),
catalog: std::env::var("CATALOG_URL").unwrap_or("http://127.0.0.1:3001".to_owned()),
legacy: std::env::var("LEGACY_URL").unwrap_or("http://127.0.0.1:3002".to_owned()),
};
let app = Router::new().fallback(forward).with_state(proxy);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}What each part does:
Proxyholds what every request needs: an HTTP client and the base addresses of the two targets.#[derive(Clone)]is required because axum gives each request its own copy of the state.reqwest::Clientis cheap to clone.Router::new().fallback(forward)sends every request toforward, whatever its path. Withoutfallback, axum would answer 404 for any path that has no.route.method,uri,headers, andbodyare extractors: function arguments that axum fills from the request.Methodis GET or POST.Uriis the path and query.HeaderMapholds the headers.Bytesis the raw body. Thebodyargument must come last.uri.path().starts_with("/products")is the routing rule. To move another feature, add anotherifbranch.path_and_query()keeps the whole path plus any?a=bpart.format!("{base}{path}")builds the target address, for examplehttp://127.0.0.1:3001/products/1.proxy.client.request(method, url).body(body)builds the outgoing request with the same method and body.- Only
Content-Typeis copied to the target. It tells the target that a body is JSON. Other headers are dropped, which is enough for this lesson. Forwarding more is covered in Gateway and discovery. let Ok(upstream) = ... else { ... }returns 502 when the target cannot be reached. 502 Bad Gateway means "a program behind me did not answer".- The last lines build the reply from the target's status and body, and copy its
Content-Typeback. unwrap_or("http://...")uses a default address when the environment variable is not set.
Start the Catalog service from the previous lesson on port 3001, leave port 3002 empty (no monolith is running), and start the router:
cargo run -p stranglerA product request goes to Catalog:
curl -s localhost:3000/products/1{"id":1,"name":"Notebook","price_cents":450}An orders request goes to the monolith address, where nothing is listening:
curl -si localhost:3000/orders | head -n 1HTTP/1.1 502 Bad GatewayWhen not to split
A split adds a network contract, separate deployments, failure handling, and data that is out of date for short times. Keep one service when these hold:
- One team owns all the code.
- Most changes touch several modules at once.
- Actions need one database transaction over the data (a group of changes that all succeed or all fail).
- The boundaries are still changing.
- Load is about the same for every feature.
A split is worth considering when several teams block each other on releases, when one part needs very different scaling, when one part must keep running while another fails, and when the boundaries have stayed stable for a while.
A distributed monolith is a set of services that must deploy together, share a database, or call each other in long chains. It has the costs of both designs and the benefits of neither. A single program with clear modules, each with a narrow set of public functions, is cheaper to split later.
Complete example
This workspace holds the three databases, the product copy consumer, and the router.
storefront-data/
Cargo.toml
db/init.sql
catalog/schema.sql
inventory/schema.sql
orders/schema.sql
orders/product_copy.sql
orders-copy/
Cargo.toml
src/main.rs
strangler/
Cargo.toml
src/main.rsCargo.toml (workspace root)
[workspace]
members = ["orders-copy", "strangler"]
resolver = "2"orders-copy/Cargo.toml
[package]
name = "orders-copy"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
serde = { version = "1.0", features = ["derive"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] }
tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "net"] }strangler/Cargo.toml
[package]
name = "strangler"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
reqwest = "0.12"
tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "net"] }db/init.sql
CREATE ROLE catalog_svc LOGIN PASSWORD 'catalog';
CREATE ROLE inventory_svc LOGIN PASSWORD 'inventory';
CREATE ROLE orders_svc LOGIN PASSWORD 'orders';
CREATE DATABASE catalog OWNER catalog_svc;
CREATE DATABASE inventory OWNER inventory_svc;
CREATE DATABASE orders OWNER orders_svc;
REVOKE CONNECT ON DATABASE catalog FROM PUBLIC;
REVOKE CONNECT ON DATABASE inventory FROM PUBLIC;
REVOKE CONNECT ON DATABASE orders FROM PUBLIC;catalog/schema.sql
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
price_cents INTEGER NOT NULL CHECK (price_cents > 0),
version BIGINT NOT NULL DEFAULT 1
);inventory/schema.sql
CREATE TABLE stock_items (
product_id BIGINT NOT NULL,
warehouse TEXT NOT NULL,
on_hand INTEGER NOT NULL CHECK (on_hand >= 0),
reserved INTEGER NOT NULL DEFAULT 0 CHECK (reserved >= 0),
PRIMARY KEY (product_id, warehouse)
);orders/schema.sql
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE order_lines (
order_id BIGINT NOT NULL REFERENCES orders (id),
line_no INTEGER NOT NULL,
product_id BIGINT NOT NULL,
name_at_purchase TEXT NOT NULL,
price_at_purchase_cents INTEGER NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
PRIMARY KEY (order_id, line_no)
);orders/product_copy.sql
CREATE TABLE product_copy (
product_id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL,
version BIGINT NOT NULL
);orders-copy/src/main.rs
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::{postgres::PgPoolOptions, PgPool};
#[derive(Deserialize)]
struct ProductChanged {
product_id: i64,
name: String,
price_cents: i32,
version: i64,
}
#[derive(Serialize, sqlx::FromRow)]
struct ProductCopy {
product_id: i64,
name: String,
price_cents: i32,
version: i64,
}
async fn apply_change(
State(pool): State<PgPool>,
Json(event): Json<ProductChanged>,
) -> Result<StatusCode, StatusCode> {
sqlx::query(
"INSERT INTO product_copy (product_id, name, price_cents, version)
VALUES ($1, $2, $3, $4)
ON CONFLICT (product_id) DO UPDATE
SET name = EXCLUDED.name,
price_cents = EXCLUDED.price_cents,
version = EXCLUDED.version
WHERE product_copy.version < EXCLUDED.version",
)
.bind(event.product_id)
.bind(&event.name)
.bind(event.price_cents)
.bind(event.version)
.execute(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
async fn get_copy(
State(pool): State<PgPool>,
Path(id): Path<i64>,
) -> Result<Json<ProductCopy>, StatusCode> {
let copy = sqlx::query_as::<_, ProductCopy>(
"SELECT product_id, name, price_cents, version FROM product_copy WHERE product_id = $1",
)
.bind(id)
.fetch_optional(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
copy.map(Json).ok_or(StatusCode::NOT_FOUND)
}
#[tokio::main]
async fn main() {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new().max_connections(5).connect(&url).await.unwrap();
let app = Router::new()
.route("/events/product-changed", post(apply_change))
.route("/product-copy/{id}", get(get_copy))
.with_state(pool);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3003").await.unwrap();
axum::serve(listener, app).await.unwrap();
}strangler/src/main.rs
use axum::{
body::Bytes,
extract::State,
http::{header::CONTENT_TYPE, HeaderMap, Method, StatusCode, Uri},
response::{IntoResponse, Response},
Router,
};
#[derive(Clone)]
struct Proxy {
client: reqwest::Client,
catalog: String,
legacy: String,
}
async fn forward(
State(proxy): State<Proxy>,
method: Method,
uri: Uri,
headers: HeaderMap,
body: Bytes,
) -> Response {
let base = if uri.path().starts_with("/products") {
&proxy.catalog
} else {
&proxy.legacy
};
let path = uri.path_and_query().map(|p| p.as_str()).unwrap_or("/");
let url = format!("{base}{path}");
let mut request = proxy.client.request(method, url).body(body);
if let Some(content_type) = headers.get(CONTENT_TYPE) {
request = request.header(CONTENT_TYPE, content_type);
}
let Ok(upstream) = request.send().await else {
return StatusCode::BAD_GATEWAY.into_response();
};
let status = upstream.status();
let content_type = upstream.headers().get(CONTENT_TYPE).cloned();
let Ok(bytes) = upstream.bytes().await else {
return StatusCode::BAD_GATEWAY.into_response();
};
let mut response = (status, bytes).into_response();
if let Some(content_type) = content_type {
response.headers_mut().insert(CONTENT_TYPE, content_type);
}
response
}
#[tokio::main]
async fn main() {
let proxy = Proxy {
client: reqwest::Client::new(),
catalog: std::env::var("CATALOG_URL").unwrap_or("http://127.0.0.1:3001".to_owned()),
legacy: std::env::var("LEGACY_URL").unwrap_or("http://127.0.0.1:3002".to_owned()),
};
let app = Router::new().fallback(forward).with_state(proxy);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Run the databases and the copy consumer
docker run --name storefront-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 \
-v "$PWD/db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro" -d postgres:16
# wait a few seconds until the database accepts connections, then:
psql postgres://catalog_svc:catalog@127.0.0.1:5432/catalog -f catalog/schema.sql
psql postgres://inventory_svc:inventory@127.0.0.1:5432/inventory -f inventory/schema.sql
psql postgres://orders_svc:orders@127.0.0.1:5432/orders -f orders/schema.sql
psql postgres://orders_svc:orders@127.0.0.1:5432/orders -f orders/product_copy.sql
DATABASE_URL=postgres://orders_svc:orders@127.0.0.1:5432/orders cargo run -p orders-copyThe consumer listens on port 3003. The router uses port 3000, so both can run at the same time (Example 4 lists the router requests).
Try the copy consumer
Send version 2 of a product change:
curl -i -X POST localhost:3003/events/product-changed \
-H 'content-type: application/json' \
-d '{"product_id":7,"name":"Notebook","price_cents":450,"version":2}'HTTP/1.1 204 No Content
date: <current date and time>Send an older change, version 1, that arrives late:
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:3003/events/product-changed \
-H 'content-type: application/json' \
-d '{"product_id":7,"name":"Old Notebook","price_cents":400,"version":1}'204Read the copy. The late change did not overwrite it:
curl -s localhost:3003/product-copy/7{"product_id":7,"name":"Notebook","price_cents":450,"version":2}An unknown product returns 404:
curl -s -o /dev/null -w '%{http_code}\n' localhost:3003/product-copy/8404Leave out the version field to see the missing-field error:
curl -s -X POST localhost:3003/events/product-changed \
-H 'content-type: application/json' \
-d '{"product_id":7,"name":"Notebook","price_cents":450}'Failed to deserialize the JSON body into the target type: missing field `version` at line 1 column 52Use the wrong method on the events path (GET instead of POST):
curl -s -o /dev/null -w '%{http_code}\n' localhost:3003/events/product-changed405Summary
- A business area gives a word one meaning. "Product" is a description and price in Catalog, a stock count in Inventory, and a purchase snapshot in Orders.
- Each fact has one owner. Other services hold copies or snapshots.
- Only the owning service connects to its database. Other services use its API, which the owner keeps stable.
- A foreign key cannot point into another service's database. Services link records by id only.
- A shared database ties services together: a column rename in one breaks another at run time.
- A service gets another service's data by calling its API on each request, or by keeping a small copy that the owner updates.
- A copy update that compares a version number is safe when messages repeat or arrive out of order. A copy can be out of date for a short time and needs a first load and a rebuild path.
- A router in front of a monolith moves one feature at a time to a new service, with one writer per fact.
- One service is the default when one team owns the code, boundaries are unstable, or actions need one transaction.
