Java to Rust: The Complete Migration Guide

Everything a Java developer needs to switch to Rust. Ownership vs GC, traits vs interfaces, error handling, concurrency, and the mental model shift that makes it click.

Max Wells

My name is Max and I'm the founder of Rustify.

I've been writing Rust professionally for 2 years and I've helped dozens of engineers make the switch to Rust professionally.

If you want to go further, here's how I can help:

  • Fullstack Bootcamp: structured program with real projects, async support, and private community access.
  • Blockchain Bootcamp: structured program with real projects, async support, and private community access.
  • 1:1 Mentorship: personalized sessions to get you hired faster, with projects tailored to you and mock interviews.

And if you want more Rust content, I post regularly on my YouTube channel:

No spam. Unsubscribe any time.


If you're reading this, you're interested in Rust. Maybe you've even started. Months ago. Maybe longer.

And yet, you're still not there.

Java developers with your exact background are shipping production Rust right now. Working remotely. Being the person in the room others listen to when the conversation turns to what's next.

Most of them will read a guide like this, nod, and go back to their Java ticket. Do you want to stay one of them?

If you're reading this, probably not.

Then this guide is for you.

This guide maps what you already know to how Rust thinks. Ownership vs GC, traits vs interfaces, Result vs exceptions, and the concurrency model that replaces synchronized.


JAVA -> RUST

├── WHAT ALREADY TRANSFERS
│   ├── types + generics (similar syntax)
│   ├── interfaces -> traits (same idea, more power)
│   ├── backend design instincts
│   ├── typed discipline and API design
│   └── OOP patterns (composition replaces inheritance)

├── WHAT IS GENUINELY NEW
│   ├── ownership -- values have one owner, no GC
│   ├── borrowing -- temporary access without ownership
│   ├── no null -- Option<T> instead, enforced by compiler
│   ├── no checked exceptions -- Result<T, E> instead
│   ├── no JVM -- compiled binary, starts in milliseconds
│   └── no inheritance -- composition via traits

└── END STATE
    ├── ship a real Rust API (Axum + SQLx + Tokio)
    ├── eliminate an entire class of production bugs
    └── move from "enterprise backend" to "systems-capable" profile

Part 1 -- Your Head Start

The Type System: What Maps Directly

Java developers have a real advantage here. You already think in types, generics, and interfaces. Rust's type system will feel familiar in structure, stricter in enforcement.

JavaRustNotes
StringString / &str&str = borrowed view, String = owned
int / long / doublei32 / i64 / f64explicit size and signedness
booleanboolidentical
nulldoes not existuse Option<T> instead
Optional<T>Option<T>same idea, but actually enforced
T or throwsResult<T, E>errors are return values, not thrown
CompletableFuture<T>Future<T>async/await syntax works the same
interfacetraitsee below -- more powerful
List<T> / ArrayList<T>Vec<T>heap-allocated, growable
Map<K, V>HashMap<K, V>key-value map
T[][T; N] (fixed) / Vec<T> (dynamic)
void()unit type
generics <T>generics <T>very similar syntax
<T extends Foo><T: Foo>trait bound, same concept
finaldefault (everything is immutable)mut to opt into mutability

The biggest shock for Java developers: in Rust, everything is immutable by default. There is no final keyword because immutability is not the opt-in. Mutability is the opt-in. You write let mut x when you need to change a value. Otherwise it cannot be changed.

Traits vs Interfaces: The Same Idea, Done Better

Java interfaces define a contract. Rust traits do the same. The syntax is different. The power is not.

// Java
interface Describable {
    String describe();
}
 
class User implements Describable {
    public String describe() { return "User: " + name; }
}
// Rust
trait Describable {
    fn describe(&self) -> String;
}
 
impl Describable for User {
    fn describe(&self) -> String {
        format!("User: {}", self.name)
    }
}

The key difference: you can implement a trait on a type you did not write, after the fact.

// Implement a trait on a type from the standard library
impl Describable for Vec<String> {
    fn describe(&self) -> String {
        format!("{} items", self.len())
    }
}

You cannot do this with Java interfaces. A class must declare implements at definition time. Rust traits are retroactive. This makes the ecosystem far more composable: a library crate can implement your trait on its types without either party knowing about the other.

Common traits every Rust backend developer uses daily:

  • Clone -- explicit copying
  • Debug -- print a value for debugging ({:?})
  • Display -- human-readable string output ({})
  • From / Into -- type conversions
  • Serialize / Deserialize (serde) -- JSON, TOML, etc.
  • Error -- custom error types

Most of these can be automatically derived. You will write #[derive(Debug, Clone, Serialize, Deserialize)] on nearly every struct. The compiler writes the boilerplate. You do not.

Enums: What Java Should Have Built

Java enums are constants with methods bolted on. They cannot hold different data per variant. To model a type that is either an ApiError or a DbError or a NotFound, you need an interface, two or three implementing classes, and a lot of boilerplate that everyone on your team will format slightly differently.

Rust enums hold data:

enum AppError {
    NotFound(String),
    Unauthorized,
    DbError { code: i32, message: String },
    Timeout(Duration),
}

Each variant can carry its own payload. Pattern matching on them is exhaustive: the compiler tells you when you have missed a case.

match error {
    AppError::NotFound(resource) => println!("{} not found", resource),
    AppError::Unauthorized => println!("Access denied"),
    AppError::DbError { code, message } => println!("DB {}: {}", code, message),
    AppError::Timeout(d) => println!("Timed out after {:?}", d),
    // forget a variant and the code will not compile
}

Once you have used Rust enums for a month, Java's will feel like a cruel joke. That's usually when my clients stop thinking of Rust as a side project.


You already have the foundation. Here's what makes Rust different from everything you've built on top of it.

Part 2 -- What You're Actually Adding

Ownership: No Garbage Collector

This is the central shift. Everything else in Rust makes sense once this lands.

In Java, the JVM manages memory for you. Objects live on the heap. The GC traces references, finds unreachable objects, and frees them. You never think about it -- until you are debugging a production spike and your flame graph shows the GC thread consuming 40% of CPU at peak load.

In Rust, every value has exactly one owner. When the owner goes out of scope, the value is dropped -- freed from memory immediately, deterministically, at compile time. No GC. No pauses. No heap profiler sessions on a Sunday.

Move semantics: what Java developers expect to work, and does not

In Java, passing an object to a method passes a reference. Both the caller and the method share access to the same object.

In Rust, passing a value to a function moves it. The original is gone. Ownership transferred.

let name = String::from("Alice");
let greeting = make_greeting(name); // ownership of name moves here
 
println!("{}", name); // compiler error: name was moved

The solutions:

// Option 1: borrow -- pass a reference, keep ownership
fn print_name(name: &str) { println!("{}", name); }
 
let name = String::from("Alice");
print_name(&name); // borrow
println!("{}", name); // still here
 
// Option 2: clone -- explicit copy when you genuinely need two owners
let name2 = name.clone();

Most of my clients coming from Java describe the ownership model as the hardest two weeks of the transition, followed by the realization that they have stopped thinking about a whole category of production bug that used to show up silently.

The GC was not free. You were paying for it in latency spikes, memory overhead, and unpredictable pauses. Rust makes that cost zero. That shift is what makes the switch feel permanent: you stop debugging memory issues and start trusting the compiler.

Borrowing: References That the Compiler Tracks

Once you have ownership, borrowing is how you share access without transferring it.

Two rules:

  1. You can have any number of immutable borrows (&T) at the same time.
  2. You can have exactly one mutable borrow (&mut T) and nothing else at the same time.

This is what prevents data races at compile time. In Java, you add synchronized, volatile, ReentrantLock, and hope you got the ordering right. In Rust, the borrow checker rejects the code before it compiles.

When this lands, usually in week two, you'll understand why engineers who've made this switch rarely go back.

let mut v = vec![1, 2, 3];
let first = &v[0];     // immutable borrow
v.push(4);             // compiler error: mutable borrow while first is alive
println!("{}", first);

This is not pedantry. If the push reallocated the vector's buffer, first would point to freed memory. The compiler found the bug before you ran the code.

Error Handling: Goodbye Checked Exceptions

Everyone who has written Java long enough has a story about a catch (Exception e) { log.error("something went wrong") } that swallowed a production error for three months. Checked exceptions are supposed to force you to handle errors. In practice, they train engineers to suppress them.

Rust has no exceptions. Every function that can fail returns Result<T, E>. Errors are regular values. There is no throw, no uncaught exception, no mysterious production crash because someone added a throws declaration and their caller copy-pasted a catch block.

// Java: caller might not know this throws, or might not care
public String readFile(String path) throws IOException { ... }
// Rust: explicit contract, errors are part of the return type
fn read_file(path: &str) -> Result<String, io::Error> { ... }

The ? operator propagates errors up automatically:

fn process() -> Result<String, io::Error> {
    let content = read_file("data.txt")?; // if error, return early with it
    let parsed = parse(content)?;
    Ok(parsed)
}

This is equivalent to the throws chain in Java, except the error type is explicit, the caller is forced to handle it, and there is no way to accidentally swallow it with an empty catch block. Six months from now, this is the section you'll be explaining to the Java developer on your team.

No Inheritance: Composition via Traits

Java OOP is built on inheritance. You extend classes. You override methods. You build hierarchies.

Rust has no inheritance. No extends. No super. No class hierarchy.

This feels like a missing feature until you realize how many Java design patterns exist specifically to work around the problems that inheritance creates: fragile base class problem, diamond inheritance, the need for interfaces to avoid tight coupling.

Rust skips all of it and goes directly to composition. You implement traits on your types. You combine behaviors by implementing multiple traits. No class hierarchy to maintain, no fragile base class to worry about, no need for a factory that returns a factory.

// Instead of class User extends BaseEntity implements Serializable, Auditable
struct User {
    id: i64,
    name: String,
    created_at: DateTime<Utc>,
}
 
#[derive(Debug, Clone, Serialize, Deserialize)]
impl User { ... }
 
impl Auditable for User { ... }

The first instinct of every Java developer in Rust is to recreate the class hierarchy. It never works cleanly. The second instinct is to model behavior with traits and compose. That works well every time.

Concurrency: What "Fearless" Actually Means

Java concurrency: Thread, synchronized, ReentrantLock, volatile, ExecutorService, CompletableFuture. The tools are there. Getting them right requires careful documentation, careful review, and still producing race conditions in production.

Rust concurrency: the ownership model makes data races a compile error.

use std::thread;
use std::sync::{Arc, Mutex};
 
let counter = Arc::new(Mutex::new(0));
 
let handles: Vec<_> = (0..10).map(|_| {
    let counter = Arc::clone(&counter);
    thread::spawn(move || {
        let mut num = counter.lock().unwrap();
        *num += 1;
    })
}).collect();

Arc<Mutex<T>> is the Rust equivalent of a thread-safe shared reference. The difference: the compiler verifies you are using it correctly. You cannot share a value across threads unless it implements Send. You cannot mutate shared state unless you go through the Mutex. Skip these and the code does not compile.

In Java, forgetting a synchronized is a silent runtime bug. In Rust, it is a compile error on Tuesday afternoon instead of a race condition in production on Saturday night. Most Java developers who make the switch call this the moment Rust stopped feeling hard.


Part 3 -- Shipping a Real Axum API

This is where your Java background stops being a comparison point and starts being an advantage.

Maven, Gradle, and the XML You Are About to Leave Behind

Java build tooling is genuinely painful. Maven's pom.xml can reach 300 lines for a medium project. Gradle replaced the XML with Groovy DSL, then Kotlin DSL, and somewhere in the middle you needed a plugin that required another plugin that had not been updated since 2021.

Cargo is one file. No XML. No plugins that require other plugins. No separate wrapper scripts.

[package]
name = "my-api"
version = "0.1.0"
edition = "2021"
 
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio"] }
serde = { version = "1", features = ["derive"] }

That is it. cargo build fetches dependencies, compiles everything, and produces a binary. No separate install step, no classpath to manage, no wrapper script to generate before you can run anything.

Maven / GradleCargoNotes
mvn install / gradle buildcargo buildcompiles everything
mvn packagecargo build --releaseoptimized binary
mvn compilecargo checktype-check only, fast
mvn testcargo testruns all tests
mvn spring-boot:runcargo runrun the app
checkstyle / PMDcargo clippylinter, much more helpful
pom.xmlCargo.tomlmanifest

Run cargo clippy constantly. It is the code reviewer you always wanted but could not afford.

The Crates You Will Actually Use

PurposeCrateJava equivalent
HTTP serveraxumSpring MVC / Spring Boot
Async runtimetokiovirtual threads / executor service
Database (SQL)sqlxJDBC / Hibernate
SerializationserdeJackson
Error handlingthiserror / anyhowcustom exceptions
Env / configdotenvy / configSpring @Value / application.properties
Tracing / loggingtracingSLF4J / Logback
HTTP clientreqwestOkHttp / RestTemplate
Passwordsargon2Spring Security crypto
JWTjsonwebtokenjjwt
ValidationvalidatorBean Validation / Hibernate Validator

Start with these. Learn them properly. Resist the urge to add more.

Axum vs Spring Boot

Spring Boot startup time: 10-30 seconds for a medium project. Axum startup time: under 100ms. Not because Spring Boot is badly written -- because it is doing an enormous amount of reflection, classpath scanning, and bean wiring at runtime. Axum does none of this. Everything is resolved at compile time.

// Spring Boot
@RestController
@RequestMapping("/users")
public class UserController {
    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        return userService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
}
// Axum
async fn get_user(Path(id): Path<i64>, State(db): State<DbPool>) -> impl IntoResponse {
    match db.find_user(id).await {
        Ok(user) => Json(user).into_response(),
        Err(_) => StatusCode::NOT_FOUND.into_response(),
    }
}
 
let app = Router::new()
    .route("/users/:id", get(get_user))
    .with_state(db);

No annotations. No classpath scanning. No reflection. Parameters are typed and extracted at the function signature level. If the path param is not a valid i64, Axum returns a 400 before your handler runs. No @PathVariable and crossed fingers.

Most of my clients coming from Spring Boot are writing real Axum endpoints by day two. The mental model is familiar. The ceremony is gone. Day two. Not week two. That's the Java background paying off.

Project Structure

my-api/
├── Cargo.toml
├── .env
├── migrations/
│   └── 001_init.sql
└── src/
    ├── main.rs          # entry point, router setup, DB pool init
    ├── config.rs        # env vars, app config struct
    ├── errors.rs        # AppError type + IntoResponse impl
    ├── db.rs            # DB connection pool setup

    ├── routes/          # one file per resource
    │   ├── mod.rs
    │   ├── users.rs
    │   └── auth.rs

    ├── models/          # structs that map to DB rows
    │   ├── mod.rs
    │   └── user.rs

    └── services/        # business logic, DB queries
        ├── mod.rs
        └── user_service.rs
Spring Boot conventionRust equivalent
@RestController classesroutes/ files
@Entity / JPA modelsmodels/ structs
@Service classesservices/ modules
@Component / beansexplicit state passed via State(...)
application.properties.env + config.rs

Create errors.rs on day one. Define your AppError type, implement IntoResponse, have every handler return Result<T, AppError>. Retrofitting error handling into an existing Axum codebase is nobody's idea of a good afternoon. The developers who ship think this way from the start.


Part 4 -- The Bigger Picture

The Market Argument

Java developers are better positioned for Rust than most other backgrounds. You already have: typed discipline, backend architecture instincts, SQL fluency, API design experience, and a tolerance for compilers that tell you no. That is most of what Rust requires, minus ownership.

The market for Rust engineers has more open roles than qualified candidates. That gap has been real for three years and is not closing fast. Companies do not hire junior engineers to figure out Rust. They want engineers who already know how to build backend systems and can learn the language. Java developers already know how to build backend systems.

Several of my clients came from Java backgrounds and moved into Rust roles within three to four months of focused practice. The profile they built: backend engineer who also writes Rust. Not a career reset. An upgrade.

If you want to build that profile with real projects and structured feedback, that's what the mentorship is for.

The roles that pay a premium for Rust:

  • infrastructure and developer tooling (CLIs, compilers, build systems)
  • fintech and trading (latency-sensitive, correctness-critical)
  • B2B SaaS rewriting Python or Node services that hit performance walls
  • web3 (Solana and similar ecosystems)

Why Most Java Developers Never Finish the Switch

These are not mistakes that happen to bad developers. They happen to developers who never had someone catch them early.

Trying to recreate class hierarchies

The first instinct in Rust is struct BaseEntity, then impl BaseEntity for User. It does not work cleanly because Rust has no inheritance. Traits are not interfaces bolted onto a hierarchy. They are behavior without hierarchy. Model your domain in data, add behavior via trait implementations, and stop looking for extends.

Cloning to silence the borrow checker

The borrow checker rejects your code. .clone() makes it compile. You move on. This works but you learned nothing. When you clone to silence an error, stop and ask: does this function actually need to own the value, or should it borrow? Nine times out of ten, borrow is the right answer and clone is a band-aid.

Using unwrap() in production code

.unwrap() panics on error. Fine in a prototype. Unacceptable in a production API. Learn ? in your first week and use it everywhere. I have seen this take down production services belonging to engineers who came from Java and assumed Rust's error handling was like Java's -- that something would catch it. Nothing catches a Rust panic. The thread dies.

Carrying over Java's defensive null checks

In Java you write if (user != null) everywhere because null can appear anywhere and the compiler cannot help you. In Rust, Option<User> forces you to handle the absent case at the type level. Express the constraint in the type. Let the compiler enforce it. Stop writing the runtime check.

Fighting the borrow checker with Arc<Mutex<T>> before understanding the model

When the borrow checker rejects something, the Java instinct is to add thread-safe wrappers everywhere. Arc<Mutex<T>> is a legitimate tool but it is not the default. Most borrow checker errors are telling you that the data flow in your code has a real problem. Restructure first. Reach for shared state wrappers only when you genuinely need shared state.

FAQ

How long until I'm productive?

For Java developers: four to six weeks to ship something real if you are focused. The first two weeks are the ownership and borrowing model. After that, progress compounds fast because so much of the rest maps directly to what you already know. The borrow checker stops feeling like a wall and starts feeling like a colleague who catches your bugs before they matter. Four to six weeks. That's the gap between you now and the version of you who introduces himself as a Rust engineer.

Do I need to understand the JVM internals to switch?

No. You need to stop relying on the JVM. The shift is not from JVM internals to Rust internals. It is from "the runtime handles it" to "the compiler guarantees it." Different framing, not deeper knowledge required.

Is Rust good for the same things Spring Boot is good for?

CRUD APIs and business logic backends: yes, fully. Axum with SQLx covers the same ground as Spring Boot with Hibernate, with less ceremony, faster startup, smaller binary, and better performance under load. The ecosystem is smaller but covers the essentials well.

What about Jakarta EE / enterprise patterns?

Most enterprise Java patterns exist to manage complexity that comes from Java's constraints: factories, abstract factories, service locators, singleton beans. Rust does not have those constraints. The patterns that made sense in Java often do not have a Rust equivalent -- because the problem they were solving does not exist. Start without the patterns. Add structure when you actually need it.

Can I keep using SQL, or do I need an ORM?

SQLx is the standard: async, compile-time verified queries, no ORM magic. Your SQL knowledge transfers directly. The queries look like what you would write in JDBC, but the compiler verifies them against the database schema at build time. Hibernate's lazy loading surprises and N+1 query problems do not exist here because there is no ORM making decisions behind your back.

Should I learn Go instead? It's closer to Java.

Go is genuinely easier to learn. It is also an easier skill to find. The supply of Go developers is growing fast. Rust requires more investment but the supply of qualified engineers is structurally low -- and that gap is not closing. Both are legitimate choices. The question is whether you want to minimize ramp-up time or maximize differentiation.

Every developer I work with chose differentiation. None of them have regretted it.


There are two types of Java developers who read a guide like this.

The first one closes it, saves the link, and tells himself he'll come back when he has more time. Two years from now he's writing the same Spring Boot services. He watches people around him make the move. He nods when Rust comes up in conversation.

The second one decides this is the guide that marks the before and after. He ships something in Rust inside a month. He stops introducing himself as a Java developer. He applies to roles he would have scrolled past six months ago.

The difference is not skill. It is not time. It is a decision made right now.

Every year you stay in the first category costs real money. Rust engineers in Europe earn €95k to €130k. Senior Java developers in the same market earn €65k to €85k. The gap is €30k to €45k per year. Every year of inaction is that gap, compounding silently.

The developer who made the switch looks like this.

When the borrow checker rejects his code, he reads the error. He doesn't spiral. He restructures. He knows the compiler is not his enemy. It's the most honest feedback he's ever gotten on a codebase.

When a recruiter asks about his background, he doesn't lead with Java as an apology. He says: "I spent years building production backends. Now I do that in Rust."

When he sees a role that's slightly above his current comfort zone, he applies. He doesn't wait to feel ready. He figures it out after he gets the interview.

When he negotiates, he negotiates in euros. Not "is this reasonable for someone coming from Java?" His background is an asset. Not a discount.

That version of you is three months of focused work away.

You already know which category you want to be in. If you're still reading, you're probably already in the second one.

You just need someone to help you close the gap faster.

Book a call →


If you want to go from reading this to actually shipping Rust professionally, with real projects, structured feedback, and a faster path through ownership, async, Axum, and SQLx, that's exactly what Rustify is built for:

  • Fullstack Bootcamp: structured program with real projects, async support, and private community access.
  • Blockchain Bootcamp: structured program with real projects, async support, and private community access.
  • 1:1 Mentorship: personalized sessions to get you hired faster, with projects tailored to you and mock interviews.

No spam. Unsubscribe any time.

Student Success Stories

Hear from engineers who built real Rust projects with Rustify

Arik Dutta

Arik Dutta

Technical Lead · Low-code & Python → Rust

Tiago Afonso

Tiago Afonso

Fullstack Developer

Ugo Tiberto

Ugo Tiberto

Rust Engineer · Fullstack Developer

Your Future Awaits

Join our 9-week self-paced bootcamp and go from one language to production Rust. Learn through hands-on projects and daily async support.