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

flowchart TD A["Where is the code you want to replace?"] --> B{"Called in a tight loop,
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:

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

  2. Implement the Rust version behind the same interface.

  3. Run both and compare (below), then switch traffic gradually — a feature flag, a percentage of requests, one customer segment.

  4. 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 "));
}

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.