Skip to content

The Rust Engineering Handbook / Chapter 97

Migration from C, C++, Go, Java, and Existing Systems

Introduce Rust through reversible seams, measured rollout gates, explicit ownership redesign, and enough review capacity to avoid a rewrite gamble.

Eighteen months into a rewrite, the replacement ingester is faster in its synthetic benchmark and still cannot ship. It reproduces most message transformations but not the undocumented retry behavior around a billing side effect. The old Java deployment remains the authority, the Rust team maintains a growing fork of the protocol model, and no one can say which traffic can move back in one command. Every week spent closing the last compatibility gap changes the system being copied.

The failure began before either implementation. “Rewrite the ingester in Rust” named a technology and a destination, but not a safe migration. It provided no boundary that could host old and new implementations simultaneously, no baseline for semantic equivalence, no rollback path, and no limit on how much organizational learning could be in flight.

A migration is a sequence of reversible changes to system authority. The unit of progress is not lines translated or modules compiled. It is a bounded responsibility moved behind a stable seam, operated with evidence, and recoverable without reconstructing the old system under pressure.

That model is intentionally language-neutral at its outer edge. Rust can make ownership and invalid-state contracts enforceable inside the new component. It cannot discover undocumented business behavior, create reviewers, repair a poor service boundary, or make a big-bang cutover safe.

Start with a suitability dossier

Suitability has three independent questions:

  1. Does the component’s problem reward Rust’s costs? Memory-corruption exposure, tail latency, resource ceilings, concurrency correctness, portability, or a need for a small native artifact can justify the learning and integration work. A stable CRUD service dominated by database latency may not.
  2. Can the responsibility be isolated? The team needs a seam with explicit inputs, outputs, side effects, failure behavior, compatibility, and ownership. A “module” that reaches through shared database tables and process globals is not isolated merely because it occupies one directory.
  3. Can the organization operate both the change and the system? Count people who can review ownership, unsafe code, build integration, performance evidence, and incidents. One enthusiastic Rust engineer is a prototype capacity, not a production ownership model.

Build the dossier from observed incidents, profiles, capacity limits, security findings, deployment topology, dependency constraints, and maintenance cost. Include the option of doing nothing and the option of repairing the existing implementation. If better bounds, profiling, or an interface cleanup in C++, Go, or Java solves the problem at lower total risk, that is a successful assessment.

Choose the first component risk-first, not glamour-first. “Risk-first” means high learning value with a bounded blast radius and honest operational importance. A parser exercised from recorded inputs, a compression worker behind a process protocol, or a new leaf capability can reveal build, deployment, observability, and review costs. The central transaction coordinator or an ABI-rich C++ object graph usually combines too many unknowns for the first move.

Choose the seam before the implementation

The seam determines which failures can remain local.

Migration option Best initial fit Isolation Main cost Rollback shape
Offline tool or build step Deterministic transformations, analysis, code generation Strong process and artifact boundary Startup, packaging, reproducibility Run the previous tool and restore the prior artifact
Sidecar or subprocess Parsers, normalizers, policy engines, compute workers Process crash, allocator, runtime, and often privilege isolation Serialization, copies, supervision, versioned protocol Route requests to the old process
C ABI library Existing C surface or callers that need in-process latency Weak fault isolation; explicit ABI boundary Pointer ownership, layout, unwinding, allocator, linking Relink or select the previous library version
C++ bridge or adapter Narrow typed access to a C++ estate In-process with additional bridge machinery templates, exceptions, object lifetime, build graph Keep old implementation behind the adapter
Network service Independently scalable responsibility with a durable protocol Strong deployment and failure boundary latency, partial failure, schema evolution, operations Shift traffic to the old service
In-place module replacement Already clean internal interface and one owning team Lowest isolation shared runtime and fast coupling growth feature flag or previous binary

A strangler migration preserves one stable entry while moving responsibilities behind it. It is not simply “put a proxy in front.” The boundary must make routing, comparison, authority, and fallback explicit. A useful sequence is observe, shadow, canary, expand, and retire. Retirement happens only after the rollback window and retained-data obligations close; it is not implied by reaching 100 percent traffic once.

A five-stage migration boundary. Baseline records the legacy behavior; shadow compares a non-authoritative Rust result; canary gives Rust authority for a bounded cohort; expansion increases that cohort through evidence gates; retirement removes the old path only after the rollback window closes. A versioned process seam and rollback route remain visible through the staged transition.
Migration progress is authority transferred through observable gates; translated code is not progress until the seam can compare, route, and reverse the change.

The diagram’s process seam is not universally superior to FFI. It is the right first boundary for the example because behavior uncertainty and rollback matter more than one serialization hop. If profiling later proves that the hop violates the budget, the team can narrow the protocol or move a proven kernel in-process. Starting at FFI to avoid a hypothetical copy would accept allocator, pointer, unwind, and crash coupling before behavior is known.

Walk one responsibility across the boundary

In this modeled migration, the legacy event-gateway performs decoding, normalization, policy lookup, deduplication, persistence, acknowledgement, and telemetry in one Java process. Its peak profile attributes 28 percent of CPU to normalization, but the component’s real suitability comes from shape: normalization is deterministic after policy version and clock-derived fields are supplied explicitly. Persistence and acknowledgement are not moved.

Define a versioned request containing encoded event bytes, tenant policy version, and an assigned ingestion timestamp. Define a response containing either a normalized event plus stable decision codes or a classified rejection. Put maximum message size, timeouts, cancellation, protocol compatibility, and resource limits in the contract. The legacy caller remains the authority over deduplication and effects.

At baseline, capture production distributions without copying secrets into an unmanaged corpus: sizes, event variants, policy versions, rejection classes, latency, CPU, and memory. Preserve approved redacted examples and property generators. Averages alone are insufficient; record tail latency, expansion, and failure-class frequencies.

During shadowing, the Java result remains authoritative. The Rust process receives the same pure input and emits a result to a bounded comparison sink. Comparison must understand semantics: field ordering may be irrelevant while normalization reason codes may be contractual. Shadow traffic needs admission control so evidence collection cannot overload the production path. Timeouts and crashes become metrics, never user-visible fallback delays.

Canary routing makes Rust authoritative for a controlled cohort while the old path stays selectable. Cohorts should expose relevant variation—message families, regions, policy versions—not only low-volume friendly traffic. Expand only when behavior, performance, rollback rehearsal, and human review capacity pass together.

The fixture expresses that gate as ordinary data:

pub fn can_expand_canary(evidence: MigrationEvidence) -> Result<(), GateFailure> {
    if evidence.compared_events < 100_000 {
        return Err(GateFailure::SampleTooSmall);
    }
    if evidence.semantic_mismatches != 0 {
        return Err(GateFailure::SemanticMismatch);
    }
    if evidence.rust_p99_micros > evidence.legacy_p99_micros {
        return Err(GateFailure::LatencyRegression);
    }
    if !evidence.rollback_rehearsed {
        return Err(GateFailure::RollbackUnproven);
    }
    if !evidence.two_reviewers_available {
        return Err(GateFailure::ReviewCapacityMissing);
    }
    Ok(())
}

The sample count and zero-mismatch rule are teaching values, not universal safety thresholds. A lossy image transcoder and a financial normalization path need different equivalence rules. The durable idea is that expansion consumes a reviewed evidence object, and the first unmet condition is visible.

Translate meaning, not class shapes

Language migration exposes design debt because source languages encode authority differently.

In C, establish provenance, allocation, length, mutability, thread access, and deallocation for every pointer crossing the seam. Do not translate a void * plus convention directly into a Rust reference. Raw pointers at FFI are an honest representation until validation can construct a narrower safe value. Pair allocations with the allocator that created them, define nullability, and prevent unwinding across an ABI that does not permit it.

C++ adds constructors, destructors, exceptions, templates, inheritance, and implementation-defined ABI concerns. Prefer a narrow C-compatible facade, generated bridge with explicitly supported types, opaque handles, or a process boundary. Do not mirror a large object graph. Ask which operations and invariants the caller actually needs. Object lifetime should be represented by owned handles and explicit destroy operations at the boundary, then wrapped safely inside Rust.

Go code often makes goroutine and channel lifecycles look local while the runtime, garbage collector, cgo rules, and cancellation conventions remain process concerns. Migrating a Go service to Rust by turning every goroutine into a task and every channel into the same channel misses the ownership question. Identify who closes, who drains, what is bounded, and what cancellation commits. A network or subprocess seam frequently preserves rollout better than embedding runtimes together.

Java estates carry garbage-collected identity, nullable references, exceptions, reflection, dynamic class loading, and framework lifecycle. Rust enums can replace some nullable and exception-shaped outcomes, but copying a mutable domain-object graph into nested Arc<Mutex<_>> preserves shared authority with more ceremony. Separate durable identifiers from live borrows, turn construction phases into validated types where useful, and keep framework-owned transaction or thread context at the adapter.

Across all four, ownership redesign begins with an authority ledger:

  • Who creates the value, buffer, handle, task, or side effect?
  • Which aliases exist, and which actor may mutate?
  • When is the value no longer usable?
  • Who releases or cancels it, including failure paths?
  • Which invariants are checked at the seam and which remain trusted?

Data-model translation should preserve external compatibility deliberately, not Rust memory layout accidentally. Use explicit wire schemas or #[repr(C)] only for the documented boundary. Ordinary Rust representation has no promise to match a foreign struct. Keep version fields and unknown-value behavior. Validate lengths before slicing, encodings before constructing strings, integer ranges before narrowing, and discriminants before mapping enums.

Integrate two build systems without creating two truths

The migration needs one reproducible route from source to deployable artifact. Decide whether Cargo invokes native compilation, the existing build invokes Cargo, or a higher-level orchestrator owns both. Avoid a cycle in which Cargo calls the legacy build, which calls Cargo again.

Cargo build scripts can compile or locate native libraries and emit linker instructions. They execute code during the build and need the same dependency, network, reproducibility, and cross-compilation review as other build tooling. Generated files belong in OUT_DIR; target facts come from Cargo-provided target environment rather than host cfg! assumptions. Pin toolchains and native compilers, declare supported host/target pairs, record linkage, and make clean-room and offline expectations explicit.

CI should build the mixed artifact from one revision identity. Store protocol fixtures and compatibility tests where both implementations consume them. Run Rust formatting, checking, tests, documentation tests, lints, and any applicable MSRV jobs alongside native or JVM tests. Cache acceleration is not reproducibility evidence; rehearse from an empty cache.

Rollout is an operating ledger

Each phase needs entry evidence, an authority decision, observed signals, an abort rule, a rollback action, and an owner. “Feature flag exists” is incomplete: verify that it is reachable during an incident, propagates within the required time, does not require the broken component, and restores state compatibility.

Dual implementation is useful when comparison is cheap and authority stays singular. It becomes dangerous when both paths perform effects or accumulate independent business rules. Keep one authoritative write path. For effects that must be duplicated, use idempotency keys and an explicit reconciliation model; do not infer safety from matching happy-path responses.

Performance comparisons use the same workload, machine class, build profile, warmup policy, measurement window, and output correctness. Include serialization and boundary cost, memory high-water marks, startup, CPU, tail latency, and failure behavior. Rust winning a microbenchmark does not prove the migrated system improved. Conversely, a small latency regression may be acceptable if memory ceilings, security exposure, or operability improve—if the proposal names that trade.

Rollback compatibility is a data question. If Rust writes a new schema, cache entry, checkpoint, or normalized representation that the old path cannot read, traffic switching is not rollback. Use additive formats, dual-read planning, or a forward repair. Practice rollback after representative state has been produced, measure recovery time, and keep the old path patched while it is still a control.

Budget the learning system

Training is delivery work. Give engineers focused ownership exercises, debugging practice, code-reading sessions, pairing on real but bounded changes, and explicit instruction on the system’s unsafe and operational contracts. A syntax course does not teach cancellation, FFI lifetime, API compatibility, or incident response.

Review capacity must grow before the Rust surface. Track how many people can author, domain-review, safety-review, deploy, and diagnose each component. Pair a Rust-experienced reviewer with the domain owner rather than letting either substitute for the other. Rotate reviews and incident participation. If every change queues behind one person, reduce migration concurrency; lowering review depth is not a capacity strategy.

Common rewrite failure modes are visible early:

  • scope expands because the legacy boundary is unpleasant;
  • undocumented behavior is discovered only at final cutover;
  • the new implementation improves internals while changing external semantics;
  • two implementations perform effects and drift;
  • the benchmark excludes the seam, copies, or production workload;
  • rollback exists in configuration but not in data compatibility;
  • the old path loses maintenance before retirement criteria pass;
  • specialists become a permanent queue;
  • the team celebrates translated code rather than transferred responsibility.

The repair is usually smaller scope, a stronger seam, a single authority, and explicit evidence—not a more heroic deadline.

Migration proposal hearing

Draft a two-page proposal for one real component. A reviewer must be able to reject it without debating whether Rust is good.

Include:

  1. present failure or constraint, with baseline evidence;
  2. do-nothing and improve-in-place alternatives;
  3. selected responsibility and explicit exclusions;
  4. boundary choice, protocol or ABI, ownership, effects, and failure isolation;
  5. semantic comparison method and approved data handling;
  6. build, deployment, observability, and support integration;
  7. phases with entry gates, authority, abort signals, and owners;
  8. rollback mechanism, data compatibility, rehearsal, and time objective;
  9. performance and resource budgets measured end to end;
  10. training and review capacity required at each phase;
  11. retirement criteria for code, data, dashboards, runbooks, and dependencies.

Use exit criteria that can falsify the plan: named event families show no unexplained semantic differences over an agreed corpus; p99 stays within the approved budget on the production workload; rollback completes within the recovery objective after new-format state exists; two independent reviewers can approve and diagnose the component; the old path remains supported until the retirement gate.

A strong migration proposal makes stopping an acceptable result. The organization is buying information in bounded increments. Once a component crosses the seam successfully, the next constraint is no longer syntax or even architecture. It is the working agreement that keeps review, unsafe obligations, dependencies, and learning from concentrating in the first few experts.

Sources and version notes

  • The Rustonomicon FFI chapter describes current Rust interoperation concerns including C representation, nullable pointers, callbacks, and unwind boundaries. The Rustonomicon identifies itself as incomplete; verify semantic claims against the better-maintained Reference and platform ABI documentation.
  • The Cargo Book on build scripts documents build-script execution, OUT_DIR, native linking instructions, target environment, and links. These are tool behaviors; the seam and migration gates in this chapter are editorial recommendations.
  • The executable fixture is examples/rust-engineering-handbook/part-15/adoption-governance. It uses Rust 2024, declares MSRV 1.85, and contains no third-party dependencies. Its thresholds illustrate explicit gates and must be replaced by system-owned limits.