Skip to content
rusthacks.dev
Rust

Rust for Java Developers a Practical Translation Guide

Java developers know strong typing and tooling. This guide translates classes, interfaces, generics, exceptions and Maven into their Rust equivalents, and shows what changes without a JVM.

17 min read
Summarize in AI
Flat illustration for the post “Rust for Java Developers a Practical Translation Guide”, composed around generics.

Java and Rust both push you toward explicit types and away from sloppy dynamic code, and a Java developer reading Rust will recognise generics, interfaces, immutability by convention and a build tool with a central repository. The differences are structural rather than syntactic: there is no virtual machine, no garbage collector, no inheritance and no null. Each absence forces a different way of writing the same program, and most of the early friction comes from carrying a Java reflex across the border and having the compiler stop it.

This guide goes through those reflexes one at a time. The Rust examples were compiled and run on Rust 1.99 with edition 2024 and the standard library only. The Java examples ran on OpenJDK 17.0.1 with the single-file source launcher, so any Java feature newer than 17 is called out where it matters. The timings come from an Apple M1 Max.

The translation table

JavaRustWhat changes
classstruct plus implData and methods are declared separately
interfacetraitCan be implemented for types you did not write
inheritancecomposition, traits, enumsNo extends between types
sealed interface and recordsenumThe compiler checks every case is handled
checked exceptionResult<T, E>Failure is a return value
null and OptionalOption<T>The only form of absence in safe code
garbage collectorownershipValues are freed when their owner leaves scope
object referencesmoves and borrowsAssignment can transfer ownership
StreamsiteratorsLazy, and fused into one loop by the compiler
Maven or GradleCargoBuild, test, format and publish through one tool
JVM bytecodenative binaryNo runtime to install

The rest of the post takes the interesting rows in the order that causes the most trouble.

Classes become structs, and the interface moves out

A Java class holds fields and methods in one block and takes part in a hierarchy. A Rust struct holds only fields. Methods go in an impl block, and shared behaviour goes in a trait. The next program shows a generic repository, the kind of thing a Java service has in every layer.

rust
use std::fmt::Debug;

trait Repository<T> {
    fn save(&mut self, item: T) -> usize;
    fn find(&self, id: usize) -> Option<&T>;
}

#[derive(Default)]
struct InMemory<T> {
    items: Vec<T>,
}

impl<T> Repository<T> for InMemory<T> {
    fn save(&mut self, item: T) -> usize {
        self.items.push(item);
        self.items.len() - 1
    }

    fn find(&self, id: usize) -> Option<&T> {
        self.items.get(id)
    }
}

fn demo<R: Repository<String>>(repo: &mut R) {
    let id = repo.save("first".to_string());
    println!("saved at {id}, found {:?}, missing {:?}", repo.find(id), repo.find(99));
}

fn show_all<T: Debug>(repo: &InMemory<T>) {
    for item in &repo.items {
        println!("- {item:?}");
    }
}

fn main() {
    let mut repo = InMemory::default();
    demo(&mut repo);
    repo.save("second".to_string());
    show_all(&repo);
}
Text
saved at 0, found Some("first"), missing None
- "first"
- "second"

The trait Repository<T> is the interface: two methods, generic over the stored type. InMemory<T> is the class, and the separate impl<T> Repository<T> for InMemory<T> block is where it claims to satisfy the contract. The method find returns Option<&T>, a borrowed reference into the vector or nothing, where a Java version would return T or null, or an Optional<T> that allocates. In demo, the bound R: Repository<String> is the same as Java's <R extends Repository<String>>. In the output you can see the three results: the id of the first save, the Some("first") found, and None for the id that does not exist.

The detail that surprises Java developers is that the trait impl and the type are separate. You can implement your own trait for Vec<u8> or a type from a library, with one restriction called the orphan rule: either the trait or the type must be yours. Java cannot do this, because a class declares its interfaces when it is written. The consequence is that adapters and wrappers, a large share of Java enterprise code, mostly disappear. Traits explained has the full model, and structs and methods covers the mechanics of the impl block.

Generics also work differently under the hood. Java erases type parameters at compile time, so a List<String> and a List<Integer> are the same class at runtime, and primitives need boxing. Rust compiles a separate copy of InMemory<String> and InMemory<u32>, with no boxing and direct calls the optimiser can inline. The price is compile time and binary size, which monomorphization explains.

Reference semantics: the first thing the compiler rejects

In Java every object variable is a reference, and passing it somewhere copies the reference, not the object. Putting an order into a customer's list and continuing to use the order afterwards is entirely normal. In Rust, pushing the order into the vector moves it.

rust
#[derive(Debug)]
struct Order {
    id: u32,
    total_cents: i64,
}

struct Customer {
    name: String,
    orders: Vec<Order>,
}

fn main() {
    let mut ada = Customer { name: "ada".to_string(), orders: Vec::new() };
    let order = Order { id: 1, total_cents: 4_250 };
    ada.orders.push(order);
    println!("{} placed order {}", ada.name, order.id);
}
Text
error[E0382]: borrow of moved value: `order`
  --> src/main.rs:16:46
   |
14 |     let order = Order { id: 1, total_cents: 4_250 };
   |         ----- move occurs because `order` has type `Order`, which does not implement the `Copy` trait
15 |     ada.orders.push(order);
   |                     ----- value moved here
16 |     println!("{} placed order {}", ada.name, order.id);
   |                                              ^^^^^^^^ value borrowed here after move
   |
note: if `Order` implemented `Clone`, you could clone the value
  --> src/main.rs:2:1
   |
 2 | struct Order {
   | ^^^^^^^^^^^^ consider implementing `Clone` for this type
...
15 |     ada.orders.push(order);
   |                     ----- you could clone this value

For more information about this error, try `rustc --explain E0382`.

The compiler states the rule in its own terms: order has type Order, which is not Copy, so push took it, and the next line tries to read from something that now lives inside ada.orders. The note suggests Clone as an escape hatch. Cloning works, and it is also the Java habit of defensive copying, now visible and paid for. The better fix is to change the shape of the code so ownership is clear: the customer owns its orders, and a method that adds one can hand back a borrow of what it just stored.

rust
#[derive(Debug)]
struct Order {
    id: u32,
    total_cents: i64,
}

struct Customer {
    name: String,
    orders: Vec<Order>,
}

impl Customer {
    fn place(&mut self, order: Order) -> &Order {
        self.orders.push(order);
        self.orders.last().expect("just pushed")
    }
}

fn main() {
    let mut ada = Customer { name: "ada".to_string(), orders: Vec::new() };
    let placed = ada.place(Order { id: 1, total_cents: 4_250 });
    println!("order {} for {} cents", placed.id, placed.total_cents);
    println!("{} has {} order(s): {:?}", ada.name, ada.orders.len(), ada.orders);
}
Text
order 1 for 4250 cents
ada has 1 order(s): [Order { id: 1, total_cents: 4250 }]

The method place takes ownership of the order, pushes it, and returns &Order pointing at the element in the vector. The expect("just pushed") documents why the None case cannot occur, instead of using a bare unwrap. The caller reads the id and total through that borrow, and afterwards reads ada.orders again; the borrow has ended by then, so there is no conflict. This is the pattern to learn: the owner holds the data, and everybody else borrows it for the shortest time they can.

Two things trip Java developers here. First, if you really need two owners, for example a graph with shared nodes, you reach for Rc (single thread) or Arc (across threads), which are reference-counted pointers, and the cost is explicit on the type. Box, Rc and Arc compares them. Second, storing an identifier instead of a pointer, such as an order id in a HashMap, is the more common answer in Rust and what a relational design would give you anyway. The bidirectional parent and child pointers common in Java object models are the thing to redesign. Ownership explained works through the rule from first principles.

There is no null

A NullPointerException is the Java error everyone has met. In Rust, safe code has no null reference at all, and a value that may be missing has type Option<T>. First the Java behaviour, since it is the thing being replaced.

java
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class Npe {
    public static void main(String[] args) {
        Map<String, String> emails = new HashMap<>();
        emails.put("ada", "[email protected]");
        Optional<String> bob = Optional.ofNullable(emails.get("bob"));
        System.out.println(bob.map(String::toUpperCase).orElse("no bob"));
        try {
            System.out.println(emails.get("bob").toUpperCase());
        } catch (NullPointerException e) {
            System.out.println("NPE at runtime: " + e.getMessage());
        }
    }
}
Text
no bob
NPE at runtime: Cannot invoke "String.toUpperCase()" because the return value of "java.util.Map.get(Object)" is null

That program looks up a missing key. The first line uses Optional, which works and which the caller has to remember to use; the second line calls a method on the raw return value and fails at runtime with a message that names the call. Here is the equivalent mistake in Rust.

rust
use std::collections::HashMap;

fn main() {
    let mut emails: HashMap<&str, &str> = HashMap::new();
    emails.insert("ada", "[email protected]");

    let bob = emails.get("bob").map(|e| e.to_uppercase());
    println!("{}", bob.unwrap_or_else(|| "no bob".to_string()));

    println!("{}", emails.get("bob").to_uppercase());
}
Text
error[E0599]: no method named `to_uppercase` found for enum `Option<T>` in the current scope
  --> src/main.rs:10:38
   |
10 |     println!("{}", emails.get("bob").to_uppercase());
   |                                      ^^^^^^^^^^^^ method not found in `Option<&&str>`
   |
note: the method `to_uppercase` exists on the type `&&str`
  --> /rustc/b940084d7eb6a299eb4bfeb8e34901bc051e7ac4/library/alloc/src/str.rs:648:4
help: consider using `Option::expect` to unwrap the `&&str` value, panicking if the value is an `Option::None`
   |
10 |     println!("{}", emails.get("bob").expect("REASON").to_uppercase());
   |                                     +++++++++++++++++

For more information about this error, try `rustc --explain E0599`.

This is not a runtime exception. It does not compile. HashMap::get returns Option, and an Option does not have a to_uppercase method, so the program is rejected before it exists. The help line suggests expect, which would turn the problem back into a panic, so treat that suggestion with caution: the real fix is to handle the missing case, as the first lines of that program already did with map and unwrap_or_else. Here is the corrected program.

rust
use std::collections::HashMap;

fn main() {
    let mut emails: HashMap<&str, &str> = HashMap::new();
    emails.insert("ada", "[email protected]");

    for name in ["ada", "bob"] {
        let shown = emails.get(name).map(|e| e.to_uppercase());
        println!("{name}: {}", shown.unwrap_or_else(|| "no address".to_string()));
    }
}
Text
ada: [email protected]
bob: no address

The loop looks up two names, and map applies the transformation only when a value exists, which is exactly Optional.map. The difference from Java is that you cannot opt out: there is no way to forget the check, because the type refuses to compile without it. Option explained lists the combinators.

Exceptions become values

Checked exceptions were Java's attempt to put failure in the signature, and many teams ended up wrapping or swallowing them. Rust puts failure in the return type with no special syntax. A function returns Result<T, E>, and the ? operator returns early from the function if the value is an error, converting its type on the way if needed.

rust
use std::fs;
use std::io;

fn read_port(path: &str) -> Result<u16, Box<dyn std::error::Error>> {
    let text = fs::read_to_string(path)?;
    let port = text.trim().parse::<u16>()?;
    Ok(port)
}

fn main() {
    fs::write("port.txt", "8080\n").unwrap();
    fs::write("badport.txt", "http\n").unwrap();
    for path in ["port.txt", "badport.txt", "gone.txt"] {
        match read_port(path) {
            Ok(p) => println!("{path}: port {p}"),
            Err(e) => {
                let kind = if e.downcast_ref::<io::Error>().is_some() { "io" } else { "parse" };
                println!("{path}: {kind} error: {e}");
            }
        }
    }
}
Text
port.txt: port 8080
badport.txt: parse error: invalid digit found in string
gone.txt: io error: No such file or directory (os error 2)

The function read_port has two failure sources, a file read and an integer parse, and both are handled by a trailing ?. Its error type, Box<dyn std::error::Error>, is the Rust counterpart of catching Exception: a catch-all that any error converts into. In main, downcast_ref::<io::Error> asks which kind it was, which is the instanceof check you would have written after a Java catch. The three results are the success, the parse error with the standard library's own message, and the missing-file error with the operating system's text.

In real code you replace the boxed error with an enum of your own so callers can match on the cause, and a crate such as thiserror removes the boilerplate. The trade-off is in error handling in Rust. Unchecked exceptions have a counterpart too: panic! unwinds the thread, and it is reserved for bugs, not for control flow.

Sealed hierarchies become enums

Java 17 introduced sealed interfaces and records, which let you model a closed set of shapes. This is the nearest Java gets to a Rust enum.

java
import java.util.List;

public class Shapes {
    sealed interface Shape permits Circle, Rect {}
    record Circle(double r) implements Shape {}
    record Rect(double w, double h) implements Shape {}

    static double area(Shape s) {
        if (s instanceof Circle c) return Math.PI * c.r() * c.r();
        if (s instanceof Rect r) return r.w() * r.h();
        throw new IllegalStateException("unreachable only by convention: " + s);
    }

    public static void main(String[] args) {
        for (Shape s : List.of(new Circle(1.5), new Rect(2.0, 3.5))) {
            System.out.printf("%s -> %.3f%n", s, area(s));
        }
    }
}
Text
Circle[r=1.5] -> 7.069
Rect[w=2.0, h=3.5] -> 7.000

Three things to note. The records give you the data classes without boilerplate. The sealed interface restricts implementers to the two listed. And the dispatch is a chain of instanceof checks with a fallthrough that throws, because switch patterns were still a preview feature in JDK 17 and became final in Java 21. The weakness is visible: add a third shape and nothing forces you to update area.

Text
Circle[r=1.5] -> 7.069
Rect[w=2.0, h=3.5] -> 7.000
Exception in thread "main" java.lang.IllegalStateException: unreachable only by convention: Triangle[b=3.0, h=4.0]
	at ShapesBad.area(ShapesBad.java:12)
	at ShapesBad.main(ShapesBad.java:17)

That is the result of adding a Triangle record to the same program. It compiles, and it fails at runtime when the new shape reaches the old function. Java 21's exhaustive switch closes this gap. Rust has had the equivalent from the start, with an enum and a match.

rust
#[derive(Debug)]
enum Shape {
    Circle { r: f64 },
    Rect { w: f64, h: f64 },
}

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle { r } => std::f64::consts::PI * r * r,
        Shape::Rect { w, h } => w * h,
    }
}

fn main() {
    for s in [Shape::Circle { r: 1.5 }, Shape::Rect { w: 2.0, h: 3.5 }] {
        println!("{s:?} -> {:.3}", area(&s));
    }
}
Text
Circle { r: 1.5 } -> 7.069
Rect { w: 2.0, h: 3.5 } -> 7.000

The variants carry their own fields, the match destructures them, and the output agrees with the Java program to three decimals. Now add the third variant and leave the match alone.

rust
#[derive(Debug)]
enum Shape {
    Circle { r: f64 },
    Rect { w: f64, h: f64 },
    Triangle { base: f64, height: f64 },
}

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle { r } => std::f64::consts::PI * r * r,
        Shape::Rect { w, h } => w * h,
    }
}

fn main() {
    println!("{}", area(&Shape::Triangle { base: 3.0, height: 4.0 }));
}
Text
error[E0004]: non-exhaustive patterns: `&Shape::Triangle { .. }` not covered
  --> src/main.rs:9:11
   |
 9 |     match s {
   |           ^ pattern `&Shape::Triangle { .. }` not covered
   |
note: `Shape` defined here
  --> src/main.rs:2:6
   |
 2 | enum Shape {
   |      ^^^^^
...
 5 |     Triangle { base: f64, height: f64 },
   |     -------- not covered
   = note: the matched value is of type `&Shape`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
   |
11 ~         Shape::Rect { w, h } => w * h,
12 ~         &Shape::Triangle { .. } => todo!(),
   |

For more information about this error, try `rustc --explain E0004`.

The compiler refuses and names the missing case, Shape::Triangle, along with the line where the variant was defined. The help suggests a todo!() arm, which is the placeholder to use during a refactor, since it compiles and then panics loudly if reached. That error is the main reason enums replace class hierarchies in Rust: every function that depends on the set of cases is checked when the set changes. Keep a trait for the situation where the set is open and other crates add types, and use an enum when you control every case. Enums explained and the visitor pattern in Rust go further, the latter being a good read for anyone who has written a Java visitor and wondered what the point was.

Streams are iterators

The pipeline vocabulary is almost the same: filter, map, collect, sum. First the Java.

java
import java.util.List;
import java.util.stream.Collectors;

public class Streams {
    public static void main(String[] args) {
        List<String> words = List.of("rust", "python", "go", "java", "c", "zig");
        List<Integer> lengths = words.stream().filter(w -> w.length() > 2).map(String::length).collect(Collectors.toList());
        System.out.println(lengths);
        System.out.println(words.stream().mapToInt(String::length).sum());
    }
}
Text
[4, 6, 4, 3]
20

And the Rust.

rust
fn main() {
    let words = ["rust", "python", "go", "java", "c", "zig"];
    let lengths: Vec<usize> = words.iter().filter(|w| w.len() > 2).map(|w| w.len()).collect();
    println!("{lengths:?}");
    println!("{}", words.iter().map(|w| w.len()).sum::<usize>());
}
Text
[4, 6, 4, 3]
20

Both print the same two lines. The structural difference is that a Java stream allocates lambda objects and relies on the JIT to inline them, while Rust closures are anonymous structs the compiler inlines directly, and the whole chain becomes one loop with no intermediate collection. Rust also has no parallelStream() in the standard library; the rayon crate provides par_iter with the same shape. Iterators explained shows when each stage runs.

Threads, locks and the memory model

Java's synchronized, ReentrantLock and ExecutorService have counterparts, but the checking differs. Rust marks types that are safe to move between threads (Send) and safe to share (Sync), and the compiler rejects a program that violates either, so a data race is a compile error. A Mutex in Rust owns the value it guards, so the only way to read the field is through the lock. In Java the lock and the field are separate, and nothing stops a method from touching the field without it. For services, the standard library gives you threads, and for many thousands of connections you add an async runtime (the Tokio runtime explained), whose closest Java relative is virtual threads from Java 21. Send and Sync explained gives the rules.

What you get without a runtime, measured

Dropping the JVM changes three things you can measure: startup, memory per object and steady throughput. I measured the first two directly.

For startup I launched a hello world program twenty times in a loop for each. The Java launch took about 31 ms each with the JVM's default settings, the Rust binary about 13 ms, and that figure includes the shell's own process-spawn time, so the true gap is somewhat larger. Peak resident memory for the trivial program was 37 MB for the JVM and 1.5 MB for the Rust binary. For a long-running service the startup difference is irrelevant, and for command-line tools, functions and anything started per request it is the whole story.

For memory per element I built a list of twenty million 64-bit integers and summed it, in ArrayList<Long> on one side and Vec<i64> on the other. Three runs each:

MeasureJava 17 ArrayList<Long>Rust 1.99 Vec<i64>
Time to build and sum340 to 416 ms44 to 50 ms
Peak resident memoryabout 875 MBabout 164 MB

The Rust figure is the arithmetic you expect: twenty million times eight bytes is 160 MB plus a little overhead. The Java list stores a reference to a separate heap object for every number, so each element costs a pointer, an object header and its value, and the collector has to walk all of them. That is roughly five times the memory and eight times the time. A fair objection is that real Java code uses long[] or a primitive collection library for exactly this case, and the gap closes sharply when it does. The test shows what the default idiom costs, not the ceiling for a tuned Java program. Peak throughput on long-running code is a different comparison again: the JIT optimises with runtime knowledge Rust's ahead-of-time compiler does not have, and on some server workloads a warmed JVM matches native code. I did not measure that here.

Tooling: Cargo does what Maven plus plugins did

Maven or GradleCargo
pom.xml or build.gradleCargo.toml, short and declarative
local repository and lock pluginsCargo.lock, committed for binaries
mvn testcargo test, with unit tests in the source file
Checkstyle, Spotlesscargo fmt and cargo clippy
multi-module projectworkspace
fat JAR plus a JREone executable

The main practical change is the first build. Cargo compiles dependencies from source rather than downloading JARs, so a fresh checkout takes longer, and later incremental builds are fast. Use cargo check in the editing loop, since it type-checks without generating machine code.

When to stay on the JVM

A Java team should not read this and plan a rewrite. The honest cases for staying are strong.

  • Mature platforms and ecosystems. Spring, the data-access stack, observability integrations and a large pool of engineers who know them are an asset, and none of them transfers. Rewriting a working service for a speed gain it does not need is the most common expensive mistake.
  • Long-running services where the JIT has warmed up. Peak throughput is competitive and developer productivity is high. The memory overhead is real, and it is usually cheaper than the engineering time to reduce it.
  • Heavy reflection and runtime plugin models. Rust has no reflection by default; the equivalent is generated at compile time with macros, which is a different architecture. If your design leans on classpath scanning and dynamic proxies, expect to redesign it.
  • Teams without time to learn ownership. The compiler messages above are good, and the first month is still slower than writing Java.

Rust earns the cost where predictable memory, start-up time or a native binary is the requirement: command-line tools, sidecars, functions that start per request, embedded or latency-sensitive components, and libraries that must be called from several languages. A common pattern is not a rewrite but a single Rust component behind a clear boundary, called through FFI or a socket, with the rest of the system staying in Java.

A sensible way in

Port one self-contained utility you already know, such as a log parser or a batch converter, rather than a framework application. Model the domain as enums and structs first, return Result from everything that can fail, and borrow in parameters while owning in the data structures. Read each ownership error to the end; the one above is the same lesson you will learn repeatedly. When the basics hold, the visitor pattern and the trait material are the next place a Java background pays off, and the Tokio runtime is where service work starts.

Taggedrustjava
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.