Guide contents

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)

TOML
[workspace]
members = ["orders-copy", "strangler"]
resolver = "2"

orders-copy/Cargo.toml (used in Example 3)

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 (used in Example 4)

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"] }
  • axum is the web framework. tokio runs async code. serde turns JSON text into Rust structs and back.
  • sqlx talks to PostgreSQL. reqwest sends 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

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

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

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 named products. Each line inside the brackets defines one column.
  • BIGSERIAL PRIMARY KEY on id gives 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.
  • TEXT is a string. INTEGER and BIGINT are whole numbers. TIMESTAMPTZ is a date and time with a time zone.
  • NOT NULL rejects 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.
  • version in products starts at 1. Catalog raises it each time the row changes. Example 3 uses it.
  • stock_items has 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_id appears in all three schemas. It is the only link between the services.
  • stock_items.product_id and order_lines.product_id have no REFERENCES. A foreign key cannot point into another service's database, and Example 2 shows why.
  • name_at_purchase and price_at_purchase_cents are 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:

SQL
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):

SQL
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:

Output
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

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_svc makes a separate database and gives the matching login control of it.
  • REVOKE CONNECT ON DATABASE ... FROM PUBLIC removes the default permission that lets any login connect. PUBLIC means every login. Without these lines, orders_svc could 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.

Terminal
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 for the server to start, then create each schema using that service's own login:

Terminal
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

The 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:

Terminal
psql postgres://orders_svc:orders@127.0.0.1:5432/catalog -c 'SELECT 1'
Output
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

SQL
CREATE TABLE product_copy (
    product_id   BIGINT PRIMARY KEY,
    name         TEXT NOT NULL,
    price_cents  INTEGER NOT NULL,
    version      BIGINT NOT NULL
);
Terminal
psql postgres://orders_svc:orders@127.0.0.1:5432/orders -f orders/product_copy.sql

The 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

Rust
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:

  • use lines bring names into scope. Router maps paths to handlers. A handler is an async function that receives a request and returns a response. Json reads or writes a JSON body. State gives a handler the shared value given to with_state. Path reads a value from the URL. StatusCode is the number at the top of an HTTP response (204 means "done, no body", 404 means "not found").
  • ProductChanged is 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.
  • ProductCopy is the shape of a stored row. Serialize turns it into JSON for the response. sqlx::FromRow lets sqlx fill it from a database row.
  • PgPool is 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 the DATABASE_URL environment variable. The program stops with a message if the variable is missing.
  • sqlx::query("...") builds a SQL statement. $1 to $4 are 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 UPDATE inserts a new row, or, if a row with that product_id exists, updates it. EXCLUDED names the row that was being inserted.
  • WHERE product_copy.version < EXCLUDED.version makes 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_optional returns None when no row matches. ok_or(StatusCode::NOT_FOUND) turns None into a 404.
  • Router::new().route(...) connects each path and method to a handler. {id} is a path value that Path<i64> reads.
  • TcpListener::bind("127.0.0.1:3003") opens port 3003 on this computer only. axum::serve starts 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:

  1. Put the router in front of the monolith. All requests pass to the monolith unchanged.
  2. Build the Catalog service with its own database. Load the product data into it.
  3. Send only GET /products... requests to Catalog. Compare its answers with the monolith's.
  4. 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

Rust
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:

  • Proxy holds 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::Client is cheap to clone.
  • Router::new().fallback(forward) sends every request to forward, whatever its path. Without fallback, axum would answer 404 for any path that has no .route.
  • method, uri, headers, and body are extractors: function arguments that axum fills from the request. Method is GET or POST. Uri is the path and query. HeaderMap holds the headers. Bytes is the raw body. The body argument must come last.
  • uri.path().starts_with("/products") is the routing rule. To move another feature, add another if branch.
  • path_and_query() keeps the whole path plus any ?a=b part. format!("{base}{path}") builds the target address, for example http://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-Type is 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-Type back.
  • 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:

Terminal
cargo run -p strangler

A product request goes to Catalog:

Terminal
curl -s localhost:3000/products/1
Output
{"id":1,"name":"Notebook","price_cents":450}

An orders request goes to the monolith address, where nothing is listening:

Terminal
curl -si localhost:3000/orders | head -n 1
Output
HTTP/1.1 502 Bad Gateway

When 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.

Text
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.rs

Cargo.toml (workspace root)

TOML
[workspace]
members = ["orders-copy", "strangler"]
resolver = "2"

orders-copy/Cargo.toml

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

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

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

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

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

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

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

Rust
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

Rust
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

Terminal
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-copy

The 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:

Terminal
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}'
Output
HTTP/1.1 204 No Content
date: <current date and time>

Send an older change, version 1, that arrives late:

Terminal
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}'
Output
204

Read the copy. The late change did not overwrite it:

Terminal
curl -s localhost:3003/product-copy/7
Output
{"product_id":7,"name":"Notebook","price_cents":450,"version":2}

An unknown product returns 404:

Terminal
curl -s -o /dev/null -w '%{http_code}\n' localhost:3003/product-copy/8
Output
404

Leave out the version field to see the missing-field error:

Terminal
curl -s -X POST localhost:3003/events/product-changed \
  -H 'content-type: application/json' \
  -d '{"product_id":7,"name":"Notebook","price_cents":450}'
Output
Failed to deserialize the JSON body into the target type: missing field `version` at line 1 column 52

Use the wrong method on the events path (GET instead of POST):

Terminal
curl -s -o /dev/null -w '%{http_code}\n' localhost:3003/events/product-changed
Output
405

Summary

  • 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.