Incremental Adoption & Refactoring
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Rust 1.99.0 and the 2024 edition, checked on 2026-10-06. Few teams start a project in Rust from scratch; more often Rust arrives in an existing system to fix a specific problem — a slow parser in a Python service, a memory-safety bug class in a C library, a CPU-heavy endpoint. Refactoring to Rust by Mara & Holmes is devoted to this situation; this page summarises the strategies, with the official documentation as the reference for each technique.
When Rust is worth it — and when it is not
Good reasons to introduce Rust:
-
performance: CPU-bound hot spots, predictable latency without garbage-collection pauses, low memory use;
-
safety: replacing C or C++ code that handles untrusted input (parsers, decoders, network protocols), where memory-safety bugs become vulnerabilities;
-
maintainability: a strong type system and compiler checks for code that is hard to get right (concurrency, state machines), and a single toolchain for building, testing and documenting.
Reasons to hesitate:
-
the bottleneck is I/O, the database, or the algorithm — a faster language will not help;
-
the code is small, stable and rarely changed;
-
nobody on the team can review Rust, and there is no time to learn it;
-
the platform has no Rust support or certification for it (some embedded and regulated environments).
Measure first. The cost of a second language in the build, the CI pipeline and the team’s heads is real and permanent.
Two strategies
| In-process (FFI, PyO3, WebAssembly) | Out-of-process (a network service) | |
|---|---|---|
How |
compile Rust into a library loaded by the host program |
run Rust as a separate process; talk over HTTP, gRPC or a queue |
Call overhead |
nanoseconds |
microseconds to milliseconds |
Granularity |
a function or a module |
a whole capability |
Failure isolation |
a crash takes the host down |
the service can fail and restart independently |
Build and deploy |
the host’s build must compile or bundle the Rust library |
separate build, image and deployment |
Data exchange |
shared memory, careful ownership rules |
serialization (JSON, protobuf) |
Typical tools |
cbindgen/bindgen, PyO3 + maturin, wasm-bindgen |
axum, tonic, tokio |
needs nanosecond calls?"} B -- yes --> C{"Host language"} C -- "C or C++" --> FFI["Rust library with a C ABI:
cdylib/staticlib + cbindgen"] C -- Python --> PYO3["PyO3 extension module
built with maturin"] C -- "JavaScript / browser" --> WASM["WebAssembly with wasm-bindgen"] B -- no --> D{"Can it be a separate
deployable capability?"} D -- yes --> SVC["Rust network service:
axum for HTTP or tonic for gRPC"] D -- no --> C
The in-process techniques are covered on FFI & C Interop, Python Interop with PyO3 and WebAssembly; building services on the Axum section of this site. Mara & Holmes (chapters 1-2) frame the choice the same way: FFI for tight integration, a network service when the boundary is already a process boundary.
The strangler-fig migration
Rewriting a whole system at once is risky: the old system keeps changing while the new one is built, and the switch-over is a single, large, untested event. The strangler fig approach instead routes one piece of functionality at a time to the new implementation, behind a stable interface, until nothing calls the old code:
-
Put a seam in front of the code to replace: a function signature (for FFI), a module boundary, or an HTTP route behind a proxy or API gateway.
-
Implement the Rust version behind the same interface.
-
Run both and compare (below), then switch traffic gradually — a feature flag, a percentage of requests, one customer segment.
-
Delete the old implementation once the new one has served all traffic for long enough.
For services, the gateway pattern makes step 4 a configuration change; the axum section’s HTTP Clients & Proxies page shows a proxy that forwards some routes to a legacy service.
Testing equivalence
The strongest evidence that a rewrite is correct is that it produces the same outputs as the old implementation on the same inputs. Two complementary techniques:
-
Reuse the existing test suite. Build the Rust code into the host language (a PyO3 module, a C library) and run the host’s tests against it. Mara & Holmes (chapter 7) run a Python module’s pytest suite against its Rust replacement.
-
Differential testing. Feed generated inputs to both implementations and compare. Property-testing tools (proptest, quickcheck) generate and shrink inputs; in production, shadow traffic sends a copy of real requests to the new implementation and logs differences without affecting users.
/// The legacy behaviour we must preserve (ported verbatim, quirks included).
fn legacy_normalise(title: &str) -> String {
let mut out = String::new();
let mut last_space = true;
for c in title.trim().chars() {
if c.is_whitespace() {
if !last_space {
out.push(' ');
}
last_space = true;
} else {
out.extend(c.to_lowercase());
last_space = false;
}
}
out
}
/// The new, idiomatic implementation.
fn normalise(title: &str) -> String {
title.split_whitespace().map(str::to_lowercase).collect::<Vec<_>>().join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn new_matches_legacy(title in "\\PC{0,40}") { // any printable Unicode, up to 40 chars
prop_assert_eq!(normalise(&title), legacy_normalise(&title));
}
}
}
fn main() {
println!("{:?}", normalise(" The Left Hand of DARKNESS "));
}
Source: proptest documentation
When the test finds a difference, decide deliberately whether it is a bug in the new code or a quirk of the old one that callers depend on — and record that decision in a regular test.
Benchmark before and after
The reason for most migrations is performance, so measure it the same way before and after: the same inputs, the same machine, in release mode, with a statistically sound harness (criterion for functions, hyperfine for programs, a load generator for services). Include the cost of crossing the boundary — conversion of Python objects, serialization over HTTP — which can eat the gains if calls are too fine-grained. See Testing & Benchmarking and Performance & Profiling.
Onboarding the team
The official learning resources are good and free, which makes onboarding cheaper than for many languages:
-
The Rust Programming Language ("The Book") for the language, read in order;
-
Rustlings for small hands-on exercises alongside it, and Rust by Example for quick reference;
-
the Rust API Guidelines and Clippy to converge on idiomatic code;
-
rust-analyzer in every editor, and Clippy and rustfmt in CI from day one.
Start with a well-bounded, low-risk piece of code, pair experienced and new Rust developers on reviews, and expect the first weeks to be dominated by learning ownership and borrowing — this section’s Ownership & Moves and Borrowing & References pages are written for that phase.
Command-line tools are a popular first Rust project because they are self-contained; this site’s AI section covers Rust CLI frameworks (clap, Ratatui) in CLI Frameworks in Go & Rust, and a dedicated CLI Apps section is planned in issue #236.
Where the books are outdated
Mara & Holmes (Refactoring to Rust) present these strategies with code from the 2018/2021 editions and 2021-22 era crate
versions (bindgen 0.56, pyo3 0.14, wasm-bindgen 0.2.87, Yew 0.19, WasmEdge-specific forks). The strategies remain sound;
the code needs updating to the 2024 edition’s unsafe extern blocks and #[unsafe(no_mangle)], PyO3’s Bound API, the
wasm32-wasip1/wasip2 targets, and current crate versions, as shown on the linked pages.