Modular crate architecture with Axum, async PostgreSQL with SQLx, type-level multi-tenancy, and compile-time guarantees. What we learned moving from TypeScript to Rust.
The Case for Rewriting
The TypeScript backend worked. It was fast enough, type-safe enough, productive enough. Rewriting in Rust wasn't about ideology—it was about specific production requirements the existing system couldn't meet.
Garbage collection pauses spiked P99 latencies unpredictably. Memory usage under load climbed to multiple gigabytes. Concurrency bugs appeared in production despite careful code review. These weren't theoretical concerns; they were affecting users.
Rust eliminates garbage collection entirely. Its ownership model catches data races at compile time. Memory usage is predictable and typically an order of magnitude lower than the equivalent Node.js process.
Crate Architecture
Rust's module system encourages separation. We split the backend into focused crates: API handlers, business logic, database operations, authentication, payment processing, event systems, and shared types.
Each crate has a single responsibility. The API crate doesn't know about SQL. The database crate doesn't know about HTTP. This separation isn't just organizational aesthetics—it's enforced by the compiler. If you try to import database code in the API layer without going through the proper interface, the build fails.
Type-Level Multi-Tenancy
Every database query must include a tenant ID. Forgetting this filter would leak data between customers—an unacceptable bug.
We encode this requirement in the type system. Service methods take a tenant context parameter. Without it, the code doesn't compile. The type system transforms a runtime bug into a compile-time error.
This is one of Rust's superpowers: making invalid states unrepresentable. If your types are designed well, entire categories of bugs become impossible.
Newtype IDs
User IDs and product IDs are both strings. Mixing them up is easy and dangerous. Rust's newtype pattern wraps each ID in a distinct type. Passing a user ID where a product ID is expected fails compilation.
This seems like overkill until you've debugged a production issue caused by ID confusion. Then it seems obvious.
Compile-Time SQL
SQL queries can fail at runtime: syntax errors, type mismatches, missing columns after schema changes. With the right tooling, these become compile-time errors.
The SQL itself is type-checked against the actual database schema. If you rename a column, every query referencing it fails to compile. If you return a number where a string is expected, the compiler tells you.
This shifts debugging from production to development. You catch errors before deployment, not in incident response.
Error Handling
Rust forces explicit error handling. Every fallible operation returns a Result type that must be addressed. You can't accidentally ignore errors—the compiler won't let you.
This creates robust code by default. Error paths are thought through because you have to think through them. The type system is relentless about incomplete error handling.
The ergonomics have improved dramatically. The question mark operator propagates errors concisely. Pattern matching handles different error types cleanly. What felt verbose in early Rust now feels natural.
Performance Characteristics
The benchmarks tell the story. P50 latency dropped sixfold. P99—the tail latency that determines user experience under load—dropped tenfold. Memory usage dropped to a fraction of the original.
But the most important number is P99 stability. Garbage collection pauses are gone. The latency distribution has a predictable shape. Users experience consistent performance, not occasional mysterious slowdowns.
The Migration Path
We didn't rewrite everything at once. New features were written in Rust. Performance-critical paths migrated first. Both systems ran in parallel during transition, with traffic gradually shifting via feature flags.
This incremental approach reduced risk. If something went wrong, we could route traffic back to the TypeScript backend. The Rust rewrite proved itself in production before becoming the sole system.
Tradeoffs
Rust isn't universally better. Development velocity can be slower, especially initially. The learning curve is real. Hire engineers who want to learn Rust—don't force it on a reluctant team.
The compilation step adds friction compared to interpreted languages. Fast iteration during development requires tuning the build configuration.
And some problems don't need Rust's guarantees. If your backend is I/O bound and latency isn't critical, the complexity overhead may not be worth it.
When It's Worth It
Rust makes sense for performance-critical backends handling real money or sensitive data. The correctness guarantees justify the complexity. The latency consistency matters to users.
For us, the rewrite was justified by production requirements. The guarantees that seemed theoretical—memory safety, data race freedom, compile-time correctness—became concrete benefits measured in incident counts and latency percentiles.
The code doesn't just work. It works reliably, predictably, and efficiently. That's the Rust value proposition.