In Part 1 we built a small Axum service that fronted an existing project’s Postgres database. The example was deliberately simple: SELECT id, customer_id, total_cents, status FROM orders. Reality is not that simple.
When the database is owned by a Rails monolith, ActiveRecord has been quietly maintaining a lot of invariants for you: counter caches, touched timestamps, enum mappings, soft deletes, polymorphic types, STI discriminators. The moment you bypass ActiveRecord and write directly to the database, those invariants are your problem. This post is about how to take them seriously.
We’ll go in the same three passes: beginner (what Rails is actually doing behind the curtain), intermediate (reading safely), advanced (writing safely, and a cutover pattern that won’t burn you). Every example shows Rust code and the Rails behavior it has to respect.
A note on scope: I’m assuming Rails 7.x and ActiveRecord, but every pattern here transfers to Django ORM, Laravel Eloquent, or Sequelize. The framework names change; the hidden state does not.
Beginner: What ActiveRecord is doing behind your back
Before you write a single line of Rust against this database, you need to know what ActiveRecord has been doing every time the monolith wrote to it. Six patterns cover most cases.
1. Timestamps
Every Rails model has created_at and updated_at. ActiveRecord sets them automatically:
# Rails
order = Order.create!(customer_id: c.id, total_cents: 1500, status: "pending")
# created_at and updated_at are set to the current UTC time, automatically.
If you INSERT from Rust without setting these, you get one of two outcomes:
- The column has a DB default (
DEFAULT now()), so you’re fine. - The column has no default, so you get a
NOT NULLviolation, or worse, a NULL row that breaks every Rails reader downstream.
Always check the schema, not the Rails model. The model lies; the schema doesn’t.
psql $DATABASE_URL -c "\d orders"
2. Counter caches
class Order < ApplicationRecord
belongs_to :customer, counter_cache: true
end
class Customer < ApplicationRecord
has_many :orders
end
That counter_cache: true means every time an order is created, Rails runs UPDATE customers SET orders_count = orders_count + 1 WHERE id = ?. Every time one is destroyed, it decrements. The column customers.orders_count exists only because of this.
If your Rust service inserts an order directly, orders_count is now wrong. Every Rails view that reads @customer.orders_count shows a stale number. You will not get an error; you will get a quiet, growing skew.
3. touch
class Order < ApplicationRecord
belongs_to :customer, touch: true
end
Every time an order is saved, Rails runs UPDATE customers SET updated_at = now() WHERE id = ?. This is what makes Rails caching (cache key: customer) invalidate correctly. Bypass it from Rust and your Rails fragment caches go stale.
4. Enums
class Order < ApplicationRecord
enum status: { pending: 0, paid: 1, shipped: 2, cancelled: 3 }
end
In the database, orders.status is an integer. In Ruby, order.status returns the string "pending". ActiveRecord does the mapping.
A Rust service that does WHERE status = 'pending' against an integer column gets zero rows. Worse, a service that writes status = 'pending' to that column gets a type error. Or, if the column is text, it writes a value that Rails will never match.
Check the actual column type and the actual stored values:
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'orders' AND column_name = 'status';
SELECT DISTINCT status FROM orders;
5. Soft deletes (paranoid / acts_as_paranoid / discard)
class Order < ApplicationRecord
acts_as_paranoid # or: include Discard::Model
end
ActiveRecord adds an implicit WHERE deleted_at IS NULL (or WHERE discarded_at IS NULL) to every query. A naive Rust SELECT * FROM orders returns deleted rows that no Rails screen will ever show.
This is the most common foot-gun in a Rust/Rails coexistence. Every read query from Rust must replicate that filter.
6. Polymorphic associations and STI
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
end
The comments table has commentable_type (string, e.g. "Order") and commentable_id (integer or uuid). Rails uses both to do the lookup. Forget the type column from Rust and you’ll match comments on the wrong model entirely.
STI (Single Table Inheritance) is similar:
class Notification < ApplicationRecord
end
class EmailNotification < Notification
end
class SmsNotification < Notification
end
All three live in notifications, distinguished by a type column with values "Notification", "EmailNotification", "SmsNotification". Rust must filter on type or model the table as a tagged union.
Intermediate: Reading the database safely
OK, now we know what to respect. Let’s build a Rust reader that doesn’t lie about any of it.
Step 1: Model the Rails enum in Rust
Rust enums are not strings, but sqlx is happy to map either direction. Let’s start with the int-backed Rails enum:
use serde::{Deserialize, Serialize};
// `#[repr(i32)]` makes this enum's discriminant a 32-bit integer at the
// memory level. That matters because Postgres' `integer` is i32.
// (Use `#[repr(i64)]` if your column is `bigint`.)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] // serialize as "pending", not "Pending"
#[repr(i32)]
pub enum OrderStatus {
Pending = 0,
Paid = 1,
Shipped = 2,
Cancelled = 3,
}
// sqlx needs to know how to (a) read an int from a row into this enum,
// and (b) bind this enum as a parameter to a query. We implement both.
impl sqlx::Type<sqlx::Postgres> for OrderStatus {
fn type_info() -> sqlx::postgres::PgTypeInfo {
<i32 as sqlx::Type<sqlx::Postgres>>::type_info()
}
}
impl<'r> sqlx::Decode<'r, sqlx::Postgres> for OrderStatus {
fn decode(
value: sqlx::postgres::PgValueRef<'r>,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let n = <i32 as sqlx::Decode<sqlx::Postgres>>::decode(value)?;
match n {
0 => Ok(Self::Pending),
1 => Ok(Self::Paid),
2 => Ok(Self::Shipped),
3 => Ok(Self::Cancelled),
other => Err(format!("unknown OrderStatus discriminant: {other}").into()),
}
}
}
impl<'q> sqlx::Encode<'q, sqlx::Postgres> for OrderStatus {
fn encode_by_ref(
&self,
buf: &mut sqlx::postgres::PgArgumentBuffer,
) -> sqlx::encode::IsNull {
<i32 as sqlx::Encode<sqlx::Postgres>>::encode_by_ref(&(*self as i32), buf)
}
}
That looks like a lot of boilerplate, but it’s worth understanding what it buys:
Typetells sqlx which Postgres type this maps to (integer).Decodetells sqlx how to read a row’s value into the enum. The crucial bit is thematchwith another =>arm: if Rails ever adds a4 => :refundedand you forget to update Rust, you get a clean error instead of silent corruption.Encodetells sqlx how to write the enum back. We cast*self as i32because of#[repr(i32)].
For string-backed enums (enum status: { pending: "pending", ... }), the pattern is the same but you match on strings.
Step 2: A model that respects soft deletes
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Order {
pub id: Uuid,
pub customer_id: Uuid,
pub total_cents: i64,
pub status: OrderStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
// Notice: `deleted_at` is NOT in this struct. It's a filter, not a field.
// If we needed it, we'd have an `OrderWithDeletion` variant.
}
The query module that wraps it:
// src/db/orders.rs
use sqlx::PgPool;
use uuid::Uuid;
use super::models::Order;
use crate::error::AppError;
/// Find an order by ID. Respects the Rails soft-delete contract.
pub async fn find(pool: &PgPool, id: Uuid) -> Result<Order, AppError> {
let order = sqlx::query_as::<_, Order>(
"SELECT id, customer_id, total_cents, status, created_at, updated_at
FROM orders
WHERE id = $1
AND deleted_at IS NULL", // <-- THE rule from `acts_as_paranoid`
)
.bind(id)
.fetch_one(pool)
.await?;
Ok(order)
}
pub async fn list_for_customer(
pool: &PgPool,
customer_id: Uuid,
limit: i64,
) -> Result<Vec<Order>, AppError> {
sqlx::query_as::<_, Order>(
"SELECT id, customer_id, total_cents, status, created_at, updated_at
FROM orders
WHERE customer_id = $1
AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT $2",
)
.bind(customer_id)
.bind(limit)
.fetch_all(pool)
.await
.map_err(Into::into)
}
The deleted_at IS NULL clause is duplicated. That’s a smell. Let’s fix it with a centralized base query so no future contributor forgets:
const ORDERS_BASE: &str = r#"
SELECT id, customer_id, total_cents, status, created_at, updated_at
FROM orders
WHERE deleted_at IS NULL
"#;
pub async fn find(pool: &PgPool, id: Uuid) -> Result<Order, AppError> {
let sql = format!("{ORDERS_BASE} AND id = $1");
sqlx::query_as::<_, Order>(&sql)
.bind(id)
.fetch_one(pool)
.await
.map_err(Into::into)
}
A safer alternative is a Postgres view that already filters out soft-deleted rows:
CREATE VIEW visible_orders AS
SELECT * FROM orders WHERE deleted_at IS NULL;
Then your Rust queries select from visible_orders and the filter is impossible to forget. The trade-off: a view is a schema object, and we said “Rust owns no migrations.” If you can get the Rails team to add the view, it’s a strict upgrade.
Step 3: Polymorphic associations
The comments table from earlier:
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
commentable_type VARCHAR NOT NULL,
commentable_id BIGINT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
A Rust query for “all comments on an order” must pin both columns:
pub async fn comments_on_order(
pool: &PgPool,
order_id: i64,
) -> Result<Vec<Comment>, AppError> {
sqlx::query_as::<_, Comment>(
"SELECT id, commentable_type, commentable_id, body, created_at, updated_at
FROM comments
WHERE commentable_type = $1 -- Rails class name, not table name!
AND commentable_id = $2
ORDER BY created_at ASC",
)
.bind("Order")
.bind(order_id)
.fetch_all(pool)
.await
.map_err(Into::into)
}
Three things to internalize:
commentable_typeis the Rails class name ("Order"), not the table name ("orders"). They look similar; they are not the same.- If the Rails app uses STI, the type might be
"PremiumOrder"for a subclass. You may need to match a set of types. - A typo here returns zero rows silently, with no SQL error. Add an integration test (Part 1 covered
sqlx::test) so a rename in Rails breaks your CI, not your prod.
Step 4: The “shadow read” pattern
You’re about to put a Rust endpoint in front of real users. You’re 90% sure your queries match Rails exactly, but “90% sure” is how outages happen. The shadow-read pattern lets you verify before you cut over.
The setup:
- Keep the existing Rails endpoint serving 100% of traffic.
- Have Rails also call the new Rust endpoint asynchronously for every request.
- Compare the responses. Log mismatches with full detail.
- After a week of zero (or explainable) mismatches, flip the traffic.
In Rails:
# app/controllers/orders_controller.rb
def show
@order = Order.find(params[:id])
ShadowReadJob.perform_later(
endpoint: "GET /orders/#{params[:id]}",
expected: render_to_string(json: @order)
)
render json: @order
end
# app/jobs/shadow_read_job.rb
class ShadowReadJob < ApplicationJob
def perform(endpoint:, expected:)
actual = HTTP.timeout(2).get("http://rust-service.internal#{endpoint.split.last}").to_s
return if normalize(actual) == normalize(expected)
Rails.logger.warn(
shadow_mismatch: {
endpoint: endpoint,
expected: expected,
actual: actual,
diff: diff(expected, actual),
}
)
end
private
def normalize(s)
# strip timestamps that differ by microseconds, normalize JSON key order, etc.
JSON.parse(s).sort.to_h
rescue
s
end
end
The Rust side is unchanged: it’s just serving its endpoint. The discipline lives in Rails.
You will find mismatches you did not expect. A decimal column being read as f64 and losing precision. A created_at that’s timestamp without time zone in the DB but TIMESTAMPTZ in your Rust struct. An enum value Rails added two weeks ago. This phase is supposed to be uncomfortable: better in shadow than in prod.
Advanced: Writing safely
Reads are forgiving. Writes are not. Once your Rust service starts issuing INSERT or UPDATE, every ActiveRecord callback that Rails was silently running for you is now your responsibility.
There are three sustainable patterns. Pick one per write path; don’t mix.
Pattern A: Don’t write, emit an event
The simplest, safest, and most common pattern: Rust never writes to the Rails-owned table. It writes to a message queue (or a pending_orders outbox table), and a Rails worker picks it up and does the actual create! through ActiveRecord.
// src/handlers/orders.rs
async fn create_order(
State(state): State<AppState>,
Json(payload): Json<CreateOrder>,
) -> Result<(StatusCode, Json<PendingOrder>), AppError> {
// Write to an *outbox* table that Rails owns no callbacks on.
let pending = sqlx::query_as::<_, PendingOrder>(
"INSERT INTO order_intake_outbox (customer_id, total_cents, status, payload)
VALUES ($1, $2, 'queued', $3)
RETURNING id, customer_id, total_cents, status, payload, created_at",
)
.bind(payload.customer_id)
.bind(payload.total_cents)
.bind(serde_json::to_value(&payload)?)
.fetch_one(&state.write)
.await?;
// 202 Accepted: "I took your request; the result will appear soon."
Ok((StatusCode::ACCEPTED, Json(pending)))
}
On the Rails side, a Sidekiq worker drains the outbox using real Order.create!, which runs every callback, validation, counter cache, and touch correctly:
class OrderIntakeWorker
include Sidekiq::Worker
def perform
OrderIntakeOutbox.where(status: "queued").find_each do |intake|
ActiveRecord::Base.transaction do
Order.create!(
customer_id: intake.customer_id,
total_cents: intake.total_cents,
)
intake.update!(status: "processed")
end
rescue => e
intake.update!(status: "failed", error: e.message)
end
end
end
Trade-off: writes are now eventually consistent. The client gets a 202 and has to poll or wait for a webhook. For most “carve out one endpoint” use cases (analytics ingestion, telemetry, fire-and-forget commands), this is exactly the right shape.
Pattern B: Write through, replicating every callback
If the endpoint truly needs synchronous writes (the client must get the created object’s ID back, immediately), you write directly to the table and reproduce every ActiveRecord callback in Rust.
async fn create_order(
State(state): State<AppState>,
Json(payload): Json<CreateOrder>,
) -> Result<Json<Order>, AppError> {
// One transaction so everything commits or nothing does.
let mut tx = state.write.begin().await?;
let now = Utc::now();
// 1. Insert the order. Set BOTH timestamps explicitly because the
// Rails column has no DB-level default (Rails sets it in Ruby).
let order = sqlx::query_as::<_, Order>(
"INSERT INTO orders
(customer_id, total_cents, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $4)
RETURNING id, customer_id, total_cents, status, created_at, updated_at",
)
.bind(payload.customer_id)
.bind(payload.total_cents)
.bind(OrderStatus::Pending)
.bind(now)
.fetch_one(&mut *tx)
.await?;
// 2. Increment the counter cache. This is what `counter_cache: true`
// does in Rails' after_create_commit.
sqlx::query(
"UPDATE customers
SET orders_count = orders_count + 1
WHERE id = $1",
)
.bind(payload.customer_id)
.execute(&mut *tx)
.await?;
// 3. Touch the customer. This is what `touch: true` does.
sqlx::query(
"UPDATE customers
SET updated_at = $1
WHERE id = $2",
)
.bind(now)
.bind(payload.customer_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
// 4. After-commit side effects: enqueue the same jobs Rails would have.
// These go on the same Sidekiq queue, by name. (More on that below.)
state
.jobs
.enqueue("OrderConfirmationMailer", "confirm", &[order.id.to_string()])
.await?;
Ok(Json(order))
}
This works, but the maintenance cost is real. Every time someone on the Rails team adds a callback, the Rust service silently diverges. Two mitigations:
Mitigation 1: Push the invariants into the database itself.
-- Make the counter cache the database's job, not Rails' or Rust's.
CREATE OR REPLACE FUNCTION update_orders_count() RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' AND NEW.deleted_at IS NULL THEN
UPDATE customers SET orders_count = orders_count + 1 WHERE id = NEW.customer_id;
ELSIF TG_OP = 'UPDATE' AND OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN
UPDATE customers SET orders_count = orders_count - 1 WHERE id = NEW.customer_id;
ELSIF TG_OP = 'UPDATE' AND OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL THEN
UPDATE customers SET orders_count = orders_count + 1 WHERE id = NEW.customer_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_orders_counter
AFTER INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION update_orders_count();
If Rails also has counter_cache: true, you’ll double-count. You’d remove the Rails-side cache (set counter_cache: false and rely entirely on the trigger). The Rails team has to agree to this; it’s a real schema change. But once done, neither Rust nor Rails can corrupt the counter: it’s enforced at the database, the only place both services share.
Mitigation 2: Sidekiq compatibility from Rust.
Sidekiq stores jobs as JSON blobs in Redis with a specific structure:
// src/jobs.rs
use redis::AsyncCommands;
use serde::Serialize;
#[derive(Serialize)]
struct SidekiqJob<'a> {
class: &'a str,
args: Vec<serde_json::Value>,
retry: bool,
queue: &'a str,
jid: String,
created_at: f64,
enqueued_at: f64,
}
pub struct JobClient {
redis: redis::Client,
}
impl JobClient {
pub async fn enqueue(
&self,
class: &str,
_method: &str,
args: &[String],
) -> Result<(), AppError> {
let mut conn = self.redis.get_async_connection().await?;
let now = chrono::Utc::now().timestamp_millis() as f64 / 1000.0;
let job = SidekiqJob {
class,
args: args.iter().map(|s| serde_json::Value::String(s.clone())).collect(),
retry: true,
queue: "default",
jid: uuid::Uuid::new_v4().simple().to_string(),
created_at: now,
enqueued_at: now,
};
let payload = serde_json::to_string(&job)?;
let _: () = conn.lpush("queue:default", payload).await?;
let _: () = conn.sadd("queues", "default").await?;
Ok(())
}
}
Now the Rust service can enqueue work into the same Sidekiq Redis that Rails reads from, and Rails workers process it normally. The Rust side doesn’t need to understand the job: it just needs to put the right JSON shape on the right Redis list. (sidekiq.rs is a crate that does this for you; the manual version is shown to demystify it.)
Pattern C: Rust owns its own table
The cleanest of all: Rust writes to a table that Rails only reads from. No callbacks fire because Rails isn’t writing. Rails reads via a model that you mark readonly or behind an unscoped query you don’t care about:
class OrderEvent < ApplicationRecord
self.table_name = "rust_order_events"
def readonly?; true; end
end
This is the right pattern when the Rust service is adding new data: event logs, analytics aggregates, derived projections. It’s the wrong pattern when the Rust service is taking over an existing write path.
Choosing between patterns
| Write path | Pattern |
|---|---|
| Hot read endpoint, no writes from Rust | (just reads, nothing to do) |
| New analytics / event ingestion | C: Rust owns its own table |
| Existing write path, async OK | A: outbox + Rails worker |
| Existing write path, sync required | B: write through + triggers |
Most teams I’ve worked with end up with a mix: B for one or two critical endpoints, A for everything else, C for whatever new data the Rust service is producing.
A real cutover plan
You’ve built it, tested it, shadow-read it for a week. Time to ship.
Phase 1: 0% traffic, week 1. The Rust endpoint is deployed but no traffic flows through it. Rails calls it in shadow mode (Pattern A above). Mismatches are logged. Fix every one.
Phase 2: 1% traffic, 48 hours. Use a feature flag in Rails:
def show
@order = Order.find(params[:id])
if Flipper.enabled?(:rust_orders_show, current_user)
redirect_to "http://rust-service.internal/orders/#{params[:id]}"
else
render json: @order
end
end
Watch your monitoring. If anything looks weird (latency, error rate, the shape of the response in client logs), flip back instantly.
Phase 3: 50% traffic, 24 hours. Same flag, percentage rollout. By now you’ve burned in every code path that 1% missed.
Phase 4: 100%. The Rails action becomes a thin redirect. Leave it that way for a week: easy rollback if something breaks.
Phase 5: Delete the Rails code. Only after you’re sure. Keep the Rails route returning a 301 forever; clients hard-coded the old URL.
The whole thing takes 2–3 weeks of clock time for a single endpoint, of which maybe 3 days is active work. That’s the cost of doing it without an outage, and it’s worth every hour.
What you should test before you ship
A short checklist, drawn from things I’ve actually seen go wrong:
- Soft-delete filter. Pick a soft-deleted row in staging. Confirm your Rust endpoint does not return it. Confirm your Rust endpoint does not let it be updated.
- Enum values. Iterate every value of every Rails enum your endpoint touches. Confirm the Rust enum has the same discriminant and the same name.
- Timezone columns.
SELECT data_type FROM information_schema.columns WHERE column_name = 'created_at'. If it’stimestamp without time zone, yourDateTime<Utc>will silently get the wrong offset. UseNaiveDateTimeor change the column. - Counter caches. Insert a row from Rust. Read
customers.orders_countfrom the Rails console. If it didn’t move, Pattern B is incomplete. updated_atpropagation. Update a child row from Rust. Confirm the parent’supdated_atmoved iftouch: truewas set.- Polymorphic types. Grep the Rails codebase for
polymorphic: true. For every such association, your Rust queries that touch the table must pin both columns. - STI types. Same exercise for
inheritance_columnortype. - Encrypted columns. Rails 7 column-level encryption is transparent in Ruby but the database stores ciphertext. Reading from Rust gets you
\x...bytes, not the plaintext. You’d need the same key and AES-GCM implementation. Or, more sanely, don’t read encrypted columns from Rust.
Where we’re going next
Part 3 in this series will be streaming responses with Axum: Server-Sent Events, StreamBody, backpressure, and the patterns that let one Rust service push live data to thousands of connected clients without breaking a sweat. It’s the kind of workload that’s painful in Rails and natural in Tokio, which makes it a strong second endpoint to migrate after the read-heavy one you just shipped.
If you take one thing from this post: the database is not the source of truth; the database plus ActiveRecord’s behavior is the source of truth. Until your Rust service respects both, you’re not coexisting with the monolith; you’re racing it.